Umzug zugewiesener Repos nach WEBklar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 10:21:34 +00:00
1 parent 51c4852488
commit 6e6c9a4c24
4 files changed
+369 -1

No files matched your search

+6 -1
View File
@@ -307,6 +307,11 @@ export function buildPreviewUrl(subdomain) {
return `https://${subdomain}.${config.preview.baseHost}`
}
/** Arbeitsverzeichnis fuer den flachen Klon eines Repos beim Preview-Deploy. */
export function previewWorkDir(repoFullName) {
return path.join(process.cwd(), 'preview-data', repoFullName.replace(/\//g, '_'))
}
/**
* Schreibt pro deployter Preview-Subdomain einen Traefik-Router mit Host()-Regel
* nach preview-hosts.yml, damit Let's Encrypt (HTTP-01) gueltige Zertifikate
@@ -421,7 +426,7 @@ export async function runDeploy(repoFullName, branch, previewConfig) {
const subdomain = previewConfig.subdomain || repoFullName.split('/').pop()
const cloneUrl = `${config.gitea.baseUrl}/${repoFullName}.git`
const workDir = path.join(process.cwd(), 'preview-data', repoFullName.replace(/\//g, '_'))
const workDir = previewWorkDir(repoFullName)
const targetDir = path.join(config.preview.deployRoot, subdomain)
await cloneRepo(cloneUrl, workDir, branch, config.gitea.apiToken)
+208
View File
@@ -0,0 +1,208 @@
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: [] }
}