Gesamtprüfung: Zeitlimit für Gitea, Aufräumen ohne Weiterleitung

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 febac3c2a3
commit d5c70a5bd6
7 files changed
+233 -11

No files matched your search

+3
View File
@@ -249,6 +249,9 @@ export const config = {
.filter(Boolean),
// Beim Zuweisen eines Projekts werden Pushes der letzten so-vielen Tage nachgetragen
backfillDays: Number(process.env.GITEA_BACKFILL_DAYS) || 30,
// Zeitlimit fuer jeden Gitea-API-Aufruf (giteaFetch, fetchPreviewConfig); verhindert ein
// haengendes Portal, wenn Gitea nicht antwortet (Controller-Ruling final-fix Punkt 1).
timeoutMs: Number(process.env.GITEA_TIMEOUT_MS) || 30000,
},
cors: {
+18 -4
View File
@@ -1,5 +1,8 @@
import { config } from '../config.js'
/** Zeitlimit fuer jeden Gitea-API-Aufruf, in ms (ueberschreibbar via `GITEA_TIMEOUT_MS`, s. config.js). */
const GITEA_TIMEOUT_MS = config.gitea.timeoutMs
/**
* Baut die Authorization-/Content-Type-Header fuer einen Gitea-API-Aufruf.
* Wirft, wenn GITEA_API_TOKEN nicht gesetzt ist.
@@ -18,12 +21,23 @@ function giteaHeaders() {
* Ruft `${config.gitea.baseUrl}/api/v1<path>` auf und liefert die geparste JSON-Antwort
* (oder null ohne Body). Eine Nicht-2xx-Antwort wirft eine Error mit .status; ein 409
* "same name already exists" wird in die deutsche Meldung uebersetzt (Subdomain-/Repo-Name).
* Bricht nach `GITEA_TIMEOUT_MS` ab (Standard 30 s); der Abbruch wirft eine Error OHNE .status,
* damit repoMove/projectAssignment ihn wie "Gitea nicht erreichbar" einordnen.
*/
export async function giteaFetch(path, options = {}) {
const response = await fetch(`${config.gitea.baseUrl}/api/v1${path}`, {
...options,
headers: { ...giteaHeaders(), ...options.headers },
})
let response
try {
response = await fetch(`${config.gitea.baseUrl}/api/v1${path}`, {
...options,
headers: { ...giteaHeaders(), ...options.headers },
signal: options.signal || AbortSignal.timeout(GITEA_TIMEOUT_MS),
})
} catch (err) {
if (err?.name === 'TimeoutError' || err?.name === 'AbortError') {
throw new Error(`Gitea antwortet nicht (Zeitlimit ${Math.round(GITEA_TIMEOUT_MS / 1000)} s)`)
}
throw err
}
const text = await response.text()
let data = null
+3
View File
@@ -63,6 +63,9 @@ export async function fetchPreviewConfig(repoFullName, branch) {
const url = `${config.gitea.baseUrl}/api/v1/repos/${owner}/${repo}/contents/.webklar-preview.json?ref=${encodeURIComponent(branch)}`
const response = await fetch(url, {
headers: { Authorization: `token ${config.gitea.apiToken}` },
// Gleiches Zeitlimit wie giteaFetch (server/services/giteaClient.js) - sonst haengt ein
// Preview-Deploy unbegrenzt, wenn Gitea nicht antwortet.
signal: AbortSignal.timeout(config.gitea.timeoutMs),
})
if (!response.ok) return null
+81 -3
View File
@@ -142,6 +142,74 @@ async function deletePortalHooks(repoFullName) {
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
@@ -207,6 +275,11 @@ async function moveViaTransfer(project, from) {
}
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)
@@ -217,6 +290,9 @@ async function moveViaTransfer(project, from) {
})
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 -
@@ -238,10 +314,12 @@ 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.
// 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 warnings = await deletePortalHooks(from)
return { moved: null, warnings }
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