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: [] }
}
+7
View File
@@ -21,6 +21,10 @@ import { createHash } from 'node:crypto'
* Dateiinhalt (Klartext, keine Kodierung) je `"owner/repo:pfad"`, z. B. `"knso/x:.webklar-preview.json"`.
* @param {string[]} [options.orgs]
* Organisationen, die von Anfang an existieren (mit leerer Owners-Mitgliedschaft).
* @param {Record<string, string>} [options.redirects]
* Vorab gesetzte Weiterleitungen (alter `"owner/repo"` -> neuer `"owner/repo"`), z. B. fuer ein
* bereits von Hand verschobenes Repo, ohne dass dafuer ein Transfer-Aufruf noetig war. Der Ziel-
* Repo-Eintrag muss separat ueber `options.repos` angelegt werden.
* @param {string} [options.user] Login, das `GET /user` meldet. Voreinstellung `'webklar-system'`.
* @returns {Promise<{
* baseUrl: string,
@@ -439,6 +443,9 @@ export async function startFakeGitea(options = {}) {
contents.set(`${keyFrom(fullName)}:${filePath}`, text)
}
for (const name of options.orgs || []) createOrgRecord(name)
for (const [from, to] of Object.entries(options.redirects || {})) {
redirects.set(keyFrom(from), to)
}
/* ------------------------------------------------------ oeffentlich --- */
+148
View File
@@ -0,0 +1,148 @@
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
import { startFakeGitea } from './helpers/fakeGitea.js'
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
let gitea
let aw
let tmp
let originalCwd
let previewWorkDir
let moveRepoToOrg
let isOrgRepo
let isInternalRepo
let repoLockKey
let currentRepoFullName
// Schluessel: urspruenglich gesetzter repoFullName -> Appwrite-$id. Wird beim Seeden befuellt
// (siehe Controller-Ruling R2) - eine Live-Suche ueber repoFullName wuerde nach einem Umzug
// nichts mehr finden, weil sich das Feld dann geaendert hat.
const idByRepo = new Map()
/** Liefert das gespeicherte Dokument des Repos, ueber den zur Seed-Zeit gebauten Id-Index. */
function doc(name) {
return aw.get('websiteProjects', idByRepo.get(name))
}
before(async () => {
gitea = await startFakeGitea({
repos: [
{ full_name: 'JUSN/D-ner-Nation' },
{ full_name: 'knso/emmas-schatzkiste' },
{ full_name: 'ANDJ/Steuerungstechnik' },
{ full_name: 'knso/hytail-ark' },
{ full_name: 'WEBklar/schon-da' },
{ full_name: 'JUSN/schon-da' },
{ full_name: 'WEBklar/von-hand' },
{ full_name: 'ANDJ/unberuehrt' },
],
hooks: {
'knso/emmas-schatzkiste': [{ id: 110, url: 'https://project.webklar.com/webhook/gitea' }],
'knso/hytail-ark': [{ id: 7, url: 'https://webklar.com/webhook/hytail-ark/deploy' }],
},
// knso/von-hand wurde von Hand bereits nach WEBklar/von-hand verschoben (kein Transfer-Aufruf noetig).
redirects: {
'knso/von-hand': 'WEBklar/von-hand',
},
})
const repoNames = [
'JUSN/D-ner-Nation',
'knso/emmas-schatzkiste',
'ANDJ/Steuerungstechnik',
'knso/hytail-ark',
'WEBklar/schon-da',
'JUSN/schon-da',
'knso/von-hand', // Appwrite kennt noch den alten Namen (siehe Brief)
'ANDJ/unberuehrt',
]
const websiteProjects = repoNames.map((repoFullName) => {
const $id = randomUUID()
idByRepo.set(repoFullName, $id)
return { $id, repoFullName, projectName: repoFullName.split('/').pop() }
})
aw = await startFakeAppwrite({ seed: { websiteProjects } })
originalCwd = process.cwd()
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-move-'))
process.chdir(tmp)
Object.assign(process.env, {
GITEA_BASE_URL: gitea.baseUrl,
GITEA_API_TOKEN: 'test-token',
APPWRITE_ENDPOINT: aw.endpoint,
APPWRITE_API_KEY: 'test-key',
PREVIEW_DEPLOY_ROOT: '',
TRAEFIK_DYNAMIC_DIR: '',
})
// config.js liest die Umgebung beim Import
;({ previewWorkDir } = await import('../server/services/previewDeploy.js'))
;({ moveRepoToOrg, isOrgRepo, isInternalRepo, repoLockKey, currentRepoFullName } = await import(
'../server/services/repoMove.js'
))
})
after(async () => {
process.chdir(originalCwd)
await gitea.close()
await aw.close()
await fs.rm(tmp, { recursive: true, force: true })
})
test('verschiebt Repo aus eigenem Konto', async () => {
await fs.mkdir(previewWorkDir('JUSN/D-ner-Nation'), { recursive: true })
const r = await moveRepoToOrg(doc('JUSN/D-ner-Nation'))
assert.deepEqual(r, { moved: { from: 'JUSN/D-ner-Nation', to: 'WEBklar/D-ner-Nation' }, warnings: [] })
assert.deepEqual(gitea.calls.find((c) => c.path.endsWith('/transfer')).body, { new_owner: 'WEBklar' })
const d = aw.get('websiteProjects', doc('JUSN/D-ner-Nation').$id)
assert.equal(d.repoFullName, 'WEBklar/D-ner-Nation')
assert.equal(d.giteaRepoUrl, `${gitea.baseUrl}/WEBklar/D-ner-Nation`)
assert.equal(d.giteaRepoName, 'D-ner-Nation')
await assert.rejects(fs.access(previewWorkDir('JUSN/D-ner-Nation')))
})
test('löscht den alten Portal-Webhook', async () => {
await moveRepoToOrg(doc('knso/emmas-schatzkiste'))
assert.deepEqual(gitea.hooksOf('WEBklar/emmas-schatzkiste'), [])
})
test('schon in WEBklar: nichts passiert', async () => {
assert.deepEqual(await moveRepoToOrg(doc('WEBklar/schon-da')), { moved: null, warnings: [] })
})
test('internes Repo per Liste und per fremdem Webhook', async () => {
for (const name of ['ANDJ/Steuerungstechnik', 'knso/hytail-ark']) {
assert.deepEqual(await moveRepoToOrg(doc(name)), { moved: null, warnings: ['Internes Repo, bleibt wo es ist.'] })
}
assert.equal(gitea.calls.filter((c) => c.path.endsWith('/transfer') && /steuerung|hytail/i.test(c.path)).length, 0)
})
test('Name existiert in WEBklar', async () => {
const r = await moveRepoToOrg(doc('JUSN/schon-da'))
assert.deepEqual(r, { moved: null, warnings: ['In WEBklar gibt es schon ein Repo namens schon-da. Bitte in Gitea umbenennen und dann ‚Pushes nachtragen‘ klicken.'] })
})
test('Weiterleitung korrigiert Projektdaten', async () => {
const r = await moveRepoToOrg(doc('knso/von-hand'))
assert.deepEqual(r.moved, { from: 'knso/von-hand', to: 'WEBklar/von-hand' })
assert.equal(gitea.calls.filter((c) => c.path.endsWith('/transfer') && c.path.includes('von-hand')).length, 0)
assert.equal(aw.get('websiteProjects', doc('knso/von-hand').$id).repoFullName, 'WEBklar/von-hand')
})
test('Erkennung ohne Groß-/Kleinschreibung', async () => {
assert.equal(isOrgRepo('webklar/x'), true)
assert.equal(await isInternalRepo('andj/steuerungstechnik'), true)
})
test('repoLockKey', () => {
assert.equal(repoLockKey('JUSN/D-ner-Nation'), 'repo:jusn/d-ner-nation')
})
test('currentRepoFullName', async () => {
assert.equal(await currentRepoFullName('WEBklar/D-ner-Nation'), 'WEBklar/D-ner-Nation') // Eintrag vorhanden
assert.equal(await currentRepoFullName('JUSN/D-ner-Nation'), 'WEBklar/D-ner-Nation') // kein Eintrag, Weiterleitung
})
test('Gitea nicht erreichbar', async () => {
gitea.setDown(true)
const r = await moveRepoToOrg(doc('ANDJ/unberuehrt'))
gitea.setDown(false)
assert.equal(r.moved, null)
assert.match(r.warnings[0], /^Gitea ist gerade nicht erreichbar \(.+\)\. Bitte später ‚Pushes nachtragen‘ klicken\.$/)
})