Files
Webklar-Kundenbereich/server/services/repoMove.js
T
2026-09-30 10:21:34 +00:00

209 lines
8.0 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, Query } from './appwriteAdmin.js'
import { invalidateProjectCache } from './projectInsights.js'
import { previewWorkDir } from './previewDeploy.js'
/** Warnungen, wörtlich (siehe Plan, Abschnitt "Global Constraints"). */
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.`,
}
/** Portal-Webhook-URL: ein Einzel-Webhook gilt als Portal-Webhook, wenn seine URL exakt damit beginnt. */
function portalWebhookUrl() {
return `${config.publicBaseUrl}/webhook/gitea`
}
/** 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()
}
/**
* 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) => !String(hook.url || '').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}).`] }
}
/** Schritt 6: Projektdokument auf den neuen Repo-Namen nachziehen. */
async function updateProjectDocument(project, 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(),
})
}
/** Schritt 8: alte Portal-Webhooks am neuen Ort loeschen. Fehler werden nur geloggt. */
async function deletePortalHooks(to) {
const portalUrl = portalWebhookUrl()
let hooks
try {
hooks = await giteaFetch(`/repos/${to}/hooks`)
} catch (err) {
console.error(`[repoMove] Webhooks von ${to} konnten nicht gelesen werden:`, err.message)
return
}
for (const hook of hooks || []) {
if (!String(hook.url || '').startsWith(portalUrl)) continue
try {
await giteaFetch(`/repos/${to}/hooks/${hook.id}`, { method: 'DELETE' })
} catch (err) {
console.error(`[repoMove] Webhook ${hook.id} bei ${to} konnte nicht geloescht werden:`, err.message)
}
}
}
/**
* 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.
if (isOrgRepo(from)) return { moved: null, warnings: [] }
// Schritt 2: aktuellen Stand holen (fetch folgt Giteas 307-Weiterleitung von Hand verschobener Repos).
let current
try {
current = await giteaFetch(`/repos/${from}`)
} 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: [] }
}
// 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
try {
await updateProjectDocument(project, confirmedTo)
} catch (err) {
return { failed: 'doc', to: confirmedTo, err }
}
return { to: confirmedTo }
})
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
// Schritt 7: Cache fuer alten und neuen Namen verwerfen.
invalidateProjectCache(from)
invalidateProjectCache(confirmedTo)
// Schritt 8: alten Portal-Webhook am neuen Ort loeschen (die Organisation hat einen eigenen).
await deletePortalHooks(confirmedTo)
// Schritt 9: Preview-Arbeitsverzeichnis des alten Namens aufraeumen.
await fs.rm(previewWorkDir(from), { recursive: true, force: true })
return { moved: { from, to: confirmedTo }, warnings: [] }
}