Files
Webklar-Kundenbereich/server/services/repoMove.js
T
ClaudeandClaude Opus 5.5 6718018211 Verwaiste Repo-Platzhalter blockieren keine Zuordnung mehr
Ein in Gitea geloeschtes Org-Repo liess sein unverknuepftes Projekt-Dokument
liegen. Der Unique-Index auf repoFullName ignoriert Gross-/Kleinschreibung,
darum scheiterte beim Zuweisen eines gleich benannten neuen Repos das
Umbenennen des Projekts; alle Pushes landeten im Platzhalter ohne Ticket,
also ohne Arbeitsblatt und ohne Push-Zeitmail (WEBklar/Schmacko-napf).

- repoMove: vor dem Umbenennen ein unverknuepftes fremdes Dokument mit dem
  Zielnamen entfernen (verknuepfte bleiben unangetastet)
- Webhook repository/deleted: unverknuepfte Platzhalter des geloeschten
  Repos entfernen, nur wenn Gitea das Repo wirklich nicht mehr kennt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:58:11 +00:00

392 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
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)
}