209 lines
8.0 KiB
JavaScript
209 lines
8.0 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, 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: [] }
|
||
}
|