diff --git a/server/routes/admin/gitea.js b/server/routes/admin/gitea.js index 20d40ac..efecce8 100644 --- a/server/routes/admin/gitea.js +++ b/server/routes/admin/gitea.js @@ -1,5 +1,5 @@ import { Router } from 'express' -import { syncReposToAppwrite, fetchRepoReadme } from '../../services/giteaAdmin.js' +import { syncReposToAppwrite, fetchRepoReadme, importNewRepos } from '../../services/giteaAdmin.js' import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' const router = Router() @@ -16,6 +16,18 @@ router.post('/sync-repos', async (_req, res) => { } }) +// Neue Repos aus Gitea als minimale Projektdokumente anlegen (vom Projekt-Dialog im +// Ticketsystem bei jedem Oeffnen aufgerufen). +router.post('/import-repos', async (_req, res) => { + try { + const result = await importNewRepos() + return res.json({ ok: true, ...result }) + } catch (err) { + const status = err.status || 500 + return res.status(status).json({ error: err.message || 'Import fehlgeschlagen' }) + } +}) + router.get('/repo-readme', async (req, res) => { const repoFullName = String(req.query.repoFullName || '').trim() if (!repoFullName) { diff --git a/server/routes/admin/websiteProjects.js b/server/routes/admin/websiteProjects.js index becf8af..9303569 100644 --- a/server/routes/admin/websiteProjects.js +++ b/server/routes/admin/websiteProjects.js @@ -4,6 +4,7 @@ import { updateWebsiteProject, archiveRepo, } from '../../services/giteaAdmin.js' +import { assignProject, reconcileProject } from '../../services/projectAssignment.js' import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' const router = Router() @@ -67,4 +68,40 @@ router.post('/:id/unarchive', async (req, res) => { } }) +// Projekt einem Kunden/Ticket zuweisen -> stoesst Umzug nach GITEA_ORG + Nachtragen an +router.post('/:id/assign', async (req, res) => { + const { customerId, ticketId } = req.body || {} + if (!customerId && !ticketId) { + return res.status(400).json({ error: 'customerId oder ticketId erforderlich' }) + } + + try { + const result = await assignProject(req.params.id, { + customerId: String(customerId || ''), + ticketId: String(ticketId || ''), + }) + return res.json({ ok: true, ...result }) + } catch (err) { + if (err.status === 404) { + return res.status(404).json({ error: 'Projekt nicht gefunden' }) + } + const status = err.status || 500 + return res.status(status).json({ error: err.message }) + } +}) + +// Umzug + Pushes eines bereits zugewiesenen Projekts erneut nachtragen +router.post('/:id/pushes', async (req, res) => { + try { + const result = await reconcileProject(req.params.id) + return res.json({ ok: true, ...result }) + } catch (err) { + if (err.status === 404) { + return res.status(404).json({ error: 'Projekt nicht gefunden' }) + } + const status = err.status || 500 + return res.status(status).json({ error: err.message }) + } +}) + export default router diff --git a/server/routes/webhook/gitea.js b/server/routes/webhook/gitea.js index da2e44f..3587fb6 100644 --- a/server/routes/webhook/gitea.js +++ b/server/routes/webhook/gitea.js @@ -1,7 +1,7 @@ import crypto from 'node:crypto' import { Router } from 'express' import { config } from '../../config.js' -import { upsertWebsiteProjectByRepo } from '../../services/appwriteAdmin.js' +import { upsertWebsiteProjectByRepo, getDocument } from '../../services/appwriteAdmin.js' import { fetchPreviewConfig, defaultPreviewConfig, @@ -12,6 +12,8 @@ import { ensureRepoPreviewConfig } from '../../services/giteaAdmin.js' import { createGitPushWorksheet } from '../../services/gitWorksheet.js' import { ensureProjectTicket } from '../../services/ticketAdmin.js' import { invalidateProjectCache } from '../../services/projectInsights.js' +import { withLocks } from '../../services/keyedLock.js' +import { repoLockKey, currentRepoFullName } from '../../services/repoMove.js' const router = Router() @@ -44,36 +46,45 @@ router.post('/gitea', async (req, res) => { const payload = req.body || {} const ref = payload.ref || '' const repo = payload.repository || {} - const repoFullName = repo.full_name || '' + const payloadName = repo.full_name || '' const commits = payload.commits || [] const pusher = payload.pusher || {} const commitSha = payload.after || commits[commits.length - 1]?.id || '' - if (!repoFullName) { + if (!payloadName) { return res.status(400).json({ error: 'repository.full_name fehlt' }) } const branch = ref.replace('refs/heads/', '') || 'main' try { - let previewConfigFile = await fetchPreviewConfig(repoFullName, branch) + // Phase 1: aktuellen Repo-Namen aufloesen - wartet auf einen laufenden Umzug DESSELBEN + // Repos (dieselbe Sperre wie moveRepoToOrg/importNewRepos, Controller-Ruling R17), damit + // der Webhook nie mit einem veralteten Namen weiterlaeuft. Keine Sperre bleibt darueber + // hinaus gehalten - ein Deploy kann lange dauern und darf sie nicht blockieren. + const name = await withLocks([repoLockKey(payloadName)], () => currentRepoFullName(payloadName), { + waitMs: 10000, + }) + + // Phase 2: Preview-Config + Deploy wie bisher, mit `name` - ohne gehaltene Sperre. + let previewConfigFile = await fetchPreviewConfig(name, branch) // Selbstheilung: Wurde .webklar-preview.json im Repo geloescht (z.B. beim // Leerraeumen/Ueberarbeiten der Seite), aber das Projekt ist in Appwrite als // gehostet bekannt, dann Config aus dem gespeicherten Stand wiederherstellen // (Datei ins Repo zurueckschreiben) und normal weiter-deployen. if (!previewConfigFile) { try { - const restored = await ensureRepoPreviewConfig(repoFullName, branch) + const restored = await ensureRepoPreviewConfig(name, branch) if (restored) previewConfigFile = restored } catch (err) { console.error('[webhook] preview-config-Wiederherstellung fehlgeschlagen:', err.message) } } - const previewConfig = previewConfigFile || defaultPreviewConfig(repoFullName) + const previewConfig = previewConfigFile || defaultPreviewConfig(name) const hasPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile) // Nur Projekte mit aktivierter Preview bekommen eine Subdomain zugewiesen - // Repo-only-Projekte (enabled:false, keine Subdomain) bleiben ohne Website. - const subdomain = hasPreview ? previewConfig.subdomain || repoFullName.split('/').pop() : '' + const subdomain = hasPreview ? previewConfig.subdomain || name.split('/').pop() : '' const previewUrl = subdomain ? buildPreviewUrl(subdomain) : '' let deployResult = { deployed: false, skipped: true } @@ -81,63 +92,93 @@ router.post('/gitea', async (req, res) => { if (hasPreview && previewConfig.enabled !== false && branch === configBranch) { try { - deployResult = await runDeploy(repoFullName, branch, previewConfig) + deployResult = await runDeploy(name, branch, previewConfig) } catch (deployErr) { console.error('[webhook] deploy failed:', deployErr.message) deployResult = { deployed: false, error: deployErr.message } } } - const projectPayload = { - projectName: previewConfig.displayName || repo.name || subdomain, - templateName: previewConfig.templateName || config.gitea.templateRepo, - giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`, - giteaRepoName: repo.name || subdomain, - repoFullName, - } + // Phase 3: Dokument unter derselben Repo-Sperre wie Import/Umzug anlegen/aktualisieren. + // Namen dabei ERNEUT aufloesen - deckt einen Umzug ab, der waehrend des (unter Sperre + // laufenden) Deploys oben passiert ist. `current` ab hier fuer alle weiteren Schritte. + let current + const project = await withLocks( + [repoLockKey(name)], + async () => { + current = await currentRepoFullName(name) - if (subdomain) projectPayload.subdomain = subdomain - if (previewUrl) projectPayload.previewUrl = previewUrl + const projectPayload = { + projectName: previewConfig.displayName || repo.name || subdomain, + templateName: previewConfig.templateName || config.gitea.templateRepo, + giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${current}`, + giteaRepoName: repo.name || subdomain, + repoFullName: current, + } - if (deployResult.deployed) { - projectPayload.status = 'deployed' - projectPayload.provisioningStatus = 'ready' - } else if (hasPreview) { - projectPayload.status = 'pending' - projectPayload.provisioningStatus = deployResult.error ? 'deploy_failed' : 'pending' - } else { - projectPayload.status = 'synced' - } + if (subdomain) projectPayload.subdomain = subdomain + if (previewUrl) projectPayload.previewUrl = previewUrl - const project = await upsertWebsiteProjectByRepo(repoFullName, projectPayload) - invalidateProjectCache(repoFullName) + if (deployResult.deployed) { + projectPayload.status = 'deployed' + projectPayload.provisioningStatus = 'ready' + } else if (hasPreview) { + projectPayload.status = 'pending' + projectPayload.provisioningStatus = deployResult.error ? 'deploy_failed' : 'pending' + } else { + projectPayload.status = 'synced' + } + return upsertWebsiteProjectByRepo(current, projectPayload) + }, + { waitMs: 10000 } + ) + + // Phase 4 + invalidateProjectCache(current) + + // Phase 5: nur bei einem echten Commit - Ticket + Arbeitsblatt unter der Projekt-Sperre + // (derselben wie assignProject/reconcileProject), damit ein gleichzeitiges Zuweisen nicht + // dieselbe ticketId-Aufloesung doppelt anstoesst. let gitWorksheet = null if (commitSha && commitSha !== '0000000000000000000000000000000000000000') { - // Projekt ohne Ticket: automatisch ein Webpage-Ticket anlegen (nur mit Kunde) - let ticketId = project.ticketId - if (!ticketId) { - try { - ticketId = await ensureProjectTicket(project, { repoFullName }) - } catch (err) { - console.error('[webhook] Auto-Ticket fehlgeschlagen:', err.message) - } - } - if (ticketId) { - gitWorksheet = await createGitPushWorksheet({ - ticketId, - repoFullName, - branch, - commits, - pusher, - commitSha, - }) - } + gitWorksheet = await withLocks( + ['project:' + project.$id], + async () => { + let fresh = project + try { + fresh = await getDocument(config.collections.websiteProjects, project.$id) + } catch { + // Nachlesen fehlgeschlagen: mit dem Upsert-Ergebnis von oben weitermachen (R17). + } + + // Projekt ohne Ticket: automatisch ein Webpage-Ticket anlegen (nur mit Kunde) + let ticketId = fresh.ticketId + if (!ticketId) { + try { + ticketId = await ensureProjectTicket(fresh, { repoFullName: current }) + } catch (err) { + console.error('[webhook] Auto-Ticket fehlgeschlagen:', err.message) + } + } + if (!ticketId) return null + + return createGitPushWorksheet({ + ticketId, + repoFullName: current, + branch, + commits, + pusher, + commitSha, + }) + }, + { waitMs: 10000 } + ) } return res.json({ ok: true, - repoFullName, + repoFullName: current, subdomain, previewUrl, deploy: deployResult, diff --git a/test/giteaRoutes.test.js b/test/giteaRoutes.test.js new file mode 100644 index 0000000..8a51906 --- /dev/null +++ b/test/giteaRoutes.test.js @@ -0,0 +1,228 @@ +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { createHmac } from 'node:crypto' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import express from 'express' +import { startFakeGitea } from './helpers/fakeGitea.js' +import { startFakeAppwrite } from './helpers/fakeAppwrite.js' + +const WEBHOOK_TOKEN = 's3cret' + +let gitea +let aw +let tmp +let originalCwd +let server +let baseUrl + +let nextFeedId = 1 + +/** + * Baut einen Gitea-1.25-Feed-Eintrag vom Typ `commit_repo` nach (ein Commit) - derselbe Aufbau + * wie in test/projectAssignment.test.js (Task 6): `content` traegt das JSON + * `{Commits:[...], HeadCommit:{...}}` als String, wie im echten Aktivitaeten-Feed. + */ +function pushEntry({ sha, at, user, msg }) { + const login = user || 'JUSN' + const commit = { + Sha1: sha, + Message: msg || `Commit ${sha}`, + AuthorEmail: `${login.toLowerCase()}@example.com`, + AuthorName: login, + CommitterEmail: `${login.toLowerCase()}@example.com`, + CommitterName: login, + Timestamp: at, + } + return { + id: nextFeedId++, + op_type: 'commit_repo', + act_user: { login, full_name: '' }, + ref_name: 'refs/heads/main', + created: at, + content: JSON.stringify({ Commits: [commit], HeadCommit: { Sha1: sha }, CompareURL: '', Len: 1 }), + } +} + +/** + * Wartet, bis die Fake-Gitea den POST .../transfer fuer `fullName` erhalten hat (Controller- + * Ruling R3), statt einen festen `sleep(50)` zu nutzen: `gitea.calls` waechst, SOBALD die + * Anfrage beim Fake-Server ankommt - strikt VOR einer etwaigen `setTransferDelay`-Verzoegerung + * der Antwort. So laesst sich der Webhook zuverlaessig genau dann abschicken, wenn der Umzug + * seine Repo-Sperren bereits haelt, ohne von der Terminzeit des Test-Rechners abzuhaengen. + */ +function waitForGiteaTransfer(fullName, timeoutMs = 1000) { + const transferPath = `/api/v1/repos/${fullName}/transfer` + const deadline = Date.now() + timeoutMs + return new Promise((resolve, reject) => { + ;(function check() { + if (gitea.calls.some((c) => c.method === 'POST' && c.path === transferPath)) return resolve() + if (Date.now() > deadline) return reject(new Error(`Timeout beim Warten auf POST ${transferPath}`)) + setTimeout(check, 5) + })() + }) +} + +async function post(urlPath, body, jwt) { + const headers = { 'Content-Type': 'application/json' } + if (jwt) headers.Authorization = `Bearer ${jwt}` + const res = await fetch(`${baseUrl}${urlPath}`, { + method: 'POST', + headers, + body: JSON.stringify(body || {}), + }) + const json = await res.json().catch(() => null) + return { status: res.status, body: json } +} + +/** Baut einen Gitea-Push-Webhook-Payload (nur die vom Portal genutzten Felder). */ +function pushPayload({ full_name, sha, pusher, branch = 'main' }) { + return { + ref: `refs/heads/${branch}`, + repository: { + full_name, + name: full_name.split('/').pop(), + html_url: `${gitea.baseUrl}/${full_name}`, + }, + commits: [ + { + id: sha, + message: `Commit ${sha}`, + author: { name: pusher }, + url: `${gitea.baseUrl}/${full_name}/commit/${sha}`, + }, + ], + pusher: { username: pusher }, + after: sha, + } +} + +async function webhook(payload) { + const bodyText = JSON.stringify(payload) + const signature = createHmac('sha256', WEBHOOK_TOKEN).update(bodyText).digest('hex') + const res = await fetch(`${baseUrl}/webhook/gitea`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Gitea-Signature': signature }, + body: bodyText, + }) + const json = await res.json().catch(() => null) + return { status: res.status, body: json } +} + +before(async () => { + gitea = await startFakeGitea({ + repos: [{ full_name: 'JUSN/D-ner-Nation' }, { full_name: 'JUSN/langsam' }], + feeds: { + // Wie Task 6 (test/projectAssignment.test.js): genau ein nachtragbarer Push + // ('26bc22ec...' von JUSN), der Rest sind Alt-System-/Alt-Branch-Rauschen. + 'JUSN/D-ner-Nation': [ + pushEntry({ sha: 's1', user: 'webklar-system', at: '2026-09-27T14:50:00Z', msg: 'System-Push' }), + pushEntry({ sha: 'k1', user: 'knso', at: '2026-09-27T14:48:00Z', msg: 'Update preview config from ticket system' }), + pushEntry({ sha: '26bc22ecf17e2e5a5a8794dcb4cf3892f802c51d', user: 'JUSN', at: '2026-09-27T14:46:34Z', msg: 'Bilder aktualisiert' }), + { ...pushEntry({ sha: 'x1', at: '2026-09-27T14:46:33Z' }), content: '' }, + { id: nextFeedId++, op_type: 'create_repo', created: '2026-09-27T14:02:46Z', act_user: { login: 'JUSN' } }, + ], + // p5/JUSN/langsam hat keine Pushes im Feed. + }, + }) + + aw = await startFakeAppwrite({ + seed: { + websiteProjects: [ + { $id: 'p1', repoFullName: 'JUSN/D-ner-Nation', projectName: 'D-ner-Nation', customerId: '', ticketId: '' }, + { $id: 'p5', repoFullName: 'JUSN/langsam', projectName: 'langsam', customerId: '', ticketId: '' }, + ], + workorders: [{ $id: 't1', woid: '100115' }], + customers: [{ $id: 'c1', name: 'Döner Nation' }], + }, + }) + + originalCwd = process.cwd() + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'gitea-routes-')) + process.chdir(tmp) + + Object.assign(process.env, { + GITEA_BASE_URL: gitea.baseUrl, + GITEA_API_TOKEN: 'test-token', + GITEA_WEBHOOK_TOKEN: WEBHOOK_TOKEN, + APPWRITE_ENDPOINT: aw.endpoint, + APPWRITE_API_KEY: 'test-key', + PREVIEW_DEPLOY_ROOT: '', + TRAEFIK_DYNAMIC_DIR: '', + }) + + // config.js liest die Umgebung beim Import + const [{ default: adminGiteaRoutes }, { default: adminWebsiteProjectsRoutes }, { default: giteaWebhookRoutes }] = + await Promise.all([ + import('../server/routes/admin/gitea.js'), + import('../server/routes/admin/websiteProjects.js'), + import('../server/routes/webhook/gitea.js'), + ]) + + const app = express() + // rawBody wird fuer die HMAC-Verifikation des Gitea-Webhooks benoetigt - derselbe Aufbau wie server/index.js. + app.use( + express.json({ + limit: '2mb', + verify: (req, _res, buf) => { + req.rawBody = buf + }, + }) + ) + app.use('/api/admin/gitea', adminGiteaRoutes) + app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes) + app.use('/webhook', giteaWebhookRoutes) + + server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)) + }) + baseUrl = `http://127.0.0.1:${server.address().port}` +}) + +after(async () => { + await new Promise((resolve) => server.close(resolve)) + process.chdir(originalCwd) + await gitea.close() + await aw.close() + await fs.rm(tmp, { recursive: true, force: true }) +}) + +test('import-repos braucht Mitarbeiter-JWT', async () => { + assert.equal((await post('/api/admin/gitea/import-repos')).status, 401) + const r = await post('/api/admin/gitea/import-repos', {}, 'jwt-admin') + assert.equal(r.status, 200); assert.equal(typeof r.body.created, 'number') +}) +test('assign über die Schnittstelle', async () => { + const r = await post('/api/admin/website-projects/p1/assign', { customerId: 'c1', ticketId: 't1' }, 'jwt-admin') + assert.equal(r.status, 200) + assert.deepEqual([r.body.moved.to, r.body.backfilled, r.body.warnings], ['WEBklar/D-ner-Nation', 1, []]) +}) +test('assign ohne Kunde und Ticket', async () => { + const r = await post('/api/admin/website-projects/p1/assign', {}, 'jwt-admin') + assert.deepEqual([r.status, r.body.error], [400, 'customerId oder ticketId erforderlich']) +}) +test('pushes über die Schnittstelle', async () => { + const r = await post('/api/admin/website-projects/p1/pushes', {}, 'jwt-admin') + assert.deepEqual([r.status, r.body.moved, r.body.backfilled], [200, null, 0]) +}) +test('Webhook nach Umzug mit altem Namen', async () => { + const docs = aw.all('websiteProjects').length + const r = await webhook(pushPayload({ full_name: 'JUSN/D-ner-Nation', sha: 'f00dbeef', pusher: 'JUSN' })) + assert.equal(r.status, 200) + assert.equal(aw.all('websiteProjects').length, docs) + assert.ok(aw.all('worksheets').some((w) => w.workorderId === 't1' && w.details.includes('Commit: f00dbee'))) +}) +test('Webhook des System-Kontos', async () => { + const before = aw.all('worksheets').length + await webhook(pushPayload({ full_name: 'WEBklar/D-ner-Nation', sha: 'cafe0001', pusher: 'webklar-system' })) + assert.equal(aw.all('worksheets').length, before) +}) +test('Webhook wartet auf laufenden Umzug', async () => { + gitea.setTransferDelay(300) + const move = post('/api/admin/website-projects/p5/assign', { customerId: 'c1', ticketId: 't1' }, 'jwt-admin') // p5: JUSN/langsam + await waitForGiteaTransfer('JUSN/langsam') + await webhook(pushPayload({ full_name: 'WEBklar/langsam', sha: 'abcd1234', pusher: 'JUSN' })) + await move + assert.equal(aw.all('websiteProjects').filter((d) => d.repoFullName === 'WEBklar/langsam').length, 1) +})