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, deleteDocument, Query } from './appwriteAdmin.js' import { invalidateProjectCache } from './projectInsights.js' import { previewWorkDir } from './previewDeploy.js' /** Warnungen, wörtlich (siehe Plan, Abschnitt "Global Constraints", und Controller-Ruling R11). */ 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.`, hookNotRemoved: (reason) => `Der alte Einzel-Webhook ans Portal konnte nicht entfernt werden (${reason}). Bitte später ‚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` } /** * Ziel-URL eines Gitea-Hooks. Echtes Gitea (Produktions-Swagger von 1.25.2, `definitions.Hook`) * hat dafuer KEIN Top-Level-`url`-Feld - das Ziel steht ausschliesslich unter `config.url`. Ein * Top-Level-`url` zu lesen wuerde in Produktion immer `''` liefern und jedes Repo mit irgendeinem * Hook faelschlich als intern einstufen (siehe Review C1). */ function hookUrl(hook) { return hook?.config?.url || '' } /** 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() } /** owner/repo ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Gitea haelt es ebenso). */ function sameRepoFullName(a, b) { return String(a || '').toLowerCase() === String(b || '').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 // Die Vorlage fuer "Neues Projekt" darf nie umziehen, sonst findet generate sie nicht mehr if (`${config.gitea.templateOwner}/${config.gitea.templateRepo}`.toLowerCase() === lower) return true const hooks = await giteaFetch(`/repos/${repoFullName}/hooks`) const portalUrl = portalWebhookUrl() return (hooks || []).some((hook) => !hookUrl(hook).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}).`] } } /** * Projekt-Dokument ohne jede Verknuepfung (kein Ticket, kein Kunde, keine Art, keine Preview): * ein reiner Platzhalter, wie ihn der Webhook `repository/created` oder der Import anlegt. */ export function isUnlinkedProject(project) { return !project?.ticketId && !project?.customerId && !project?.kind && !project?.subdomain } /** * Gibt den Namen `to` fuer `projectId` frei, falls ihn ein anderes, unverknuepftes Dokument belegt - * z. B. das Ueberbleibsel eines in Gitea geloeschten Repos gleichen Namens. Der Unique-Index auf * `repoFullName` ignoriert Gross-/Kleinschreibung; ohne Freigabe scheitert das Umbenennen, und alle * Pushes landen danach im Platzhalter statt im zugewiesenen Projekt (keine Arbeitsblaetter, keine * Zeitmail). Ein verknuepftes fremdes Dokument bleibt unangetastet - dann scheitert das Update wie bisher. */ async function releaseRepoName(projectId, to) { const others = await listDocuments(config.collections.websiteProjects, [ Query.equal('repoFullName', to), Query.limit(10), ]) for (const other of others) { if (other.$id === projectId || !isUnlinkedProject(other)) continue await deleteDocument(config.collections.websiteProjects, other.$id) console.warn(`[repoMove] Verwaistes Projekt-Dokument ${other.$id} (${other.repoFullName}) entfernt, Name frei für ${projectId}`) } } /** Schritt 6: Projektdokument auf den neuen Repo-Namen nachziehen. */ async function updateProjectDocument(project, to) { await releaseRepoName(project.$id, 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(), }) } /** * Loescht die alten Einzel-Webhooks ans Portal an einem (aktuellen) Repo-Ort - die Organisation * hat inzwischen ihren eigenen Webhook. Ein fehlgeschlagenes Listen wird nur geloggt (kein * Repo-Zustand haengt daran); ein fehlgeschlagenes Loeschen liefert eine Warnung (R11), damit * "Pushes nachtragen" es beim naechsten Versuch erneut probieren kann - sonst bleibt der alte * Webhook fuer immer bestehen und jeder Push wird doppelt zugestellt (siehe Review I3). */ async function deletePortalHooks(repoFullName) { const portalUrl = portalWebhookUrl() let hooks try { hooks = await giteaFetch(`/repos/${repoFullName}/hooks`) } catch (err) { console.error(`[repoMove] Webhooks von ${repoFullName} konnten nicht gelesen werden:`, err.message) return [] } const warnings = [] for (const hook of hooks || []) { if (!hookUrl(hook).startsWith(portalUrl)) continue try { await giteaFetch(`/repos/${repoFullName}/hooks/${hook.id}`, { method: 'DELETE' }) } catch (err) { console.error(`[repoMove] Webhook ${hook.id} bei ${repoFullName} konnte nicht geloescht werden:`, err.message) warnings.push(WARN.hookNotRemoved(err.message)) } } return warnings } /** * Wie `deletePortalHooks`, aber fuer Schritt 1 (isOrgRepo(from)-Zweig): LIST und DELETE laufen mit * `redirect: 'manual'`, damit ein von Hand AUS der Organisation herausbewegtes Repo (der Name * `repoFullName` sieht per Owner noch wie WEBklar aus, Gitea antwortet aber mit einer * Weiterleitung) nicht angefasst wird - eine ganz normale (Folge-)Anfrage wuerde sonst den * Portal-Webhook eines inzwischen fremden Repos loeschen (final-fix Punkt 2). Eine 3xx- oder * 404-Antwort der LIST gilt als "hier nicht (mehr) zu finden" (`{ redirected: true }`); die * Aufloesung des tatsaechlichen aktuellen Namens ist Sache des Aufrufers. */ async function deletePortalHooksStrict(repoFullName) { const portalUrl = portalWebhookUrl() let hooks try { hooks = await giteaFetch(`/repos/${repoFullName}/hooks`, { redirect: 'manual' }) } catch (err) { if (err.status === 404 || (err.status >= 300 && err.status < 400)) return { redirected: true } console.error(`[repoMove] Webhooks von ${repoFullName} konnten nicht gelesen werden:`, err.message) return { redirected: false, warnings: [] } } const warnings = [] for (const hook of hooks || []) { if (!hookUrl(hook).startsWith(portalUrl)) continue try { await giteaFetch(`/repos/${repoFullName}/hooks/${hook.id}`, { method: 'DELETE', redirect: 'manual' }) } catch (err) { console.error(`[repoMove] Webhook ${hook.id} bei ${repoFullName} konnte nicht geloescht werden:`, err.message) warnings.push(WARN.hookNotRemoved(err.message)) } } return { redirected: false, warnings } } /** * Schritt 1 selbst, wenn `deletePortalHooksStrict` eine Weiterleitung meldet: den kanonischen * Namen normal (mit Redirect-Folge) aufloesen. Liegt er AUSSERHALB von WEBklar, wurde das Repo von * Hand herausverschoben - nur das Projektdokument wird nachgezogen, OHNE es automatisch * zurueckzuholen (final-fix Punkt 2). Liegt er weiterhin in WEBklar (z. B. nur umbenannt), wird * dort ganz normal aufgeraeumt. */ async function resolveRedirectedOrgRepo(project, from) { let canonical try { canonical = (await giteaFetch(`/repos/${from}`)).full_name } catch (err) { return classifyGiteaError(err) } if (!isOrgRepo(canonical)) { try { await updateProjectDocument(project, canonical) } catch (err) { return { moved: null, warnings: [WARN.docNotUpdated(err.message)] } } return { moved: null, warnings: [] } } const warnings = await deletePortalHooks(canonical) if (!sameRepoFullName(canonical, from)) { try { await updateProjectDocument(project, canonical) } catch (err) { warnings.push(WARN.docNotUpdated(err.message)) } } return { moved: null, warnings } } /** * Schritte 7-9, idempotent: Cache fuer alten und neuen Namen verwerfen, alte Portal-Webhooks am * neuen Ort loeschen, Preview-Arbeitsverzeichnis des alten Namens aufraeumen. Laeuft unabhaengig * davon, ob das Projektdokument selbst erfolgreich aktualisiert wurde (R11) - der Repo-Zustand in * Gitea ist in jedem Fall schon "in WEBklar", und genau das muss aufgeraeumt werden. `fs.rm` ist * abgesichert (Review I2): ein EACCES/EBUSY/ENOTEMPTY & Co. nach dem schon erfolgten, nicht mehr * rueckgaengig zu machenden Transfer darf `moveRepoToOrg` nicht mehr zum Werfen bringen. */ async function finishMove(from, to) { invalidateProjectCache(from) invalidateProjectCache(to) const warnings = await deletePortalHooks(to) try { await fs.rm(previewWorkDir(from), { recursive: true, force: true }) } catch (err) { console.error(`[repoMove] Preview-Arbeitsverzeichnis von ${from} konnte nicht entfernt werden:`, err.message) } return warnings } /** * Schritte 3-6 (+ Aufraeumen): internes Repo bleibt, Namenskonflikt wird gemeldet, sonst Transfer * + Dokument-Update unter Sperre, danach `finishMove` - unabhaengig davon, ob `moveRepoToOrg` hier * ueber den normalen Weg ankommt oder ueber die Selbstheilung mit dem kanonischen, aber * Nicht-WEBklar-Namen als `from`. */ async function moveViaTransfer(project, from) { // 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 // Ein-Zeiler-Absicherung (final-fix Punkt 3): Gitea kann 2xx melden, ohne das Repo tatsaechlich // verschoben zu haben (z. B. Teil-Fehlschlag). Erst wenn der bestaetigte Name wirklich in // WEBklar liegt, gilt der Transfer als geglueckt - sonst kein Dokument-Update, kein Aufraeumen. if (!isOrgRepo(confirmedTo)) return { failed: 'notMoved' } let docErr = null try { await updateProjectDocument(project, confirmedTo) } catch (err) { docErr = err } return { to: confirmedTo, docErr } }) if (outcome.failed === 'gitea') return classifyGiteaError(outcome.err) if (outcome.failed === 'notMoved') { return { moved: null, warnings: ['Umzug nach WEBklar nicht möglich (Übertragung nicht abgeschlossen).'] } } const confirmedTo = outcome.to // Das Aufraeumen (Cache, alter Webhook, Preview-Verzeichnis) haengt nicht am Dokument-Update - // das Repo liegt in Gitea so oder so schon in WEBklar (R11). const finishWarnings = await finishMove(from, confirmedTo) const warnings = outcome.docErr ? [WARN.docNotUpdated(outcome.docErr.message), ...finishWarnings] : finishWarnings return { moved: { from, to: confirmedTo }, warnings } } /** * 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 - nur noch nach einem liegen gebliebenen Einzel-Webhook // schauen (R11), damit "Pushes nachtragen" auch das noch nachholen kann. Ohne Redirect-Folge // (final-fix Punkt 2), damit ein von Hand herausverschobenes Repo nicht angefasst wird. if (isOrgRepo(from)) { const result = await deletePortalHooksStrict(from) if (result.redirected) return resolveRedirectedOrgRepo(project, from) return { moved: null, warnings: result.warnings } } // Schritt 2: aktuellen Stand holen (fetch folgt Giteas 301-Weiterleitung von Hand verschobener // Repos). Der Vergleich ist ohne Ruecksicht auf Gross-/Kleinschreibung (Review I1) - Gitea // liefert sonst z. B. "JUSN/Case-Test" fuer den gespeicherten Namen "jusn/case-test", ohne dass // sich am Repo tatsaechlich etwas geaendert haette. let current try { current = await giteaFetch(`/repos/${from}`) } catch (err) { return classifyGiteaError(err) } if (!sameRepoFullName(current.full_name, from)) { // Das Repo wurde ausserhalb des Portals umbenannt/verschoben (z. B. von Hand). Den // gespeicherten Namen dafuer korrigieren - unter derselben Sperre wie ein regulaerer Umzug. const canonical = current.full_name const renameOutcome = await withLocks([repoLockKey(from), repoLockKey(canonical)], async () => { try { await updateProjectDocument(project, canonical) return { ok: true } } catch (err) { return { ok: false, err } } }) const renameWarnings = renameOutcome.ok ? [] : [WARN.docNotUpdated(renameOutcome.err.message)] if (isOrgRepo(canonical)) { // Schon in WEBklar (z. B. von Hand verschoben): nur noch aufraeumen. const finishWarnings = await finishMove(from, canonical) return { moved: { from, to: canonical }, warnings: [...renameWarnings, ...finishWarnings] } } // Woanders hin verschoben, aber nicht nach WEBklar: mit dem kanonischen Namen als neuem // Ausgangspunkt tatsaechlich umziehen (Schritte 3-5), statt den Umzug hier abzubrechen. const result = await moveViaTransfer(project, canonical) return { moved: result.moved, warnings: [...renameWarnings, ...result.warnings] } } return moveViaTransfer(project, from) }