- Neues Projekt ohne Subdomain: subdomain null statt '' (Unique-Index subdomain_unique, jedes weitere scheiterte mit "Document with the requested ID already exists"); ebenso Typ "Internes Projekt" - Anlegen unter derselben Repo-Sperre wie der Webhook repository/created; upsertWebsiteProjectByRepo macht aus einem 409 ein Update - Gitea-Beschreibung = Anzeigename; Art (kind) beim Anlegen aus dem Ticket - Reaktivieren setzt status zurueck (vorher blieb 'archived' stehen) - Import: archivierte Repos als archiviert, Archiv-Stand aus Gitea wird gespiegelt, ein fehlerhaftes Repo bricht den Lauf nicht mehr ab - Abgleich ueberschreibt Projektnamen nicht mehr - CORS erlaubt PUT (Preview-Variablen speichern) - Vorlage zieht beim Zuweisen nie in die Org um - Erinnerungs-Lauf ueberspringt archivierte Projekte - kaputte Umlaute (U+FFFD) in drei Fehlermeldungen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
394 lines
17 KiB
JavaScript
394 lines
17 KiB
JavaScript
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)
|
||
}
|