# Plan 1 – Kundenportal: System-Konto, Umzug nach WEBklar, Pushes nachtragen > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Ziel:** Das Kundenportal arbeitet über das neutrale Konto `webklar-system` statt über knso. Es verschiebt zugewiesene Repos in die Organisation WEBklar, trägt Pushes der letzten 30 Tage als Arbeitsblätter nach und schreibt Push-Zeiten in deutscher Zeit. Danach ziehen die 6 Kundenprojekte um. **Aufbau:** - **Kleine Bausteine**, jeder in einer eigenen Datei unter `server/services/`: - `slug.js`: Umlaute für Subdomains und Repo-Namen, - `berlinTime.js`: deutsche Zeit, - `keyedLock.js`: Sperren im Portal-Prozess, - `giteaClient.js`: der bisherige `giteaFetch`, aus `giteaAdmin.js` herausgelöst. - **Fachlogik:** - `pushHistory.js` liest die Push-Historie aus den Gitea-Aktivitäten, - `repoMove.js` erledigt den Umzug und erkennt interne Repos, - `projectAssignment.js` bündelt Zuweisen, Umzug und Nachtragen. - **Bestehende Dateien:** - `gitWorksheet.js`: deutsche Zeit, keine System-Pushes, keine Doppelten, - `giteaAdmin.js`: Import neuer Repos, Abgleich ohne Subdomains, Selbstheilung nur für gehostete Projekte, - drei neue Admin-Routen, - der Webhook wartet auf laufende Umzüge. - **Zwei Skripte:** Einrichtung der Organisation (Phase 1) und Umzug der Kundenprojekte (Phase 4). - **Tests:** Sie laufen gegen einen Gitea-Nachbau und einen Appwrite-Nachbau im Testprozess. **Technik:** - Node 20 in Produktion, Node 24 auf dem Host - ESM, Express - Gitea 1.25.2 REST (`/api/v1`) - Appwrite-REST über `server/services/appwriteAdmin.js` - `node --test` **Spec:** `docs/superpowers/specs/2026-09-27-gitea-organisation-design.md`. Maßgeblich sind die Abschnitte 4, 5, 7, 9, 10, 11 und 12. Die drei Punkte „bitte prüfen“ aus Abschnitt 3 hat der Nutzer am 27.09.2026 bestätigt. **Weitere Pläne:** - Plan 2 baut das Ticketsystem um: `2026-09-27-gitea-organisation-2-ticketsystem.md`. - Plan 3 baut die Lead-Automatik um: `2026-09-27-gitea-organisation-3-lead-automatik.md`. - Plan 2 setzt voraus, dass Plan 1 live ist (Livegang, Schritt L5). ## Global Constraints **Werte, die wörtlich gelten** - **Organisation:** `WEBklar`. Konfiguration `GITEA_ORG`, Voreinstellung `WEBklar`. - **System-Konto:** `webklar-system`. Konfiguration `GITEA_SYSTEM_USER`, Voreinstellung `webklar-system`. E-Mail `webklar-system@noreply.webklar.com`. - **Schlüssel des Portals:** Name `portal`, Rechte `write:repository,write:organization,read:user`. - **Portal-Webhook-URL:** `${config.publicBaseUrl}/webhook/gitea`, in Produktion `https://project.webklar.com/webhook/gitea`. Ein Einzel-Webhook gilt als Portal-Webhook, wenn seine URL mit genau diesem Text beginnt. - **Organisations-Webhook:** `type: "gitea"`, `active: true`, `events: ["push"]`, `branch_filter: "*"`, `config.content_type: "json"`, `config.secret` = Wert von `GITEA_WEBHOOK_TOKEN`. - **Nachtragen:** 30 Tage. Konfiguration `GITEA_BACKFILL_DAYS`, Voreinstellung 30. - **Alte System-Commits:** Beim Nachtragen wird ein Push übersprungen, wenn die erste Zeile jeder seiner Commit-Nachrichten in `SYSTEM_COMMIT_MESSAGES` steht, also `Update preview config from ticket system` oder `Automatisch erstellte Preview-Seite`. Solche Commits hat früher das System mit Kensos Schlüssel geschrieben, und sie tragen deshalb knso als Pusher. - **Wartezeit des Webhooks auf einen Umzug:** 10000 ms. - **`GITEA_KEEP_IN_PLACE`**, Voreinstellung: `ANDJ/Emailsorter,ANDJ/Steuerungstechnik,JUSN/tickte-system,JUSN/Webklar,knso/eship,knso/Webklar-Kundenbereich`. - **Zeit:** Europe/Berlin. Datum `dd.mm.yyyy`, Uhrzeit `hhmm`, also dasselbe Format wie heute im Arbeitsblatt. - **Groß- und Kleinschreibung:** Besitzer- und Repo-Namen werden immer ohne Rücksicht darauf verglichen, denn Gitea behandelt sie ebenso. **Warnungen, wörtlich** - `Internes Repo, bleibt wo es ist.` - `` `In WEBklar gibt es schon ein Repo namens ${name}. Bitte in Gitea umbenennen und dann ‚Pushes nachtragen‘ klicken.` `` - `` `Gitea ist gerade nicht erreichbar (${grund}). Bitte später ‚Pushes nachtragen‘ klicken.` `` - `` `Das Repo liegt jetzt in WEBklar, aber die Projektdaten konnten nicht angepasst werden (${grund}). Bitte ‚Pushes nachtragen‘ klicken.` `` **Was sich nicht ändern darf** - der Wert von `GITEA_WEBHOOK_TOKEN`, - `GITEA_TEMPLATE_OWNER`, - die Deploy-Container, - vorhandene Arbeitsblätter, - das Verhalten für Kunden im Portal. **Tests** - Tests erreichen nie Produktion. `GITEA_BASE_URL` und `APPWRITE_ENDPOINT` zeigen in Tests auf Nachbauten unter `127.0.0.1`. `PREVIEW_DEPLOY_ROOT` und `TRAEFIK_DYNAMIC_DIR` sind leer. - `npm test` muss unter Node 24 und unter Node 20 grün sein. **Git und Produktion** - Gearbeitet wird im Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-gitea-org` auf dem Branch `feature/gitea-organisation`, ohne Upstream. - Niemals `git add -A`, denn der `node_modules`-Symlink ist untracked. - Ein Push nach `main` geht sofort live und erfolgt nur mit Freigabe des Nutzers. - Produktionsschritte (Gitea-Befehlszeile, `.env`, Compose) stehen nur im Abschnitt „Livegang“, und jeder braucht das OK des Nutzers. Blockiert die Rechteprüfung einen Schritt, bekommt der Nutzer die fertigen Befehle. ## Review Focus 1. **Groß- und Kleinschreibung.** Gitea liefert „JUSN/D-ner-Nation“, jemand tippt „webklar/x“, oder die Liste enthält „andj/steuerungstechnik“. Die Erkennung von WEBklar-Repos und internen Repos darf daran nicht scheitern. Test: Task 5, „Erkennung ohne Groß-/Kleinschreibung“. 2. **Ein Push mit mehreren Commits.** Beim Nachtragen muss `commitSha` der HeadCommit sein, also derselbe Wert wie `after` im Webhook. Sonst entsteht bei einer späteren Zustellung desselben Pushes ein zweites Arbeitsblatt. Test: Task 4, „HeadCommit bestimmt commitSha“. 3. **Ein Doppelklick auf „Zuweisen“.** Das ergibt genau eine Übertragung und keine doppelten Arbeitsblätter. Test: Task 6, „Doppelklick: ein Umzug, keine doppelten Arbeitsblätter“. 4. **Ein Repo wurde schon von Hand in Gitea verschoben.** Der Projekt-Eintrag wird über die Weiterleitung korrigiert, ohne zweite Übertragung. Test: Task 5, „Weiterleitung korrigiert Projektdaten“. 5. **Ein Push mit dem alten Repo-Namen kommt nach dem Umzug an.** Dabei entsteht kein zweiter Projekt-Eintrag, und das Arbeitsblatt landet im richtigen Ticket. Test: Task 8, „Webhook nach Umzug mit altem Namen“. --- ## Dateien | Datei | Aufgabe | |---|---| | `server/services/slug.js` (neu) | `slugify` mit Umlauten, Apostrophen und Akzenten | | `server/services/berlinTime.js` (neu) | `berlinDateTime` | | `server/services/keyedLock.js` (neu) | `withLocks` | | `server/services/giteaClient.js` (neu) | `giteaFetch`, `splitRepo` (aus `giteaAdmin.js` verschoben) | | `server/services/pushHistory.js` (neu) | `pushesFromFeed`, `listRecentPushes` | | `server/services/repoMove.js` (neu) | `WARN`, `isOrgRepo`, `repoLockKey`, `isInternalRepo`, `moveRepoToOrg`, `currentRepoFullName` | | `server/services/projectAssignment.js` (neu) | `backfillPushes`, `reconcileProject`, `assignProject` | | `server/config.js` | `gitea.org`, `gitea.systemUser`, `gitea.keepInPlace`, `gitea.backfillDays` | | `server/services/gitWorksheet.js` | deutsche Zeit, `pushedAt`, `isSystemPusher`, Sperre | | `server/services/giteaAdmin.js` | nutzt `giteaClient.js` und `slug.js`; `importNewRepos`, `isHostedProject`; Abgleich ohne Subdomain | | `server/services/previewDeploy.js` | `previewWorkDir` exportiert und in `runDeploy` genutzt | | `server/routes/admin/gitea.js` | `POST /import-repos` | | `server/routes/admin/websiteProjects.js` | `POST /:id/assign`, `POST /:id/pushes` | | `server/routes/webhook/gitea.js` | Sperre und `currentRepoFullName` | | `scripts/gitea-organisation-setup.mjs` (neu) | Organisation, Team, Organisations-Webhook | | `scripts/gitea-organisation-migrate.mjs` (neu) | Umzug der 6 Kundenprojekte | | `test/helpers/fakeAppwrite.js`, `test/helpers/fakeGitea.js` (neu) | Nachbauten; liegen nicht unter `test/*.test.js` und laufen daher nicht als Tests | | `test/*.test.js` (neu, je Task) | siehe Tasks | --- ### Task 1: Kleine Bausteine – slugify, deutsche Zeit, Sperren **Files:** - Create: `server/services/slug.js`, `server/services/berlinTime.js`, `server/services/keyedLock.js` - Test: `test/slug.test.js`, `test/berlinTime.test.js`, `test/keyedLock.test.js` **Interfaces:** - Produces: - `slugify(value: unknown): string` - `berlinDateTime(date: Date = new Date()): { date: string, time: string }` - `withLocks(keys: string[], fn: () => Promise, { waitMs = Infinity } = {}): Promise` - [ ] **Step 1: Write the failing tests** ```js // test/slug.test.js test('Umlaute', () => { assert.equal(slugify('Döner Nation'), 'doener-nation') assert.equal(slugify('Größe Überall Ärger'), 'groesse-ueberall-aerger') }) test('Apostrophe entfallen', () => { assert.equal(slugify("Emma's Schatzkiste"), 'emmas-schatzkiste') assert.equal(slugify('Emma’s'), 'emmas') }) test('Akzente', () => assert.equal(slugify('Café Élan'), 'cafe-elan')) test('Sonderzeichen, Ränder, Länge', () => { assert.equal(slugify('--A&B--'), 'a-b') assert.equal(slugify('x'.repeat(150)).length, 100) assert.equal(slugify(null), '') }) // test/berlinTime.test.js test('Sommerzeit', () => assert.deepEqual(berlinDateTime(new Date('2026-09-27T14:46:34Z')), { date: '27.09.2026', time: '1646' })) test('Winterzeit', () => assert.deepEqual(berlinDateTime(new Date('2026-01-15T10:05:00Z')), { date: '15.01.2026', time: '1105' })) test('nach Mitternacht UTC', () => assert.deepEqual(berlinDateTime(new Date('2026-09-27T22:30:00Z')), { date: '28.09.2026', time: '0030' })) // test/keyedLock.test.js – Hilfe: const log = []; const job = (name, ms) => async () => { log.push(name + ':start'); await sleep(ms); log.push(name + ':end') } test('gleicher Schlüssel läuft nacheinander', async () => { await Promise.all([withLocks(['a'], job('1', 50)), withLocks(['a'], job('2', 10))]) assert.deepEqual(log, ['1:start', '1:end', '2:start', '2:end']) }) test('verschiedene Schlüssel laufen parallel', async () => { await Promise.all([withLocks(['a'], job('1', 50)), withLocks(['b'], job('2', 10))]) assert.deepEqual(log.slice(0, 2), ['1:start', '2:start']) }) test('mehrere Schlüssel sperren alle', async () => { await Promise.all([withLocks(['a', 'b'], job('1', 50)), withLocks(['b'], job('2', 10))]) assert.deepEqual(log, ['1:start', '1:end', '2:start', '2:end']) }) test('waitMs begrenzt das Warten', async () => { const t0 = Date.now() let started await Promise.all([withLocks(['a'], job('1', 300)), withLocks(['a'], async () => { started = Date.now() - t0 }, { waitMs: 50 })]) assert.ok(started >= 45 && started < 250, `gestartet nach ${started} ms`) }) test('Fehler geben die Sperre frei', async () => { await assert.rejects(withLocks(['a'], async () => { throw new Error('x') })) assert.equal(await withLocks(['a'], async () => 42), 42) }) ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `npm test` Expected: FAIL. The three new test files cannot import their modules. - [ ] **Step 3: Implement the three modules** - **`slugify`:** 1. Lowercase the input. 2. Map ä→ae, ö→oe, ü→ue, ß→ss. 3. Remove accents with `normalize('NFD')` followed by removing `\p{M}`. 4. Remove `'`, `’`, `‘` and `` ` ``. 5. Turn `[^a-z0-9-]+` into `-`, strip leading and trailing `-`, then `slice(0, 100)`. - **`berlinDateTime`:** Use `Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(date)` and assemble `dd.mm.yyyy` and `hhmm` from the parts. - **`withLocks`:** Keep one promise chain per key in a module-level `Map`. 1. Wait on the current tails of all keys with `Promise.race([Promise.allSettled(tails), sleep(waitMs)])`. 2. Run `fn`. 3. Put a new tail for every key that resolves in `finally`. 4. Delete a key only when its tail is still the stored one. - [ ] **Step 4: Run the tests to verify they pass** Run: `npm test` Expected: PASS for all tests, including the old ones. - [ ] **Step 5: Commit** ```bash git add server/services/slug.js server/services/berlinTime.js server/services/keyedLock.js test/slug.test.js test/berlinTime.test.js test/keyedLock.test.js git commit -m "Bausteine: slugify mit Umlauten, deutsche Zeit, Sperren" ``` --- ### Task 2: Gitea-Client herauslösen, Konfiguration, Nachbauten **Files:** - Create: `server/services/giteaClient.js`, `test/helpers/fakeGitea.js`, `test/helpers/fakeAppwrite.js` - Modify: `server/services/giteaAdmin.js` (remove the local `giteaHeaders`/`giteaFetch`/`slugify`; import from `giteaClient.js` and `slug.js`), `server/config.js` (`gitea` block) - Test: `test/giteaClient.test.js` **Interfaces:** - Consumes: `slugify` (Task 1) - Produces: - `giteaFetch(path: string, options?: RequestInit): Promise`. Same behaviour as today: it adds `Authorization: token …`, throws an `Error` with `.status`, and turns a 409 "same name already exists" into the German message. - `splitRepo(fullName: string): [string, string]` - `config.gitea.org`, `config.gitea.systemUser`, `config.gitea.keepInPlace: string[]`, `config.gitea.backfillDays: number` - **`startFakeGitea(options)`** → `{ baseUrl, calls, repo(fullName), addRepo(repo), hooksOf(fullName), setDown(bool), setTransferDelay(ms), close() }`. - Options: - `repos: [{ full_name, archived?, private?, description?, default_branch? }]` - `hooks: { [fullName]: [{ id, url }] }` - `feeds: { [fullName]: entry[] }`, newest first - `contents: { [fullName + ':' + path]: string }` - `orgs: string[]` - `user = 'webklar-system'` - Routes, all under `/api/v1`: - `GET /user` - `GET /repos/search` (`{ ok, data }`, with `limit`/`page`) - `GET` and `PATCH /repos/:o/:r`. For a moved repo it answers `307` to the new path, like Gitea. - `POST /repos/:o/:r/transfer`. On success `202` with the new repo, and hooks, feeds and contents move along. If the target exists, `422 { message: 'repository already exists …' }`. - `GET /repos/:o/:r/hooks`, `DELETE /repos/:o/:r/hooks/:id` - `GET /repos/:o/:r/activities/feeds?limit&page` - `GET/POST/PUT /repos/:o/:r/contents/:path` - `GET /orgs/:org`, `POST /orgs` - `GET /orgs/:org/teams`, which returns Owners with id 1 for created orgs - `GET /teams/:id/members`, `PUT /teams/:id/members/:user` - `GET/POST /orgs/:org/hooks` - Names are case-insensitive. `setDown(true)` destroys every connection. - **`startFakeAppwrite({ seed })`** → `{ endpoint, all(col), get(col, id), calls, close() }`. - `endpoint` ends in `/v1`. - Documents under `/databases/:db/collections/:col/documents`: list with `queries[]` (`equal`, `limit`, `orderAsc`, `orderDesc`, `greaterThan`), create from `{ documentId, data }`, get, patch from `{ data }`, delete. - `GET /account`: `X-Appwrite-JWT: jwt-admin` returns `{ $id: 'staff1', labels: ['admin'] }`, anything else returns 401. - [ ] **Step 1: Write the failing test** `test/giteaClient.test.js` It starts `startFakeGitea({ repos: [{ full_name: 'JUSN/D-ner-Nation' }] })` and sets `GITEA_BASE_URL=` and `GITEA_API_TOKEN=test-token` before the dynamic import. ```js test('sendet den Token', async () => { await giteaFetch('/repos/JUSN/D-ner-Nation') assert.equal(fake.calls.at(-1).headers.authorization, 'token test-token') }) test('404 hat status', async () => { await assert.rejects(giteaFetch('/repos/x/y'), (e) => e.status === 404) }) test('Voreinstellungen der Konfiguration', () => { assert.equal(config.gitea.org, 'WEBklar') assert.equal(config.gitea.systemUser, 'webklar-system') assert.equal(config.gitea.backfillDays, 30) assert.ok(config.gitea.keepInPlace.includes('JUSN/tickte-system')) assert.equal(config.gitea.keepInPlace.length, 6) }) ``` - [ ] **Step 2: Run to verify it fails** Run: `node --test test/giteaClient.test.js` Expected: FAIL, because `giteaClient.js` is missing. - [ ] **Step 3: Implement** - Move `giteaHeaders` and `giteaFetch` into `giteaClient.js` unchanged, plus `splitRepo`. `giteaAdmin.js` imports them from there. - Replace the local `slugify` in `giteaAdmin.js` with the one from `slug.js`. - Build the fakes as specified under Interfaces. - In `config.js`, add inside `gitea`: - `org: process.env.GITEA_ORG || 'WEBklar'` - `systemUser: process.env.GITEA_SYSTEM_USER || 'webklar-system'` - `keepInPlace`: parsed from `GITEA_KEEP_IN_PLACE` (comma-separated, trimmed), defaulting to the list in Global Constraints - `backfillDays: Number(process.env.GITEA_BACKFILL_DAYS) || 30` - [ ] **Step 4: Run all tests** Run: `npm test` Expected: PASS. The old tests stay green; `giteaAdmin.js` has only been refactored. - [ ] **Step 5: Commit** ```bash git add server/services/giteaClient.js server/services/giteaAdmin.js server/config.js test/helpers/fakeGitea.js test/helpers/fakeAppwrite.js test/giteaClient.test.js git commit -m "Gitea-Client herausgelöst, Konfiguration für Organisation und System-Konto, Test-Nachbauten" ``` --- ### Task 3: Push-Arbeitsblätter – deutsche Zeit, keine System-Pushes, keine Doppelten **Files:** - Modify: `server/services/gitWorksheet.js` - Test: `test/gitWorksheet.test.js` **Interfaces:** - Consumes: `berlinDateTime`, `withLocks` (Task 1); `config.gitea.systemUser` (Task 2) - Produces: - `isSystemPusher(pusher): boolean`. The name is `pusher.username || pusher.login`, compared with `config.gitea.systemUser` case-insensitively. - `createGitPushWorksheet({ ticketId, repoFullName, branch, commits = [], pusher, commitSha, pushedAt = new Date() })` returns one of: - `{ created: true, wsid, worksheetId }` - `{ skipped: true, reason: 'system_pusher' | 'duplicate_commit' }` - `null` - [ ] **Step 1: Write the failing tests** The fake Appwrite gets the seed `workorders: [{ $id: 't1', woid: '100115', status: 'Assigned' }]` and `worksheets: [{ $id: 'w0', wsid: '100077', woid: '100001', details: '' }]`. The base call is: ```js const base = { ticketId: 't1', repoFullName: 'WEBklar/D-ner-Nation', branch: 'main', commits: [{ id: '26bc22ecf17e2e5a5a8794dcb4cf3892f802c51d', message: 'Döner Nation Website hinzufügen', author: { name: 'JUSN' } }], pusher: { login: 'JUSN', username: 'JUSN', full_name: '' }, commitSha: '26bc22ecf17e2e5a5a8794dcb4cf3892f802c51d' } ``` ```js test('Push-Zeit in deutscher Zeit', async () => { const r = await createGitPushWorksheet({ ...base, pushedAt: new Date('2026-09-27T14:46:34Z') }) assert.equal(r.created, true) const ws = fake.get('worksheets', r.worksheetId) assert.equal(ws.wsid, '100078') assert.equal(ws.employeeName, 'JUSN') assert.deepEqual([ws.startDate, ws.startTime, ws.endDate, ws.endTime], ['27.09.2026', '', '27.09.2026', '1646']) assert.equal(ws.totalTime, 0); assert.equal(ws.isComment, true); assert.equal(ws.serviceType, 'GIT') assert.match(ws.details, /Commit: 26bc22e/) }) test('zweiter Aufruf: Duplikat', async () => { assert.deepEqual(await createGitPushWorksheet({ ...base }), { skipped: true, reason: 'duplicate_commit' }) }) test('System-Konto erzeugt kein Arbeitsblatt', async () => { const before = fake.all('worksheets').length for (const name of ['webklar-system', 'WEBKLAR-SYSTEM']) { const r = await createGitPushWorksheet({ ...base, commitSha: 'aaaaaaa1', pusher: { login: name, username: name, full_name: '' } }) assert.deepEqual(r, { skipped: true, reason: 'system_pusher' }) } assert.equal(fake.all('worksheets').length, before) }) test('gleichzeitige Zustellung ergibt ein Arbeitsblatt', async () => { const args = { ...base, commitSha: 'bbbbbbb2' } const results = await Promise.all([createGitPushWorksheet(args), createGitPushWorksheet(args)]) assert.equal(results.filter((r) => r?.created).length, 1) assert.equal(fake.all('worksheets').filter((w) => w.details.includes('Commit: bbbbbbb')).length, 1) }) ``` - [ ] **Step 2: Run to verify it fails** Run: `node --test test/gitWorksheet.test.js` Expected: FAIL. `endTime` is in UTC, and the system pusher still gets a worksheet. - [ ] **Step 3: Implement** - Replace `formatToday` and `formatTimeNow` with `berlinDateTime(pushedAt)`. It sets `startDate`, `endDate` and `endTime`; `startTime` stays `''`. - Check `isSystemPusher(pusher)` before loading the ticket. - Run the existence check and the create step inside `withLocks([`ws:${woid}:${commitSha.slice(0, 7)}`], …)`. - The rest stays unchanged, including the field list and `DETAILS_MAX_LENGTH`. - [ ] **Step 4: Run all tests** Run: `npm test` Expected: PASS - [ ] **Step 5: Commit** ```bash git add server/services/gitWorksheet.js test/gitWorksheet.test.js git commit -m "Push-Arbeitsblätter: deutsche Zeit, keine System-Pushes, keine Doppelten" ``` --- ### Task 4: Push-Historie aus den Gitea-Aktivitäten **Files:** - Create: `server/services/pushHistory.js` - Test: `test/pushHistory.test.js` **Interfaces:** - Consumes: `giteaFetch`, `splitRepo`, `config.gitea.backfillDays` (Task 2) - Produces: - A `Push` has the shape `{ branch, commitSha, pushedAt: Date, pusher: { login, username, full_name }, commits: [{ id, message, author: { name } }] }`. - `pushesFromFeed(feed: object[], { since: Date }): Push[]`, oldest first. - `listRecentPushes(repoFullName, { days = config.gitea.backfillDays, now = new Date() } = {}): Promise`. - [ ] **Step 1: Write the failing tests** Feed entries look like Gitea 1.25: `{ op_type, act_user: { login, full_name }, ref_name, created, content }`. For `commit_repo`, `content` is a JSON string `{"Commits":[{"Sha1","Message","AuthorName",…}],"HeadCommit":{"Sha1",…}}`. ```js test('nur commit_repo mit Commits', () => { const feed = [pushEntry({ sha: 'c1', at: '2026-09-27T14:46:34Z' }), { ...pushEntry({ at: '2026-09-27T14:46:33Z' }), content: '' }, { op_type: 'create_repo', created: '2026-09-27T14:02:46Z', act_user: { login: 'JUSN' } }] assert.equal(pushesFromFeed(feed, { since: new Date('2026-09-01') }).length, 1) }) test('Felder wie beim Webhook', () => { const [p] = pushesFromFeed([pushEntry({ sha: 'c1', user: 'JUSN', at: '2026-09-27T14:46:34Z', msg: 'Döner Nation Website hinzufügen' })], { since: new Date('2026-09-01') }) assert.deepEqual(p, { branch: 'main', commitSha: 'c1', pushedAt: new Date('2026-09-27T14:46:34Z'), pusher: { login: 'JUSN', username: 'JUSN', full_name: '' }, commits: [{ id: 'c1', message: 'Döner Nation Website hinzufügen', author: { name: 'JUSN' } }] }) }) test('HeadCommit bestimmt commitSha', () => { const [p] = pushesFromFeed([pushEntry({ commits: ['c2', 'c1'], head: 'c2' })], { since: new Date(0) }) assert.equal(p.commitSha, 'c2'); assert.equal(p.commits.length, 2) }) test('ohne HeadCommit gilt der erste Commit', () => { const [p] = pushesFromFeed([pushEntry({ commits: ['c2', 'c1'], head: null })], { since: new Date(0) }) assert.equal(p.commitSha, 'c2') }) test('Anzeigename wird übernommen', () => { const [p] = pushesFromFeed([pushEntry({ user: 'knso', fullName: 'Kenso Grimm' })], { since: new Date(0) }) assert.equal(p.pusher.full_name, 'Kenso Grimm') }) test('älteste zuerst und 30-Tage-Grenze', () => { const now = new Date('2026-09-27T12:00:00Z') const feed = [daysAgo(now, 1, 'n1'), daysAgo(now, 2, 'n2'), daysAgo(now, 31, 'alt')] assert.deepEqual(pushesFromFeed(feed, { since: new Date(now - 30 * 864e5) }).map((p) => p.commitSha), ['n2', 'n1']) }) test('listRecentPushes blättert nur so weit wie nötig', async () => { // 120 Einträge, 3 pro Tag über 40 Tage, neueste zuerst; limit 50 const r = await listRecentPushes('knso/viel', { now }) assert.equal(r.length, 90) assert.equal(fake.calls.filter((c) => c.path.includes('/activities/feeds')).length, 2) }) ``` `pushEntry`, `daysAgo` and the 120-entry feed are small builders in the test file. - [ ] **Step 2: Run to verify it fails** Run: `node --test test/pushHistory.test.js` Expected: FAIL, because the module is missing. - [ ] **Step 3: Implement** **`pushesFromFeed`:** 1. Keep only entries with `op_type === 'commit_repo'` and `new Date(created) >= since` whose parsed `content` has a non-empty `Commits` array. If `JSON.parse` fails, skip the entry. 2. `branch` is `ref_name` without `refs/heads/`. 3. `commitSha` is `HeadCommit?.Sha1 || Commits[0].Sha1`. 4. The commits keep the order of the feed. 5. The pusher is `{ login: act_user.login, username: act_user.login, full_name: act_user.full_name || '' }`. 6. Sort the result by `pushedAt` in ascending order. **`listRecentPushes`:** 1. Fetch `GET /repos/{o}/{r}/activities/feeds?limit=50&page=N` starting with `N=1`. 2. Stop at an empty page, at a page with fewer than 50 entries, or when the oldest `created` of the page is older than `since = now − days`. 3. Pass everything to `pushesFromFeed`. - [ ] **Step 4: Run all tests** Run: `npm test` Expected: PASS - [ ] **Step 5: Commit** ```bash git add server/services/pushHistory.js test/pushHistory.test.js git commit -m "Push-Historie aus den Gitea-Aktivitäten lesen" ``` --- ### Task 5: Umzug nach WEBklar **Files:** - Create: `server/services/repoMove.js` - Modify: `server/services/previewDeploy.js`. Export `previewWorkDir(repoFullName)`, which returns `path.join(process.cwd(), 'preview-data', repoFullName.replace(/\//g, '_'))`, and use it in `runDeploy`. - Test: `test/repoMove.test.js` **Interfaces:** - Consumes: `giteaFetch`, `splitRepo`, `config.gitea.*` (Task 2); `withLocks` (Task 1); `listDocuments`, `updateDocument`, `Query` (`appwriteAdmin.js`); `invalidateProjectCache` (`projectInsights.js`); `previewWorkDir` - Produces: - `WARN = { internal, nameTaken(name), giteaDown(reason), docNotUpdated(reason) }`, using the exact texts from Global Constraints. - `isOrgRepo(repoFullName): boolean` - `repoLockKey(repoFullName): string`, which returns `'repo:' + repoFullName.toLowerCase()`. - `isInternalRepo(repoFullName): Promise`. It is true if the repo is in `keepInPlace` (case-insensitive) or has a hook whose URL does not start with the portal webhook URL. - `moveRepoToOrg(project): Promise<{ moved: { from, to } | null, warnings: string[] }>` - `currentRepoFullName(repoFullName): Promise` - [ ] **Step 1: Write the failing tests** Setup: - **Fake Gitea:** - repos `JUSN/D-ner-Nation`, `knso/emmas-schatzkiste`, `ANDJ/Steuerungstechnik`, `knso/hytail-ark`, `WEBklar/schon-da`, `JUSN/schon-da`, `WEBklar/von-hand`, `ANDJ/unberuehrt`, - the redirect `knso/von-hand` → `WEBklar/von-hand`, which is already moved in the fake, - hooks `knso/emmas-schatzkiste: [{ id: 110, url: 'https://project.webklar.com/webhook/gitea' }]` and `knso/hytail-ark: [{ id: 7, url: 'https://webklar.com/webhook/hytail-ark/deploy' }]`. - **Fake Appwrite:** one `websiteProjects` document per repo, with `repoFullName` as it is stored today. For von-hand that is still `knso/von-hand`. - **Working directory:** `process.chdir(tmp)` before the tests. ```js 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('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\.$/) }) ``` `doc(name)` returns the stored document of that repo. - [ ] **Step 2: Run to verify it fails** Run: `node --test test/repoMove.test.js` Expected: FAIL, because the module is missing. - [ ] **Step 3: Implement `moveRepoToOrg(project)` in this order** 1. Let `from = project.repoFullName`. If `isOrgRepo(from)`, return `{ moved: null, warnings: [] }`. 2. `GET /repos/{from}`. `fetch` follows Gitea's 307 redirect. If the answer has `full_name !== from`, update the document (step 6 without the transfer) and return `moved: { from, to: full_name }`. 3. If `isInternalRepo(from)`, return the `internal` warning. 4. Look at `GET /repos/{org}/{name}`. On 200, return `nameTaken(name)`; on 404, continue. 5. Inside `withLocks([repoLockKey(from), repoLockKey(to)])`, `POST /repos/{from}/transfer` with `{ new_owner: config.gitea.org }`. `to` is the `full_name` from the response. 6. Call `updateDocument(websiteProjects, project.$id, { repoFullName: to, giteaRepoUrl: `${config.gitea.baseUrl}/${to}`, giteaRepoName: name, updatedAt })`. If this fails, return `moved` with `docNotUpdated(err.message)`. 7. Call `invalidateProjectCache(from)` and `invalidateProjectCache(to)`. 8. For every hook of `to` whose URL starts with the portal webhook URL, send `DELETE /repos/{to}/hooks/{id}`. Errors only get logged. 9. `fs.rm(previewWorkDir(from), { recursive: true, force: true })`. Any network error (no `.status`) or 5xx from Gitea in steps 2–5 returns `giteaDown(err.message)`. `currentRepoFullName(name)`: 1. If a `websiteProjects` document with `repoFullName === name` exists, return `name`. 2. Otherwise return `(await giteaFetch(`/repos/${name}`)).full_name`. 3. If that fails, return `name`. - [ ] **Step 4: Run all tests** Run: `npm test` Expected: PASS - [ ] **Step 5: Commit** ```bash git add server/services/repoMove.js server/services/previewDeploy.js test/repoMove.test.js git commit -m "Umzug zugewiesener Repos nach WEBklar" ``` --- ### Task 6: Zuweisen, Umzug nachholen, Pushes nachtragen **Files:** - Create: `server/services/projectAssignment.js` - Test: `test/projectAssignment.test.js` **Interfaces:** - Consumes: - `moveRepoToOrg`, `WARN` (Task 5) - `listRecentPushes` (Task 4) - `createGitPushWorksheet`, `isSystemPusher` (Task 3) - `withLocks` (Task 1) - `ensureProjectTicket` (`ticketAdmin.js`) - `getDocument`, `updateDocument` (`appwriteAdmin.js`) - Produces: - `backfillPushes(project, { now = new Date() } = {}): Promise<{ backfilled: number, warnings: string[] }>` - `reconcileProject(projectId, { now } = {}): Promise<{ project, moved, backfilled, warnings }>`. It runs under `withLocks(['project:' + projectId])`. - `assignProject(projectId, { customerId = '', ticketId = '' }, { now } = {}): Promise<{ ok: true, project, moved, backfilled, warnings }>`. It throws an `Error` with `.status = 404` for an unknown project. - `SYSTEM_COMMIT_MESSAGES: string[]` (see Global Constraints). - [ ] **Step 1: Write the failing tests** Setup: - **Fake Appwrite:** - `websiteProjects`: - `p1` → `JUSN/D-ner-Nation` - `p2` → `JUSN/leer` - `p3` → `JUSN/nur-kunde` - `p4` → `JUSN/doppelt` All four have `customerId: ''` and `ticketId: ''`. - `workorders`: `t1` with `woid: '100115'`. - `customers`: `c1` with name `Döner Nation`. - **Fake Gitea feeds:** - `JUSN/D-ner-Nation`, newest first: - a `webklar-system` push at `2026-09-27T14:50:00Z` with commit `s1`, - a knso push at `14:48:00Z` with commit `k1` and the message `Update preview config from ticket system`, an old system commit that must not be backfilled, - a JUSN push at `14:46:34Z` with commit `26bc22e…`, - an empty-content push at `14:46:33Z`, - `create_repo`. - `JUSN/nur-kunde`: one JUSN push. - `JUSN/doppelt`: one JUSN push. - `JUSN/leer`: no pushes. `now` is always `new Date('2026-09-27T16:00:00Z')`. ```js 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 () => { gitea.setDown(true) const r = await assignProject('p2', { customerId: 'c1', ticketId: 't1' }, { now }) gitea.setDown(false) 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) }) ``` - [ ] **Step 2: Run to verify it fails** Run: `node --test test/projectAssignment.test.js` Expected: FAIL, because the module is missing. - [ ] **Step 3: Implement** **`backfillPushes(project)`:** 1. Load the pushes with `listRecentPushes(project.repoFullName, { now })`. Drop the ones where `isSystemPusher` is true and the ones where the first line of every commit message is in `SYSTEM_COMMIT_MESSAGES`. If Gitea fails, return `giteaDown(err.message)`. 2. If no pushes remain, return `{ backfilled: 0, warnings: [] }`. 3. `ticketId = project.ticketId || await ensureProjectTicket(project, { repoFullName: project.repoFullName })`. Without a ticket, return 0. 4. For each push, oldest first: `createGitPushWorksheet({ ticketId, repoFullName: project.repoFullName, ...push })`. Count the results with `created`. **`reconcileProject`** runs under the project lock: 1. `getDocument`, then `moveRepoToOrg`. 2. If something moved, load the document again. 3. Run `backfillPushes`. 4. Merge the warnings. **`assignProject`:** 1. `getDocument` (404 → an `Error` with `status 404`). 2. `updateDocument({ customerId, ticketId, updatedAt })`. 3. `reconcileProject`. 4. Return `{ ok: true, … }`. - [ ] **Step 4: Run all tests** Run: `npm test` Expected: PASS - [ ] **Step 5: Commit** ```bash git add server/services/projectAssignment.js test/projectAssignment.test.js git commit -m "Zuweisen mit Umzug und Nachtragen der Pushes" ``` --- ### Task 7: Neue Repos finden, Abgleich ohne Subdomains, Selbstheilung nur für gehostete Projekte **Files:** - Modify: `server/services/giteaAdmin.js` - Test: `test/giteaAdmin.test.js` **Interfaces:** - Consumes: `withLocks`, `repoLockKey` (Tasks 1, 5); `listAllAccessibleRepos` (unchanged) - Produces: - `isHostedProject(doc): boolean`. True if `['preview', 'website', 'template'].includes(doc.projectType)` or `['deployed', 'pending'].includes(doc.status)`. - `importNewRepos(): Promise<{ created: number }>`, running under `withLocks(['import'])`. - `syncReposToAppwrite()` keeps its signature, but no longer sets `subdomain` or `previewUrl`. - `ensureRepoPreviewConfig(repoFullName, branch)` returns `null` when `!isHostedProject(doc)`. - [ ] **Step 1: Write the failing tests** Setup: - **Fake Gitea repos:** `knso/alt`, which has a document, `JUSN/neu` with description `Döner Nation`, `JUSN/archiv` with `archived: true`, `knso/privat` with `private: true`, and `knso/synced-sub`, `knso/lead`. - **Fake Appwrite:** - `knso/alt` with subdomain `alt`, - `knso/synced-sub` with `status: 'synced'`, `subdomain: 'synced-sub'`, - `knso/lead` with `status: 'deployed'`, `subdomain: 'lead'`. ```js test('importNewRepos legt nur fehlende an', async () => { assert.deepEqual(await importNewRepos(), { created: 2 }) const neu = aw.all('websiteProjects').find((d) => d.repoFullName === 'JUSN/neu') assert.deepEqual([neu.projectName, neu.status, neu.subdomain || '', neu.previewUrl || ''], ['Döner Nation', 'synced', '', '']) assert.ok(aw.all('websiteProjects').some((d) => d.repoFullName === 'knso/privat')) assert.ok(!aw.all('websiteProjects').some((d) => d.repoFullName === 'JUSN/archiv')) assert.equal(aw.calls.filter((c) => c.method === 'PATCH').length, 0) }) test('importNewRepos läuft nie doppelt', async () => { gitea.addRepo({ full_name: 'ANDJ/zweimal' }) await Promise.all([importNewRepos(), importNewRepos()]) assert.equal(aw.all('websiteProjects').filter((d) => d.repoFullName === 'ANDJ/zweimal').length, 1) }) test('Abgleich vergibt keine Subdomain mehr', async () => { gitea.addRepo({ full_name: 'ANDJ/sync-neu' }) await syncReposToAppwrite() const d = aw.all('websiteProjects').find((x) => x.repoFullName === 'ANDJ/sync-neu') assert.equal(d.subdomain || '', ''); assert.equal(d.previewUrl || '', '') assert.equal(aw.all('websiteProjects').find((x) => x.repoFullName === 'knso/alt').subdomain, 'alt') }) test('isHostedProject', () => { for (const [doc, want] of [[{ projectType: 'preview' }, true], [{ projectType: 'website' }, true], [{ projectType: 'template' }, true], [{ status: 'deployed' }, true], [{ status: 'pending' }, true], [{ status: 'synced', subdomain: 'x' }, false], [{}, false]]) { assert.equal(isHostedProject(doc), want, JSON.stringify(doc)) } }) test('Selbstheilung nur für gehostete Projekte', async () => { assert.equal(await ensureRepoPreviewConfig('knso/synced-sub', 'main'), null) assert.equal(gitea.calls.filter((c) => c.path.includes('synced-sub/contents')).length, 0) const cfg = await ensureRepoPreviewConfig('knso/lead', 'main') assert.equal(cfg.subdomain, 'lead') assert.ok(gitea.calls.some((c) => c.method === 'POST' && c.path.includes('knso/lead/contents/.webklar-preview.json'))) }) ``` - [ ] **Step 2: Run to verify it fails** Run: `node --test test/giteaAdmin.test.js` Expected: FAIL - [ ] **Step 3: Implement** - **`importNewRepos`:** 1. Load all repos with `listAllAccessibleRepos()` and all documents with `listDocuments(websiteProjects, [Query.limit(5000)])`. Compare case-insensitively on `repoFullName`. 2. For every non-archived repo without a document, take `withLocks([repoLockKey(full_name)])`, check again, then `upsertWebsiteProjectByRepo(full_name, { projectName: description || name, giteaRepoUrl: html_url, giteaRepoName: name, repoFullName: full_name, archived: false, status: 'synced', giteaSyncedAt: now })`. - **`syncReposToAppwrite`:** Delete the call to `resolveSubdomainForSync` and the `subdomain`/`previewUrl` lines, and delete the now unused function. - **`ensureRepoPreviewConfig`:** Check with `isHostedProject(doc)` instead of `hasWebsite`. Keep `deriveProjectType` for the content of the config. - [ ] **Step 4: Run all tests** Run: `npm test` Expected: PASS - [ ] **Step 5: Commit** ```bash git add server/services/giteaAdmin.js test/giteaAdmin.test.js git commit -m "Neue Repos importieren, Abgleich ohne Subdomains, Selbstheilung nur für gehostete Projekte" ``` --- ### Task 8: Schnittstellen und Webhook **Files:** - Modify: `server/routes/admin/gitea.js`, `server/routes/admin/websiteProjects.js`, `server/routes/webhook/gitea.js` - Test: `test/giteaRoutes.test.js` **Interfaces:** - Consumes: `importNewRepos` (Task 7); `assignProject`, `reconcileProject` (Task 6); `withLocks` (Task 1); `repoLockKey`, `currentRepoFullName` (Task 5) - Produces: - **`POST /api/admin/gitea/import-repos`** returns `200 { ok: true, created }`. - **`POST /api/admin/website-projects/:id/assign`** with the body `{ customerId, ticketId }`: - `200 { ok: true, project, moved, backfilled, warnings }`, - `400 { error: 'customerId oder ticketId erforderlich' }` if both are empty, - `404 { error: 'Projekt nicht gefunden' }`. - **`POST /api/admin/website-projects/:id/pushes`** returns `200 { ok: true, moved, backfilled, warnings }`. - **Webhook:** The processing runs inside `withLocks([repoLockKey(payloadName)], …, { waitMs: 10000 })`. Within it, `repoFullName = await currentRepoFullName(payloadName)`, and all later steps use this name. - [ ] **Step 1: Write the failing tests** - **Test app:** `express()` with `express.json({ verify: (req, _res, buf) => { req.rawBody = buf } })`, mounted under the same paths as `server/index.js`. - **Environment:** `GITEA_WEBHOOK_TOKEN=s3cret` and `PREVIEW_DEPLOY_ROOT=` (empty). - **Signature:** `X-Gitea-Signature = createHmac('sha256', 's3cret').update(body).digest('hex')`. - **Data:** the same as in Task 6 for `p1`/`t1`, plus `p5` → `JUSN/langsam` (repo in the fake Gitea, no pushes). ```js 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 sleep(50) 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) }) ``` - [ ] **Step 2: Run to verify it fails** Run: `node --test test/giteaRoutes.test.js` Expected: FAIL. The routes are missing, and the webhook creates a second document for the old name. - [ ] **Step 3: Implement** - Add the routes the same way as the existing ones in the same files. Errors respond with `err.status || 500` and `{ error: err.message }`. - In the webhook handler, put the existing body into `withLocks` and use `currentRepoFullName` before `fetchPreviewConfig`. Only the name source and the lock change. - [ ] **Step 4: Run all tests** Run: `npm test` Expected: PASS - [ ] **Step 5: Commit** ```bash git add server/routes/admin/gitea.js server/routes/admin/websiteProjects.js server/routes/webhook/gitea.js test/giteaRoutes.test.js git commit -m "Schnittstellen für Import, Zuweisen, Nachtragen; Webhook wartet auf Umzug" ``` --- ### Task 9: Skripte für Einrichtung und Umzug **Files:** - Create: `scripts/gitea-organisation-setup.mjs`, `scripts/gitea-organisation-migrate.mjs` - Test: `test/giteaScripts.test.js` **Interfaces:** - Consumes: `reconcileProject` (Task 6); `isHostedProject`, `ensureRepoPreviewConfig`, `getRepoInfo` (`giteaAdmin.js`); `fetchPreviewConfig` (`previewDeploy.js`) - Produces: - **Setup script, exports:** - `readEnvFile(path): Record`. It reads `KEY=VALUE` lines, ignores `#` comments, strips surrounding `"` or `'`, and never prints anything. - `setupOrganisation({ baseUrl, token, org = 'WEBklar', members = ['knso', 'jusn', 'andj'], hookUrl = 'https://project.webklar.com/webhook/gitea', secret }): Promise<{ orgCreated, membersAdded: string[], hookCreated }>`. It is idempotent. - **Setup script, command line:** `node scripts/gitea-organisation-setup.mjs --env /home/webklar/apps/.env`. It uses `GITEA_SYSTEM_TOKEN` and `GITEA_WEBHOOK_TOKEN` from the file, `baseUrl` `https://git.webklar.com`, and prints the result as JSON. - **Migration script, exports:** - `CUSTOMER_REPOS = ['knso/emmas-schatzkiste', 'knso/Anelia-Wolf', 'ANDJ/Unger-Bus-Unternehmen', 'JUSN/D-ner-Nation', 'knso/videogen', 'knso/kfz']` - `migrateRepos({ repoFullNames = CUSTOMER_REPOS, backupDir, now = new Date() }): Promise>` - **Migration script, command line:** `node scripts/gitea-organisation-migrate.mjs`, run inside the container. `backupDir` is `/app/upload-data/gitea-org`. - **Order inside the migration:** 1. First write `hooks-.json` with the hooks of all repos (`GET /repos/{r}/hooks`: id, url, events). 2. Then, for each repo: find the document by `repoFullName`; if it is missing, return `warnings: ['kein Projekt-Eintrag']` and continue. 3. Call `reconcileProject(doc.$id, { now })`. 4. If `isHostedProject` is true and `fetchPreviewConfig(to, defaultBranch)` returns null, call `ensureRepoPreviewConfig(to, defaultBranch)` and set `previewConfigWritten: true`. - [ ] **Step 1: Write the failing tests** ```js test('Einrichtung legt Organisation, Mitglieder und Webhook an', async () => { const r = await setupOrganisation({ baseUrl: gitea.baseUrl, token: 't', secret: 's3cret' }) assert.deepEqual(r, { orgCreated: true, membersAdded: ['knso', 'jusn', 'andj'], hookCreated: true }) assert.deepEqual(gitea.calls.find((c) => c.method === 'POST' && c.path === '/api/v1/orgs').body, { username: 'WEBklar', full_name: 'WEBklar', visibility: 'public' }) assert.deepEqual(gitea.calls.find((c) => c.path === '/api/v1/orgs/WEBklar/hooks' && c.method === 'POST').body, { type: 'gitea', active: true, events: ['push'], branch_filter: '*', config: { url: 'https://project.webklar.com/webhook/gitea', content_type: 'json', secret: 's3cret' } }) }) test('Einrichtung ist wiederholbar', async () => { assert.deepEqual(await setupOrganisation({ baseUrl: gitea.baseUrl, token: 't', secret: 's3cret' }), { orgCreated: false, membersAdded: [], hookCreated: false }) }) test('readEnvFile', async () => { await fs.writeFile(envPath, '# x\nGITEA_SYSTEM_TOKEN="abc"\nGITEA_WEBHOOK_TOKEN=def\n') assert.deepEqual(readEnvFile(envPath), { GITEA_SYSTEM_TOKEN: 'abc', GITEA_WEBHOOK_TOKEN: 'def' }) }) test('Umzug sichert Webhooks und schreibt fehlende Preview-Einstellung', async () => { const r = await migrateRepos({ repoFullNames: ['knso/emmas-schatzkiste', 'JUSN/D-ner-Nation'], backupDir, now }) assert.deepEqual(r.map((x) => [x.to, x.previewConfigWritten]), [['WEBklar/emmas-schatzkiste', false], ['WEBklar/D-ner-Nation', true]]) const [file] = await fs.readdir(backupDir) assert.equal(JSON.parse(await fs.readFile(path.join(backupDir, file), 'utf8'))['knso/emmas-schatzkiste'][0].id, 110) }) ``` Test data: - `knso/emmas-schatzkiste`: hook 110 to the portal, `.webklar-preview.json` present, document with `projectType: 'website'`. - `JUSN/D-ner-Nation`: no config, document with `projectType: 'preview'`, `subdomain: 'doener-nation'`. - [ ] **Step 2: Run to verify it fails** Run: `node --test test/giteaScripts.test.js` Expected: FAIL - [ ] **Step 3: Implement both scripts** Run the command-line part only if `import.meta.url === pathToFileURL(process.argv[1]).href`. The setup script does: 1. `GET /orgs/{org}`. On 404, `POST /orgs`. 2. `GET /orgs/{org}/teams` and pick Owners. Add every member that is not yet in it with `PUT /teams/{id}/members/{user}`. Check first with `GET /teams/{id}/members`. 3. `GET /orgs/{org}/hooks`. If no hook has `config.url === hookUrl`, `POST` one. - [ ] **Step 4: Run all tests** Run: `npm test` Expected: PASS - [ ] **Step 5: Commit** ```bash git add scripts/gitea-organisation-setup.mjs scripts/gitea-organisation-migrate.mjs test/giteaScripts.test.js git commit -m "Skripte: Organisation einrichten, Kundenprojekte umziehen" ``` --- ### Task 10: Gesamtprüfung unter Node 20 **Files:** keine Änderungen - [ ] **Step 1: Run the tests on the host (Node 24)** Run: `npm test` Expected: all tests pass, with no `todo` and no `skip`. - [ ] **Step 2: Run the tests on Node 20, the production runtime** Run: ```bash docker run --rm -v /home/webklar/apps/kundenbereich.webklar.com:/home/webklar/apps/kundenbereich.webklar.com:ro -w /home/webklar/apps/kundenbereich.webklar.com/dev-gitea-org node:20-alpine npm test ``` Expected: the same number of passing tests as in Step 1. - [ ] **Step 3: Start check without production** Run `node server/index.js` from the worktree with `env -i PATH=$PATH PORT=3197 APPWRITE_ENDPOINT=http://127.0.0.1:9/v1 APPWRITE_API_KEY=x GITEA_API_TOKEN=x`. Expected: `läuft auf Port 3197`, and no import or syntax error. `config.js` falls back to PROD for `APPWRITE_ENDPOINT`, which is why the value is set explicitly. Stop the process by its PID. - [ ] **Step 4: Review** Run a fresh code review of the whole branch against the spec (`superpowers:requesting-code-review`). Fix any findings in their own commits. --- ## Livegang (Phasen 1, 2, 4) – jeder Schritt nur mit OK des Nutzers **L0 Sicherungen** ```bash mkdir -p /root/reports/gitea-org-2026-09-27 && chmod 700 /root/reports/gitea-org-2026-09-27 cp /home/webklar/apps/docker-compose.yml /home/webklar/apps/docker-compose.yml.bak-gitea-org cp /home/webklar/apps/.env /home/webklar/apps/.env.bak-gitea-org docker exec webklar-gitea-db sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -t webhook -t repository -t collaboration -t repo_redirect' > /root/reports/gitea-org-2026-09-27/gitea-vorher.sql chmod 600 /root/reports/gitea-org-2026-09-27/gitea-vorher.sql ``` Also take the preview statuses before the migration: ```bash for s in emmas-schatzkiste anelia-wolf unger-reisen doener-nation videogen kfz; do printf '%s ' $s; curl -s -o /dev/null -w '%{http_code}\n' https://$s.project.webklar.com/; done > /root/reports/gitea-org-2026-09-27/previews-vorher.txt ``` **L1 System-Konto und Schlüssel (Phase 1)** ```bash docker exec -u git webklar-gitea gitea admin user create --username webklar-system --email webklar-system@noreply.webklar.com --admin --random-password --must-change-password=false > /dev/null printf '\nGITEA_SYSTEM_TOKEN=%s\n' "$(docker exec -u git webklar-gitea gitea admin user generate-access-token --username webklar-system --token-name portal --scopes write:repository,write:organization,read:user --raw)" >> /home/webklar/apps/.env grep -c '^GITEA_SYSTEM_TOKEN=.\{30,\}$' /home/webklar/apps/.env ``` Expected: `1`. The token is never printed. Gitea sends no mails (`[mailer] ENABLED = false`), so there are no notification settings to change. **L2 Organisation (Phase 1)** Run `node scripts/gitea-organisation-setup.mjs --env /home/webklar/apps/.env` on the host, in the worktree. Expected: `{"orgCreated":true,"membersAdded":["knso","jusn","andj"],"hookCreated":true}`. Check it with a read-only query against the Gitea DB: - the organisation `webklar` exists, - the Owners team contains `webklar-system`, `knso`, `jusn` and `andj`, - there is one webhook with `owner_id` = the organisation and a URL starting with `https://project.webklar.com/webhook/gitea`. **L3 Portal-Einstellungen (Phase 2)** In `docker-compose.yml`, service `kundenbereich-server`: - `GITEA_API_TOKEN=${GITEA_SYSTEM_TOKEN}` - `GITEA_DEFAULT_OWNER=${GITEA_DEFAULT_OWNER:-WEBklar}` - new: `GITEA_ORG=WEBklar` and `GITEA_SYSTEM_USER=webklar-system` Then run `cd /home/webklar/apps && docker compose up -d --no-deps kundenbereich-server`. Check: 1. The log shows `[startup] Preview-TLS-Sync: {"synced":true,…,"publicSource":"appwrite"}`. 2. This command prints `webklar-system true`: ```bash docker exec webklar-kundenbereich-server node -e "fetch(process.env.GITEA_BASE_URL+'/api/v1/user',{headers:{Authorization:'token '+process.env.GITEA_API_TOKEN}}).then(r=>r.json()).then(u=>console.log(u.login,u.is_admin))" ``` **L4 Branch prüfen** - `git fetch origin && git log --oneline origin/main -3`. - Another session may have pushed to `main` in the meantime. In that case rebase (`git rebase origin/main`), run `npm test` again, and have a range-diff checked. **L5 Code live (Phase 2)** Push with the user's OK: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-gitea-org push origin feature/gitea-organisation:main`. Check: 1. `build/` is at the new commit, and the container restarted without errors. 2. `curl -s -o /dev/null -w '%{http_code}' -X POST https://project.webklar.com/api/admin/gitea/import-repos` returns `401`. 3. The Preview sync reports 104 hosts or more, with `publicSource` `appwrite`. **L6 Umzug der Kundenprojekte (Phase 4)** Run `docker exec -w /app webklar-kundenbereich-server node scripts/gitea-organisation-migrate.mjs`. Expected: 6 entries. Each has `to` beginning with `WEBklar/` and no warning. For Döner Nation, `backfilled` is at least 1 and `previewConfigWritten` is `true`. For Anelia Wolf, `backfilled` is 1: the push `first publish` by knso from 06.09., which happened before its webhook existed. The system commit from the same day is skipped. For the other four, `backfilled` is 0; they had no pushes in the 30 days before 27.09. Check: - The statuses in `previews-vorher.txt` are unchanged when measured again. - The 6 project documents carry the new names. - Ticket 100115 has an arbeitsblatt from `JUSN` with end 16:46. - The per-repo hooks 110, 100, 13 and 59 are gone. Then tell the employees the per-repo commands `git remote set-url origin https://git.webklar.com/WEBklar/.git`, and tell Justin to run `git pull` once before his next push. **L7 Live-Durchlauf „eigenes Konto“** (spec 11), only with the user's explicit OK. It runs as a script in the portal container, and nothing is printed except results. 1. Create `knso/umzug-probe` with knso's old key (the value of `GITEA_WEBHOOK_TOKEN` in the container): `POST /user/repos` with `{ name: 'umzug-probe' }`, then create `index.html` through the contents API. That is a push by knso. 2. Call `importNewRepos()`. There is then an entry for `knso/umzug-probe`. 3. Call `assignProject(, { ticketId: <$id of test ticket #99999> })`. Expected: `moved.to === 'WEBklar/umzug-probe'`, `backfilled === 1`, and an arbeitsblatt with the German push time. 4. Make a second file change with knso's key in `WEBklar/umzug-probe`. Within a minute, the organisation webhook produces a second arbeitsblatt on #99999. `GET /repos/WEBklar/umzug-probe/hooks` returns `[]`. 5. Clean up: delete the repo (`DELETE /repos/WEBklar/umzug-probe`), the `websiteProjects` entry, and both arbeitsblätter, then check that all three are gone. **L8 Notizen** Update the memory notes (`webklar-preview-projekte`, `webklar-kundenportal`). **Rückweg** - **L3:** restore `docker-compose.yml.bak-gitea-org` and run `docker compose up -d --no-deps kundenbereich-server`. - **L5:** `git revert` the commits on a branch, then push after the OK. - **L6:** transfer every repo back to its old owner (`POST /repos/WEBklar//transfer` with `new_owner`), then recreate the hooks from `hooks-.json` with the secret `GITEA_WEBHOOK_TOKEN`. Use a script in the container, so the secret is never printed.