diff --git a/server/services/projectAssignment.js b/server/services/projectAssignment.js new file mode 100644 index 0000000..fdcb247 --- /dev/null +++ b/server/services/projectAssignment.js @@ -0,0 +1,106 @@ +import { config } from '../config.js' +import { moveRepoToOrg, WARN } from './repoMove.js' +import { listRecentPushes } from './pushHistory.js' +import { createGitPushWorksheet, isSystemPusher } from './gitWorksheet.js' +import { withLocks } from './keyedLock.js' +import { ensureProjectTicket } from './ticketAdmin.js' +import { getDocument, updateDocument } from './appwriteAdmin.js' + +/** + * Commit-Nachrichten (erste Zeile), die frueher das System mit Kensos Schluessel geschrieben hat + * (Preview-Config, automatisch erstellte Preview-Seite) - siehe Plan, Abschnitt "Global + * Constraints". Beim Nachtragen zaehlt ein Push nicht, wenn die erste Zeile JEDER seiner + * Commit-Nachrichten hier steht - unabhaengig vom Pusher, denn diese alten Commits tragen oft + * "knso" als Pusher, nicht das heutige System-Konto (siehe `isSystemPusher`, das dafuer allein + * NICHT greift). + */ +export const SYSTEM_COMMIT_MESSAGES = ['Update preview config from ticket system', 'Automatisch erstellte Preview-Seite'] + +function firstLine(message) { + return String(message || '').split('\n')[0] +} + +/** true, wenn jede Commit-Nachricht des Pushes (erste Zeile) ein alter System-Commit ist. */ +function isOldSystemCommitPush(push) { + const commits = push.commits || [] + if (commits.length === 0) return false + return commits.every((commit) => SYSTEM_COMMIT_MESSAGES.includes(firstLine(commit.message))) +} + +/** + * Traegt die Pushes der letzten `GITEA_BACKFILL_DAYS` Tage eines Repos als Git-Arbeitsblaetter + * nach (fuer neu zugewiesene Projekte, deren bisherige Pushes noch nicht abgerechnet sind). + * Ueberspringt Systempushes: das aktuelle System-Konto (`isSystemPusher`) und alte, mit Kensos + * Schluessel gemachte System-Commits (`SYSTEM_COMMIT_MESSAGES`). Legt bei Bedarf automatisch ein + * Ticket an - aber erst, nachdem feststeht, dass tatsaechlich etwas nachzutragen ist, damit ein + * Projekt ohne (relevante) Pushes kein Ticket aufgedraengt bekommt. Wirft nie wegen eines + * Gitea-Problems (siehe `reconcileProject`, das sich - wie `moveRepoToOrg` - darauf verlaesst, + * dass eine Zuweisung in jedem Fall bestehen bleibt). + */ +export async function backfillPushes(project, { now = new Date() } = {}) { + let pushes + try { + pushes = await listRecentPushes(project.repoFullName, { now }) + } catch (err) { + return { backfilled: 0, warnings: [WARN.giteaDown(err.message)] } + } + + const relevant = pushes.filter((push) => !isSystemPusher(push.pusher) && !isOldSystemCommitPush(push)) + if (relevant.length === 0) return { backfilled: 0, warnings: [] } + + const ticketId = project.ticketId || (await ensureProjectTicket(project, { repoFullName: project.repoFullName })) + if (!ticketId) return { backfilled: 0, warnings: [] } + + let backfilled = 0 + // Aelteste zuerst (siehe `listRecentPushes`), damit die Arbeitsblaetter in derselben + // Reihenfolge entstehen, in der die Pushes tatsaechlich passiert sind. + for (const push of relevant) { + const result = await createGitPushWorksheet({ ticketId, repoFullName: project.repoFullName, ...push }) + if (result?.created) backfilled += 1 + } + return { backfilled, warnings: [] } +} + +/** + * Liest das Projekt, verschiebt sein Repo bei Bedarf nach `GITEA_ORG` und traegt danach dessen + * Pushes nach - alles unter der Projekt-Sperre, damit ein Doppelklick auf "Zuweisen" (oder ein + * gleichzeitiger Aufruf ueber die Migration, Task 9) nicht zweimal umzieht oder doppelte + * Arbeitsblaetter erzeugt (Review Focus 3, Plan). Das Projektdokument wird ERST innerhalb der + * Sperre gelesen, damit ein zweiter, gleichzeitiger Aufruf nie mit einem inzwischen veralteten + * Objekt arbeitet. + */ +export async function reconcileProject(projectId, { now } = {}) { + return withLocks(['project:' + projectId], async () => { + let project = await getDocument(config.collections.websiteProjects, projectId) + const { moved, warnings: moveWarnings } = await moveRepoToOrg(project) + + // Immer neu lesen, nicht nur wenn `moved` gesetzt ist: der Selbstheilungs-Zweig in + // `moveRepoToOrg` (von Hand verschobenes/umbenanntes Repo) kann das Dokument umbenennen und + // dabei trotzdem `moved: null` liefern (Controller-Ruling R13). + project = await getDocument(config.collections.websiteProjects, projectId) + + const { backfilled, warnings: backfillWarnings } = await backfillPushes(project, { now }) + + return { project, moved, backfilled, warnings: [...moveWarnings, ...backfillWarnings] } + }) +} + +/** + * Weist ein Projekt einem Kunden/Ticket zu und stoesst danach Umzug + Nachtragen an. Die + * Zuweisung selbst wird ZUERST und ausserhalb der Projekt-Sperre gespeichert (reines Appwrite, + * ohne Gitea) - sie bleibt deshalb bestehen, selbst wenn Gitea gerade nicht erreichbar ist + * (siehe `reconcileProject`/`moveRepoToOrg`, die deshalb nie wegen Gitea werfen duerfen). + */ +export async function assignProject(projectId, { customerId = '', ticketId = '' }, { now } = {}) { + // 404 fuer ein unbekanntes Projekt, bevor ueberhaupt geschrieben wird. + await getDocument(config.collections.websiteProjects, projectId) + + await updateDocument(config.collections.websiteProjects, projectId, { + customerId, + ticketId, + updatedAt: new Date().toISOString(), + }) + + const { project, moved, backfilled, warnings } = await reconcileProject(projectId, { now }) + return { ok: true, project, moved, backfilled, warnings } +} diff --git a/test/projectAssignment.test.js b/test/projectAssignment.test.js new file mode 100644 index 0000000..148baa3 --- /dev/null +++ b/test/projectAssignment.test.js @@ -0,0 +1,157 @@ +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 { startFakeGitea } from './helpers/fakeGitea.js' +import { startFakeAppwrite } from './helpers/fakeAppwrite.js' + +let gitea +let aw +let tmp +let originalCwd +let assignProject +let reconcileProject + +const now = new Date('2026-09-27T16:00:00Z') + +let nextFeedId = 1 + +/** + * Baut einen Gitea-1.25-Feed-Eintrag vom Typ `commit_repo` nach (ein Commit), siehe + * `test/pushHistory.test.js` fuer denselben Aufbau: `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 }), + } +} + +before(async () => { + gitea = await startFakeGitea({ + repos: [ + { full_name: 'JUSN/D-ner-Nation' }, + { full_name: 'JUSN/leer' }, + { full_name: 'JUSN/nur-kunde' }, + { full_name: 'JUSN/doppelt' }, + ], + feeds: { + // Newest first, wie der echte Gitea-Feed. + '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' } }, + ], + 'JUSN/nur-kunde': [pushEntry({ sha: 'n1', user: 'JUSN', at: '2026-09-27T12:00:00Z', msg: 'Kundenseite live' })], + 'JUSN/doppelt': [pushEntry({ sha: 'd1', user: 'JUSN', at: '2026-09-27T11:00:00Z', msg: 'Doppelt-Push' })], + 'JUSN/leer': [], + }, + }) + + aw = await startFakeAppwrite({ + seed: { + websiteProjects: [ + { $id: 'p1', repoFullName: 'JUSN/D-ner-Nation', projectName: 'D-ner-Nation', customerId: '', ticketId: '' }, + { $id: 'p2', repoFullName: 'JUSN/leer', projectName: 'leer', customerId: '', ticketId: '' }, + { $id: 'p3', repoFullName: 'JUSN/nur-kunde', projectName: 'nur-kunde', customerId: '', ticketId: '' }, + { $id: 'p4', repoFullName: 'JUSN/doppelt', projectName: 'doppelt', 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(), 'project-assignment-')) + 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 + ;({ assignProject, reconcileProject } = await import('../server/services/projectAssignment.js')) +}) + +after(async () => { + process.chdir(originalCwd) + await gitea.close() + await aw.close() + await fs.rm(tmp, { recursive: true, force: true }) +}) + +test('assign speichert, verschiebt und trägt nach', async () => { + const r = await assignProject('p1', { customerId: 'c1', ticketId: 't1' }, { now }) + assert.equal(r.ok, true) + assert.deepEqual(r.moved, { from: 'JUSN/D-ner-Nation', to: 'WEBklar/D-ner-Nation' }) + assert.equal(r.backfilled, 1); assert.deepEqual(r.warnings, []) + const d = aw.get('websiteProjects', 'p1') + assert.deepEqual([d.customerId, d.ticketId], ['c1', 't1']) + const ws = aw.all('worksheets').filter((w) => w.workorderId === 't1') + assert.equal(ws.length, 1) + assert.deepEqual([ws[0].employeeName, ws[0].endDate, ws[0].endTime], ['JUSN', '27.09.2026', '1646']) +}) +test('zweites Nachtragen erzeugt nichts Neues', async () => { + const r = await reconcileProject('p1', { now }) + assert.deepEqual([r.moved, r.backfilled], [null, 0]) +}) +test('nur Kunde ohne Pushes: kein Ticket', async () => { + const before = aw.all('workorders').length + await assignProject('p2', { customerId: 'c1' }, { now }) + assert.equal(aw.all('workorders').length, before) +}) +test('nur Kunde mit Pushes: Webpage-Ticket entsteht', async () => { + const r = await assignProject('p3', { customerId: 'c1' }, { now }) + const t = aw.all('workorders').find((w) => w.$id === aw.get('websiteProjects', 'p3').ticketId) + assert.equal(t.type, 'Webpage'); assert.equal(r.backfilled, 1) +}) +test('Doppelklick: ein Umzug, keine doppelten Arbeitsblätter', async () => { + await Promise.all([assignProject('p4', { customerId: 'c1', ticketId: 't1' }, { now }), assignProject('p4', { customerId: 'c1', ticketId: 't1' }, { now })]) + assert.equal(gitea.calls.filter((c) => c.path === '/api/v1/repos/JUSN/doppelt/transfer').length, 1) + assert.equal(aw.all('worksheets').filter((w) => w.details.includes('WEBklar/doppelt') || w.details.includes('JUSN/doppelt')).length, 1) +}) +test('Gitea-Ausfall: Zuweisung bleibt', async () => { + // Diese Zuweisung loest bewusst einen Gitea-Ausfall aus (`deletePortalHooks` in `repoMove.js` + // loggt einen fehlgeschlagenen Hook-Abruf mit console.error) - Konsole hier abfangen, damit + // die Testausgabe sauber bleibt. + const originalError = console.error + const originalWarn = console.warn + console.error = () => {} + console.warn = () => {} + gitea.setDown(true) + let r + try { + r = await assignProject('p2', { customerId: 'c1', ticketId: 't1' }, { now }) + } finally { + gitea.setDown(false) + console.error = originalError + console.warn = originalWarn + } + assert.equal(r.ok, true); assert.equal(aw.get('websiteProjects', 'p2').ticketId, 't1') + assert.match(r.warnings[0], /^Gitea ist gerade nicht erreichbar/) +}) +test('unbekanntes Projekt', async () => { + await assert.rejects(assignProject('nope', { ticketId: 't1' }, { now }), (e) => e.status === 404) +})