import fs from 'node:fs/promises' import { config } from '../config.js' import { giteaFetch, splitRepo } from './giteaClient.js' import { withLocks } from './keyedLock.js' import { listDocuments, updateDocument, Query } from './appwriteAdmin.js' import { invalidateProjectCache } from './projectInsights.js' import { previewWorkDir } from './previewDeploy.js' /** Warnungen, wörtlich (siehe Plan, Abschnitt "Global Constraints"). */ export const WARN = { internal: 'Internes Repo, bleibt wo es ist.', nameTaken: (name) => `In WEBklar gibt es schon ein Repo namens ${name}. Bitte in Gitea umbenennen und dann ‚Pushes nachtragen‘ klicken.`, giteaDown: (reason) => `Gitea ist gerade nicht erreichbar (${reason}). Bitte später ‚Pushes nachtragen‘ klicken.`, docNotUpdated: (reason) => `Das Repo liegt jetzt in WEBklar, aber die Projektdaten konnten nicht angepasst werden (${reason}). Bitte ‚Pushes nachtragen‘ klicken.`, } /** Portal-Webhook-URL: ein Einzel-Webhook gilt als Portal-Webhook, wenn seine URL exakt damit beginnt. */ function portalWebhookUrl() { return `${config.publicBaseUrl}/webhook/gitea` } /** true, wenn `owner/repo` (ohne Ruecksicht auf Gross-/Kleinschreibung) in der Ziel-Organisation liegt. */ export function isOrgRepo(repoFullName) { const [owner] = splitRepo(repoFullName) return String(owner || '').toLowerCase() === config.gitea.org.toLowerCase() } /** Sperr-Schluessel fuer ein Repo (fuer `withLocks`), ohne Ruecksicht auf Gross-/Kleinschreibung. */ export function repoLockKey(repoFullName) { return 'repo:' + repoFullName.toLowerCase() } /** * true, wenn das Repo in `GITEA_KEEP_IN_PLACE` steht (ohne Ruecksicht auf Gross-/Kleinschreibung) * oder einen Webhook hat, dessen URL nicht mit der Portal-Webhook-URL beginnt (Deploy-verdrahtetes * internes Repo). Wirft weiter, wenn die Gitea-Abfrage der Hooks fehlschlaegt - die Einordnung * eines Gitea-Fehlers ist Sache der Aufrufer (siehe `moveRepoToOrg`). */ export async function isInternalRepo(repoFullName) { const lower = repoFullName.toLowerCase() if (config.gitea.keepInPlace.some((entry) => entry.toLowerCase() === lower)) return true const hooks = await giteaFetch(`/repos/${repoFullName}/hooks`) const portalUrl = portalWebhookUrl() return (hooks || []).some((hook) => !String(hook.url || '').startsWith(portalUrl)) } /** * Aktueller `repoFullName`: der gespeicherte Projektstand, falls (noch) aktuell, sonst der Name, * auf den Gitea per Weiterleitung verweist. Liefert `name` unveraendert zurueck, wenn beides nicht * greift (z. B. Gitea nicht erreichbar) - reine Bestpractice-Aufloesung ohne eigene Warnungen. */ export async function currentRepoFullName(name) { const existing = await listDocuments(config.collections.websiteProjects, [ Query.equal('repoFullName', name), Query.limit(1), ]) if (existing[0]) return name try { const repo = await giteaFetch(`/repos/${name}`) return repo.full_name } catch { return name } } /** Netzwerkfehler (kein `.status`) oder 5xx gelten als "Gitea gerade nicht erreichbar". */ function isGiteaDownError(err) { return !err.status || err.status >= 500 } /** * Ordnet einen waehrend Schritt 2-5 aufgetretenen Gitea-Fehler ein (siehe Controller-Ruling R10): * Netzwerkfehler/5xx -> `giteaDown`, alles andere (z. B. unerwartete 403/404) -> ein eigener * Hinweistext, ohne zu werfen. `moveRepoToOrg` darf wegen Gitea-/Appwrite-Problemen nie werfen, * damit eine Zuweisung (Task 6) in jedem Fall bestehen bleibt. */ function classifyGiteaError(err) { if (isGiteaDownError(err)) return { moved: null, warnings: [WARN.giteaDown(err.message)] } return { moved: null, warnings: [`Umzug nach WEBklar nicht möglich (${err.message}).`] } } /** Schritt 6: Projektdokument auf den neuen Repo-Namen nachziehen. */ async function updateProjectDocument(project, to) { const name = splitRepo(to)[1] return updateDocument(config.collections.websiteProjects, project.$id, { repoFullName: to, giteaRepoUrl: `${config.gitea.baseUrl}/${to}`, giteaRepoName: name, updatedAt: new Date().toISOString(), }) } /** Schritt 8: alte Portal-Webhooks am neuen Ort loeschen. Fehler werden nur geloggt. */ async function deletePortalHooks(to) { const portalUrl = portalWebhookUrl() let hooks try { hooks = await giteaFetch(`/repos/${to}/hooks`) } catch (err) { console.error(`[repoMove] Webhooks von ${to} konnten nicht gelesen werden:`, err.message) return } for (const hook of hooks || []) { if (!String(hook.url || '').startsWith(portalUrl)) continue try { await giteaFetch(`/repos/${to}/hooks/${hook.id}`, { method: 'DELETE' }) } catch (err) { console.error(`[repoMove] Webhook ${hook.id} bei ${to} konnte nicht geloescht werden:`, err.message) } } } /** * Verschiebt das Repo eines zugewiesenen Projekts nach `config.gitea.org`, wenn noetig, und haelt * Projektdokument, Cache und Webhooks auf dem Laufenden. Wirft nie wegen eines Gitea- oder * Appwrite-Problems (siehe `classifyGiteaError`/`docNotUpdated`) - Task 6 verlaesst sich darauf, * dass eine Zuweisung auch bei einem Ausfall bestehen bleibt. */ export async function moveRepoToOrg(project) { const from = project.repoFullName // Schritt 1: schon in der Organisation. if (isOrgRepo(from)) return { moved: null, warnings: [] } // Schritt 2: aktuellen Stand holen (fetch folgt Giteas 307-Weiterleitung von Hand verschobener Repos). let current try { current = await giteaFetch(`/repos/${from}`) } catch (err) { return classifyGiteaError(err) } if (current.full_name !== from) { const to = current.full_name try { await updateProjectDocument(project, to) } catch (err) { return { moved: { from, to }, warnings: [WARN.docNotUpdated(err.message)] } } return { moved: { from, to }, warnings: [] } } // Schritt 3: internes (Deploy-verdrahtetes) Repo bleibt, wo es ist. let internal try { internal = await isInternalRepo(from) } catch (err) { return classifyGiteaError(err) } if (internal) return { moved: null, warnings: [WARN.internal] } // Schritt 4: Namenskonflikt in der Organisation pruefen. `candidateTo` ist der Name, den der // Transfer voraussichtlich ergibt - fuer den zweiten Sperr-Schluessel (siehe Controller-Ruling), // bestaetigt wird er erst ueber die full_name der Transfer-Antwort (`confirmedTo` unten). const name = splitRepo(from)[1] const candidateTo = `${config.gitea.org}/${name}` try { await giteaFetch(`/repos/${candidateTo}`) return { moved: null, warnings: [WARN.nameTaken(name)] } } catch (err) { if (err.status !== 404) return classifyGiteaError(err) // 404: der Name ist frei, weiter mit dem Umzug. } // Schritt 5+6: Transfer und Dokument-Update gemeinsam gesperrt (siehe Controller-Ruling). const outcome = await withLocks([repoLockKey(from), repoLockKey(candidateTo)], async () => { let transferResult try { transferResult = await giteaFetch(`/repos/${from}/transfer`, { method: 'POST', body: JSON.stringify({ new_owner: config.gitea.org }), }) } catch (err) { return { failed: 'gitea', err } } const confirmedTo = transferResult.full_name try { await updateProjectDocument(project, confirmedTo) } catch (err) { return { failed: 'doc', to: confirmedTo, err } } return { to: confirmedTo } }) if (outcome.failed === 'gitea') return classifyGiteaError(outcome.err) if (outcome.failed === 'doc') { return { moved: { from, to: outcome.to }, warnings: [WARN.docNotUpdated(outcome.err.message)] } } const confirmedTo = outcome.to // Schritt 7: Cache fuer alten und neuen Namen verwerfen. invalidateProjectCache(from) invalidateProjectCache(confirmedTo) // Schritt 8: alten Portal-Webhook am neuen Ort loeschen (die Organisation hat einen eigenen). await deletePortalHooks(confirmedTo) // Schritt 9: Preview-Arbeitsverzeichnis des alten Namens aufraeumen. await fs.rm(previewWorkDir(from), { recursive: true, force: true }) return { moved: { from, to: confirmedTo }, warnings: [] } }