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:
1 parent
6e6c9a4c24
commit
405b2a0485
4 files changed
+277
-88
No files matched your search
+121
-44
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user