Umzug: Webhook-Adresse aus config.url, Aufräumen auch nach Selbstheilung

Fix round 1 (Review C1/I1/I2/I3, Controller-Ruling R11):
- C1 (kritisch): Hooks haben in echtem Gitea kein Top-Level-url, nur config.url -
  isInternalRepo/deletePortalHooks lasen bisher ein Feld, das es in Produktion nicht gibt.
- I1: Schritt-2-Namensvergleich jetzt ohne Ruecksicht auf Gross-/Kleinschreibung.
- I2: fs.rm in Schritt 9 gegen EACCES/EBUSY/ENOTEMPTY & Co. abgesichert.
- I3: Aufraeumen (Cache, alter Einzel-Webhook, Preview-Verzeichnis) laeuft jetzt ueber den
  neuen idempotenten finishMove()-Baustein in jedem Fall, in dem das Repo tatsaechlich in
  WEBklar liegt - auch nach einem Appwrite-Fehler, bei Selbstheilung und bei einem schon
  dort liegenden Repo (WARN.hookNotRemoved bei fehlgeschlagenem Loeschen).
- Fake Gitea: Hook-Form ohne Top-Level-url, 301 mit relativer Location (auch fuer
  Unterpfade) wie im echten Gitea 1.25.2, failNext() fuer gezielte Fehler in Tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 10:21:34 +00:00
1 parent 6e6c9a4c24
commit 405b2a0485
4 files changed
+277 -88

No files matched your search

+121 -44
View File
@@ -6,7 +6,7 @@ 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"). */
/** Warnungen, wörtlich (siehe Plan, Abschnitt "Global Constraints", und Controller-Ruling R11). */
export const WARN = {
internal: 'Internes Repo, bleibt wo es ist.',
nameTaken: (name) =>
@@ -15,6 +15,8 @@ export const WARN = {
`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. */
@@ -22,6 +24,16 @@ 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)
@@ -33,6 +45,11 @@ 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
@@ -45,7 +62,7 @@ export async function isInternalRepo(repoFullName) {
const hooks = await giteaFetch(`/repos/${repoFullName}/hooks`)
const portalUrl = portalWebhookUrl()
return (hooks || []).some((hook) => !String(hook.url || '').startsWith(portalUrl))
return (hooks || []).some((hook) => !hookUrl(hook).startsWith(portalUrl))
}
/**
@@ -95,56 +112,66 @@ async function updateProjectDocument(project, to) {
})
}
/** Schritt 8: alte Portal-Webhooks am neuen Ort loeschen. Fehler werden nur geloggt. */
async function deletePortalHooks(to) {
/**
* 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/${to}/hooks`)
hooks = await giteaFetch(`/repos/${repoFullName}/hooks`)
} catch (err) {
console.error(`[repoMove] Webhooks von ${to} konnten nicht gelesen werden:`, err.message)
return
console.error(`[repoMove] Webhooks von ${repoFullName} konnten nicht gelesen werden:`, err.message)
return []
}
const warnings = []
for (const hook of hooks || []) {
if (!String(hook.url || '').startsWith(portalUrl)) continue
if (!hookUrl(hook).startsWith(portalUrl)) continue
try {
await giteaFetch(`/repos/${to}/hooks/${hook.id}`, { method: 'DELETE' })
await giteaFetch(`/repos/${repoFullName}/hooks/${hook.id}`, { method: 'DELETE' })
} catch (err) {
console.error(`[repoMove] Webhook ${hook.id} bei ${to} konnte nicht geloescht werden:`, err.message)
console.error(`[repoMove] Webhook ${hook.id} bei ${repoFullName} konnte nicht geloescht werden:`, err.message)
warnings.push(WARN.hookNotRemoved(err.message))
}
}
return 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.
* 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.
*/
export async function moveRepoToOrg(project) {
const from = project.repoFullName
async function finishMove(from, to) {
invalidateProjectCache(from)
invalidateProjectCache(to)
// Schritt 1: schon in der Organisation.
if (isOrgRepo(from)) return { moved: null, warnings: [] }
const warnings = await deletePortalHooks(to)
// Schritt 2: aktuellen Stand holen (fetch folgt Giteas 307-Weiterleitung von Hand verschobener Repos).
let current
try {
current = await giteaFetch(`/repos/${from}`)
await fs.rm(previewWorkDir(from), { recursive: true, force: true })
} 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: [] }
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 {
@@ -180,29 +207,79 @@ export async function moveRepoToOrg(project) {
}
const confirmedTo = transferResult.full_name
let docErr = null
try {
await updateProjectDocument(project, confirmedTo)
} catch (err) {
return { failed: 'doc', to: confirmedTo, err }
docErr = err
}
return { to: confirmedTo }
return { to: confirmedTo, docErr }
})
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
// 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 }
}
// Schritt 7: Cache fuer alten und neuen Namen verwerfen.
invalidateProjectCache(from)
invalidateProjectCache(confirmedTo)
/**
* 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 8: alten Portal-Webhook am neuen Ort loeschen (die Organisation hat einen eigenen).
await deletePortalHooks(confirmedTo)
// Schritt 1: schon in der Organisation - nur noch nach einem liegen gebliebenen Einzel-Webhook
// schauen (R11), damit "Pushes nachtragen" auch das noch nachholen kann.
if (isOrgRepo(from)) {
const warnings = await deletePortalHooks(from)
return { moved: null, warnings }
}
// Schritt 9: Preview-Arbeitsverzeichnis des alten Namens aufraeumen.
await fs.rm(previewWorkDir(from), { recursive: true, force: true })
// 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)
}
return { moved: { from, to: confirmedTo }, warnings: [] }
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)
}