From a3faa1aff19f935362affbdf3d22aa8726eb0753 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:16:54 +0000 Subject: [PATCH] =?UTF-8?q?Umsetzungspl=C3=A4ne:=20Kundenportal,=20Tickets?= =?UTF-8?q?ystem,=20Lead-Automatik?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 1 Kundenportal (Gitea-Grundlage, Umzug, Nachtragen, Livegang), Plan 2 Ticketsystem (Dialog, Knöpfe), Plan 3 Lead-Automatik (neue Lead-Repos in WEBklar über das System-Konto). Co-Authored-By: Claude Opus 5.5 --- ...09-27-gitea-organisation-1-kundenportal.md | 1117 +++++++++++++++++ ...09-27-gitea-organisation-2-ticketsystem.md | 344 +++++ ...-27-gitea-organisation-3-lead-automatik.md | 343 +++++ 3 files changed, 1804 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-27-gitea-organisation-1-kundenportal.md create mode 100644 docs/superpowers/plans/2026-09-27-gitea-organisation-2-ticketsystem.md create mode 100644 docs/superpowers/plans/2026-09-27-gitea-organisation-3-lead-automatik.md diff --git a/docs/superpowers/plans/2026-09-27-gitea-organisation-1-kundenportal.md b/docs/superpowers/plans/2026-09-27-gitea-organisation-1-kundenportal.md new file mode 100644 index 0000000..9af4c5c --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-gitea-organisation-1-kundenportal.md @@ -0,0 +1,1117 @@ +# 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. diff --git a/docs/superpowers/plans/2026-09-27-gitea-organisation-2-ticketsystem.md b/docs/superpowers/plans/2026-09-27-gitea-organisation-2-ticketsystem.md new file mode 100644 index 0000000..eea67a7 --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-gitea-organisation-2-ticketsystem.md @@ -0,0 +1,344 @@ +# Plan 2 – Ticketsystem: neue Repos sehen, Zuweisen mit Umzug, Preview und Pushes per Knopf + +> **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:** Der Dialog „Projekte bearbeiten“ zeigt neue Gitea-Repos sofort. „Zuweisen“ läuft über das Portal, das das Repo verschiebt und Pushes nachträgt, und zeigt danach eine Zusammenfassung. Zugewiesene Projekte bekommen die Knöpfe „Preview einschalten“ und „Pushes nachtragen“. „Kunde zuordnen“ auf der Projekte-Seite nutzt denselben Weg. + +**Aufbau:** + +- **Reine Logik** in zwei neuen Dateien: + - `src/lib/subdomain.js`: Subdomain-Vorschlag, + - `src/lib/projectAssign.js`: Organisation, Texte der Zusammenfassung, Zuweisen nacheinander. + Beide werden mit `node --test` geprüft, geladen über esbuild. Das Ticketsystem hat bisher keinen Testlauf. +- **Portal-Aufrufe** kommen nach `src/lib/projectAdminApi.js`. +- **Oberfläche:** Der Hook `useWebsiteProjects` und drei Komponenten nutzen das. +- **Oberflächen-Prüfung:** Playwright gegen einen echten vite-Build mit nachgebautem Appwrite und Portal. Sie liegt im Scratchpad und wird nicht committet. + +**Technik:** + +- React 18, Vite 5 +- `appwrite` 13 (Web-SDK) +- Node 24 auf dem Host für `node --test` +- esbuild aus `node_modules`, genutzt über die JS-Schnittstelle, nicht über `bin/esbuild` + +**Spec:** `docs/superpowers/specs/2026-09-27-gitea-organisation-design.md` im Kundenportal-Repo. Maßgeblich sind die Abschnitte 5.7, 6 und 11. + +**Voraussetzung:** Plan 1 ist live (dort Livegang L5), sonst fehlen die Schnittstellen. Der Dialog muss trotzdem bedienbar bleiben, wenn `import-repos` scheitert. + +## Global Constraints + +**Werte und Texte, wörtlich** + +- Organisation `WEBklar`, in `src/lib/projectAssign.js` als `GITEA_ORG` festgelegt. +- Portal-Schnittstellen, jeweils mit `Authorization: Bearer ` über das vorhandene `adminFetch`: + - `POST /api/admin/gitea/import-repos` → `{ ok, created }` + - `POST /api/admin/website-projects/:id/assign` mit `{ customerId, ticketId }` → `{ ok, project, moved, backfilled, warnings }` + - `POST /api/admin/website-projects/:id/pushes` → `{ ok, moved, backfilled, warnings }` + - `PATCH /api/admin/website-projects/:id` mit `{ projectType: 'preview', subdomain }` → `{ ok, previewUrl, … }` +- Texte: + - `Suche neue Repos …` + - `wird beim Zuweisen nach WEBklar verschoben` + - `Preview einschalten`, `Einschalten`, `Pushes nachtragen` + - `` `Preview läuft: ${previewUrl}` `` + - `` `Neue Repos konnten nicht geladen werden (${message}).` `` + - `` `${projectName}: Zuweisen fehlgeschlagen – ${error}` `` +- Die Zusammenfassung (`assignSummary`) besteht aus zwei Teilen, getrennt durch ` · `: + - „nach WEBklar verschoben“, wenn verschoben wurde, + - danach „keine neuen Pushes“, „1 Push nachgetragen“ oder `` `${n} Pushes nachgetragen` ``. + - Beispiel aus dem Spec: `Döner Nation: nach WEBklar verschoben · 1 Push nachgetragen`. +- **Subdomain-Vorschlag:** + 1. Grundlage ist der Kundenname des Tickets, sonst der Projektname. + 2. Kleinschreibung. + 3. ä→ae, ö→oe, ü→ue, ß→ss. + 4. Akzente entfernen (NFD). + 5. Apostrophe `'` `’` `‘` `` ` `` entfernen. + 6. `[^a-z0-9]+` → `-`, Bindestriche am Rand entfernen. + 7. Höchstens 63 Zeichen, danach nochmals Bindestriche am Ende entfernen. +- **„Preview einschalten“** erscheint, wenn `!project.previewUrl || project.status !== 'deployed'`, und nur, wenn die Karte `showActions` hat (also im Dialog). + +**Git und Deploy – wichtig** + +- Das Deploy-Webhook des Ticketsystems setzt **jeden** gepushten Branch live. Den Feature-Branch deshalb niemals pushen. Live geht es nur mit OK des Nutzers per `git push origin feature/gitea-organisation:test`. +- `node_modules` ist im Repo **versioniert** (etwa 8900 Dateien), und im Worktree ist es ein Symlink. Deshalb nur `git add `, niemals `-A` oder `.`. +- Eine andere Sitzung arbeitet parallel in `dev-entschlackung`, gerade an `App.jsx`, `InvoicePanel.jsx`, `useInvoices.js`, `integrationsApi.js` und `FinancePage.jsx`. Diese Dateien fasst Plan 2 nicht an. Vor dem Push `git rebase origin/test`. +- Neue Texte werden in UTF-8 mit echten Umlauten geschrieben. Die berührten Dateien sind bisher reines ASCII und bleiben gültiges UTF-8. + +## Review Focus + +1. **Das Portal ist noch nicht aktualisiert.** `import-repos` antwortet dann mit 404 oder 500. Der Dialog zeigt nur den Hinweis, und die Listen laden trotzdem. Test: Task 4, Prüfung 7. +2. **Beim Zuweisen mehrerer Projekte scheitert eines.** Die anderen werden trotzdem zugewiesen, und die Zusammenfassung nennt den Fehler beim betroffenen Projekt. Test: Task 1, „nacheinander und weiter nach Fehler“. +3. **Das Ticket hat keinen Kundennamen.** Der Vorschlag nimmt dann den Projektnamen. Sind beide leer, bleibt das Feld leer, und „Einschalten“ ist gesperrt, bis etwas eingetragen ist. Tests: Task 1 (`suggestSubdomain('', '')`) und Task 4, Prüfung 4. +4. **Namen mit Zeichen wie „&“, „+“, „ß“ oder „é“.** Das ergibt eine gültige DNS-Subdomain ohne doppelte Bindestriche. Test: Task 1, „Sonderzeichen“. +5. **Die Subdomain ist schon vergeben (409 vom Portal).** Die Meldung des Portals erscheint, und die Eingabe bleibt stehen. Test: Task 4, Prüfung 5. + +--- + +## Dateien + +| Datei | Aufgabe | +|---|---| +| `src/lib/subdomain.js` (neu) | `suggestSubdomain` | +| `src/lib/projectAssign.js` (neu) | `GITEA_ORG`, `isOutsideOrg`, `needsPreviewButton`, `assignSummary`, `assignSequentially` | +| `src/lib/projectAdminApi.js` | `importNewRepos`, `assignWebsiteProject`, `backfillProjectPushes` | +| `src/hooks/useWebsiteProjects.js` | `assignProjects` über das Portal, nacheinander | +| `src/components/TicketProjectsModal.jsx` | Import beim Öffnen, Hinweis, Zusammenfassung, Daten für die Karte | +| `src/components/AssignedProjectCard.jsx` | Knöpfe „Preview einschalten“ und „Pushes nachtragen“ (nur mit `showActions`) | +| `src/pages/ProjectsPage.jsx` | „Kunde zuordnen“ zeigt die Zusammenfassung | +| `package.json` | Skript `"test": "node --test tests/unit/*.test.mjs"` (Muster statt Ordner, damit Node 20 und 24 gleich laufen) | +| `tests/unit/load.mjs`, `tests/unit/*.test.mjs` (neu) | Laden der Quellen über esbuild, Tests | + +--- + +### Task 0: Worktree anlegen + +- [ ] **Step 1: Create the worktree from the current `test`** + +```bash +cd /home/webklar/apps/ticket.webklar.com/build && git fetch -q origin +git worktree add -b feature/gitea-organisation ../dev-gitea-org origin/test +cd ../dev-gitea-org && git branch --unset-upstream; rm -rf node_modules && ln -s ../build/node_modules node_modules +git log --oneline -1 +``` + +Expected: HEAD = `origin/test`. `git status --short | grep -v node_modules` shows nothing. + +--- + +### Task 1: Reine Logik mit Tests + +**Files:** +- Create: `src/lib/subdomain.js`, `src/lib/projectAssign.js`, `tests/unit/load.mjs`, `tests/unit/subdomain.test.mjs`, `tests/unit/projectAssign.test.mjs` +- Modify: `package.json` (only `scripts.test`) + +**Interfaces:** +- Produces: + - `suggestSubdomain(customerName?: string, projectName?: string): string` + - `GITEA_ORG = 'WEBklar'` + - `isOutsideOrg(repoFullName: string): boolean`. It is `false` for an empty name, and the comparison ignores case. + - `needsPreviewButton(project): boolean` + - `assignSummary(projectName: string, result: { moved, backfilled, warnings } | { error: string }): { text: string, warnings: string[] }` + - `assignSequentially(projectIds: string[], ctx, assignFn: (id, ctx) => Promise): Promise>` + - `loadModule(relPathFromRepoRoot): Promise` in `tests/unit/load.mjs`. It uses esbuild `build({ bundle: true, format: 'esm', write: false, platform: 'neutral' })` and `import()` from a `data:` URL. + +- [ ] **Step 1: Write the failing tests** + +```js +// tests/unit/subdomain.test.mjs +const { suggestSubdomain } = await loadModule('src/lib/subdomain.js') +test('Kundenname vor Projektname', () => assert.equal(suggestSubdomain('Döner Nation', 'D-ner-Nation'), 'doener-nation')) +test('Apostroph', () => assert.equal(suggestSubdomain("Emma's Schatzkiste", ''), 'emmas-schatzkiste')) +test('Sonderzeichen', () => assert.equal(suggestSubdomain('', 'Café Müller & Söhne + Straße'), 'cafe-mueller-soehne-strasse')) +test('leer', () => assert.equal(suggestSubdomain('', ''), '')) +test('63 Zeichen ohne Bindestrich am Ende', () => assert.equal(suggestSubdomain('a'.repeat(62) + ' b', ''), 'a'.repeat(62))) + +// tests/unit/projectAssign.test.mjs +test('isOutsideOrg', () => { + assert.equal(isOutsideOrg('JUSN/D-ner-Nation'), true) + assert.equal(isOutsideOrg('webklar/x'), false) + assert.equal(isOutsideOrg(''), false) +}) +test('needsPreviewButton', () => { + assert.equal(needsPreviewButton({ previewUrl: 'https://x', status: 'deployed' }), false) + assert.equal(needsPreviewButton({ previewUrl: '', status: 'synced' }), true) + assert.equal(needsPreviewButton({ previewUrl: 'https://x', status: 'pending' }), true) +}) +test('assignSummary', () => { + assert.deepEqual(assignSummary('Döner Nation', { moved: { from: 'JUSN/D-ner-Nation', to: 'WEBklar/D-ner-Nation' }, backfilled: 1, warnings: [] }), + { text: 'Döner Nation: nach WEBklar verschoben · 1 Push nachgetragen', warnings: [] }) + assert.deepEqual(assignSummary('X', { moved: null, backfilled: 0, warnings: ['Internes Repo, bleibt wo es ist.'] }), + { text: 'X: keine neuen Pushes', warnings: ['Internes Repo, bleibt wo es ist.'] }) + assert.equal(assignSummary('X', { moved: null, backfilled: 3, warnings: [] }).text, 'X: 3 Pushes nachgetragen') + assert.deepEqual(assignSummary('X', { error: 'boom' }), { text: 'X: Zuweisen fehlgeschlagen – boom', warnings: [] }) +}) +test('nacheinander und weiter nach Fehler', async () => { + const log = [] + const fn = async (id) => { log.push(id + ':start'); await sleep(10); log.push(id + ':end'); if (id === 'b') throw new Error('boom'); return { backfilled: 0 } } + const r = await assignSequentially(['a', 'b', 'c'], { ticketId: 't1' }, fn) + assert.deepEqual(log, ['a:start', 'a:end', 'b:start', 'b:end', 'c:start', 'c:end']) + assert.deepEqual(r.map((x) => [x.projectId, x.ok, x.error]), [['a', true, undefined], ['b', false, 'boom'], ['c', true, undefined]]) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npm test` +Expected: FAIL, because the modules are missing. The script is already added. + +- [ ] **Step 3: Implement both modules** + +Follow the rules in Global Constraints. `assignSequentially` runs the calls strictly one after another. It catches every error as `{ ok: false, error: err.message }` and continues with the next ID. + +- [ ] **Step 4: Run to verify it passes** + +Run: `npm test` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/subdomain.js src/lib/projectAssign.js tests/unit/load.mjs tests/unit/subdomain.test.mjs tests/unit/projectAssign.test.mjs package.json +git commit -m "Subdomain-Vorschlag und Zuweisungs-Logik mit Tests" +``` + +--- + +### Task 2: Portal-Aufrufe und Hook + +**Files:** +- Modify: `src/lib/projectAdminApi.js`, `src/hooks/useWebsiteProjects.js` + +**Interfaces:** +- Consumes: `assignSequentially` (Task 1) +- Produces: + - `importNewRepos(): Promise<{ ok, created }>` + - `assignWebsiteProject(id, { customerId, ticketId }): Promise<{ ok, project, moved, backfilled, warnings }>` + - `backfillProjectPushes(id): Promise<{ ok, moved, backfilled, warnings }>` + - `useWebsiteProjects().assignProjects(projectIds, { customerId, ticketId })` returns `{ success: boolean, error?: string, results }`. + - `results` comes from `assignSequentially` with `assignWebsiteProject`. + - `success` is true only if every result is `ok`. + - `error` is the first error text. + - `unassignProject` stays as it is, directly in Appwrite. + +- [ ] **Step 1: Implement** + +Use the existing `adminFetch` pattern, with method POST and body `JSON.stringify(...)`. `assignProject`, the single direct Appwrite update, is removed from the hook. It has no other callers: check with `git grep -n "assignProject\b" src`. + +- [ ] **Step 2: Build check** + +Run: + +```bash +node -e "require('esbuild').build({entryPoints:['src/hooks/useWebsiteProjects.js'],bundle:true,write:false,format:'esm',platform:'browser',external:['react','appwrite'],define:{'import.meta.env.VITE_PROJECT_ADMIN_URL':'\"https://project.webklar.com\"'},loader:{'.js':'jsx','.css':'empty','.ttf':'empty','.woff2':'empty','.png':'empty','.svg':'empty'}}).then(()=>console.log('ok'))" +``` + +Expected: `ok`. `npm test` stays green. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/projectAdminApi.js src/hooks/useWebsiteProjects.js +git commit -m "Zuweisen über das Portal, Import neuer Repos, Pushes nachtragen" +``` + +--- + +### Task 3: Dialog, Projektkarte, Projekte-Seite + +**Files:** +- Modify: `src/components/TicketProjectsModal.jsx`, `src/components/AssignedProjectCard.jsx`, `src/pages/ProjectsPage.jsx` + +**Interfaces:** +- Consumes: Tasks 1 and 2; `updateWebsiteProject` (already in `projectAdminApi.js`) +- Produces: + - `AssignedProjectCard` gets the new optional props `customerName` and `onChanged()`. Without `showActions` it looks exactly as before. + +- [ ] **Step 1: Implement `TicketProjectsModal`** + +1. **When the dialog opens:** + - set `importing = true` and show `Suche neue Repos …`, + - call `await importNewRepos()`; on an error, set `importHint` to `` `Neue Repos konnten nicht geladen werden (${err.message}).` `` and carry on, + - then call `loadProjects()`. +2. **List entries:** For `isOutsideOrg(p.repoFullName)`, add a grey note to the entry: `wird beim Zuweisen nach WEBklar verschoben`. +3. **`handleAssign`:** + - call `assignProjects(selectedIds, { customerId: ticket.customerId || '', ticketId: ticket.$id })`, + - build a list of `assignSummary(projectName, result.ok ? result.result : { error: result.error })` from `results` and show it under the button: one line per project, warnings indented, + - clear the selection, then `loadProjects()` and `onUpdated?.()`. +4. **Cards:** Pass `customerName={ticket.customerName || ''}` and `onChanged={() => { loadProjects(); onUpdated?.() }}` to `AssignedProjectCard`. + +- [ ] **Step 2: Implement `AssignedProjectCard`** + +These controls appear only with `showActions`: + +- **„Preview einschalten“** appears if `needsPreviewButton(project)`. + - A click shows an input prefilled with `suggestSubdomain(customerName, project.projectName)` and the button `Einschalten`. It is disabled while the input is empty or a request is running. + - `Einschalten` calls `updateWebsiteProject(project.$id, { projectType: 'preview', subdomain })`. + - On success: the message `` `Preview läuft: ${r.previewUrl}` `` and `onChanged()`. + - On an error: `err.message`, and the input stays. +- **„Pushes nachtragen“** calls `backfillProjectPushes(project.$id)` and shows `assignSummary(project.projectName, r)`, then `onChanged()`. +- Messages appear inside the card, below the status line. Buttons use `className="btn btn-sm"`, like the existing trash button. + +- [ ] **Step 3: Implement `ProjectsPage`** + +`handleAssignCustomer`: + +- With a customer: the result of `assignProjects([project.$id], …)` shows as `setNotice(assignSummary(project.projectName, r.results[0].ok ? r.results[0].result : { error: r.results[0].error }).text)`. Warnings are appended with ` – `. +- Without a customer: unchanged. + +- [ ] **Step 4: Build check** + +Run the same esbuild call as in Task 2 Step 2, once each for `src/components/TicketProjectsModal.jsx` and `src/pages/ProjectsPage.jsx`, with `external:['react','react-dom','react-icons/*','appwrite','react-router-dom','react-markdown','@tabler/icons-react']`. + +Expected: `ok` both times. `npm test` stays green. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/TicketProjectsModal.jsx src/components/AssignedProjectCard.jsx src/pages/ProjectsPage.jsx +git commit -m "Dialog: neue Repos, Zuweisen mit Umzug, Preview einschalten, Pushes nachtragen" +``` + +--- + +### Task 4: Oberflächen-Prüfung (Scratchpad, nicht committen) + +**Files:** Scratchpad `…/scratchpad/woms-ui/` (build script, `ui.mjs`) + +**Setup:** + +- **vite-Build** like the deploy: + + ```bash + git archive HEAD | tar -x -C + docker run --rm -v :/w -w /w node:20-alpine sh -c "rm -rf node_modules && npm install && npm run build" + ``` + +- **Browser:** `playwright-core` from `/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core` (load it via `createRequire`) and Chromium at `~/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome`. Run it under `systemd-run --scope -p MemoryMax=1200M -p MemorySwapMax=0`, and check `free -m` beforehand. +- **Page:** served from `https://ticket.test` via `page.route`. +- **Appwrite mock:** + - a logged-in admin session and `POST /v1/account/jwts` → `{ jwt: 'jwt-admin' }`, + - `listDocuments` for workorders: ticket 100115, customer name „Döner Nation“, + - `websiteProjects`: + - `JUSN/Ben-Breidt` and `WEBklar/sauber`, both available, + - `WEBklar/D-ner-Nation`, assigned to ticket 100115, without `previewUrl`, status `synced`, + - plus employees and customers. +- **Portal mock** under `https://project.webklar.com/api/admin/**`. It records every request with its start and end time. +- If the Entschlackung harness still exists (`appsmoke.mjs` in scratchpad `c07881f0…/wstimes`), reuse its Appwrite mock. + +**Prüfungen** (all must pass): + +1. Opening „Projekte zuweisen / bearbeiten“ on ticket 100115 sends exactly one `POST /api/admin/gitea/import-repos`. While the answer is pending (delayed by 500 ms), the dialog shows `Suche neue Repos …`. +2. `JUSN/Ben-Breidt` carries `wird beim Zuweisen nach WEBklar verschoben`. `WEBklar/sauber` does not. +3. Selecting both and clicking „Zuweisen“ sends two `…/assign` requests. The second starts only after the first has answered. Two summary lines appear. The first is `Ben Breidt: nach WEBklar verschoben · 1 Push nachgetragen`, with the mock answer to match. +4. The card of `D-ner-Nation` has „Preview einschalten“. + - The input shows `doener-nation`. + - Changed to `doener-test` and sent with „Einschalten“, it produces `PATCH …/website-projects/` with the body `{"projectType":"preview","subdomain":"doener-test"}`. + - The card then shows `Preview läuft: https://doener-test.project.webklar.com`. + - Once the field is cleared, „Einschalten“ is disabled. +5. When the PATCH answers `409 { error: 'Subdomain "doener-nation" ist bereits vergeben' }`, exactly that text appears and the input stays. +6. „Pushes nachtragen“ sends `POST …/pushes` and shows `Döner Nation: keine neuen Pushes`. +7. When `import-repos` answers 404, the dialog shows `Neue Repos konnten nicht geladen werden (…)`, and both lists still appear. +8. On the Projekte page, „Kunde zuordnen“ sends `…/assign` with `{"customerId":"","ticketId":""}`, and the notice shows the summary. +9. The ticket's Details tab (`TicketAssignedProjectsPanel`) and the customer page show the card without the new buttons. + +Expected: the script prints `9/9 ok`. + +--- + +## Livegang – nur mit OK des Nutzers, erst nach Plan 1 L5 + +1. Update and test: + + ```bash + git -C /home/webklar/apps/ticket.webklar.com/dev-gitea-org fetch origin && git -C … rebase origin/test + ``` + + Then run `npm test`, and the Task 4 check once more against the rebased state. +2. Deploy-like check: `git archive` + `docker run node:20-alpine` (`rm -rf node_modules; npm install; npm run build`) finishes without errors. +3. Push: `git -C /home/webklar/apps/ticket.webklar.com/dev-gitea-org push origin feature/gitea-organisation:test`. +4. Check: + - the `out/` bundle and the live https://ticket.webklar.com contain the string `Suche neue Repos`, + - the deploy log shows `npm run build` without errors, + - opening the dialog in the live ticket 100115 lists `JUSN/Ben-Breidt` and similar repos. Doing this is only reading. +5. Update the memory note `woms-ticketsystem`. + +**Rückweg:** `git revert` the Plan 2 commits on a branch, then push to `test` after the OK. diff --git a/docs/superpowers/plans/2026-09-27-gitea-organisation-3-lead-automatik.md b/docs/superpowers/plans/2026-09-27-gitea-organisation-3-lead-automatik.md new file mode 100644 index 0000000..19f8905 --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-gitea-organisation-3-lead-automatik.md @@ -0,0 +1,343 @@ +# Plan 3 – Lead-Automatik: neue Lead-Repos in WEBklar, Zugang über das System-Konto + +> **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:** Die Lead-Automatik legt neue Lead-Previews in der Organisation WEBklar an und arbeitet mit dem Schlüssel `lead-automatik` des Kontos `webklar-system`. Bestehende Lead-Repos bei knso werden weiter unter ihrem bisherigen Pfad bedient. + +**Aufbau:** + +- **Neues Modul `repo_paths.py`:** reine Funktionen, die entscheiden: + - wem ein Lead-Repo gehört, + - wie Pfad, Web-Adresse und Git-Adresse lauten, + - über welche Gitea-Schnittstelle ein Repo angelegt wird, + - ob es einen Einzel-Webhook braucht. + + Es wird mit pytest geprüft. +- **Bestehende Dateien:** `worker.py`, `processor.py`, `enrich.py`, `backfill_leads.py` und `migrate_previews.py` nutzen diese Funktionen statt `GITEA_USER/`. +- **Unveränderter Start:** Ohne neue Einstellungen verhält sich alles wie heute. Der Code geht deshalb zuerst live, die Umstellung folgt danach über `config.env`. + +**Technik:** + +- Python 3 (System), pytest 6.2.5 +- systemd-Dienste `webklar-automation-hook` und `webklar-automation-processor` +- Cron-Lauf `worker.py` um 06:30 Uhr (Europe/Berlin) + +**Spec:** `docs/superpowers/specs/2026-09-27-gitea-organisation-design.md` im Kundenportal-Repo. Maßgeblich sind die Abschnitte 8, 9, 11 und 12. + +**Voraussetzung:** Plan 1 ist live (dort L5). Erst dann erzeugen Pushes des System-Kontos keine Arbeitsblätter mehr. Mit dem alten Portal-Code bekämen Lead-Tickets sonst Arbeitsblätter auf „webklar-system“. + +## Global Constraints + +**Werte, die wörtlich gelten** + +- **Neue Schlüssel in `config.env`:** + - `GITEA_AUTH_USER=webklar-system`. Das ist der Benutzer in Git-Adressen. + - `GITEA_NEW_REPO_OWNER=WEBklar` +- **Unveränderte Schlüssel:** `GITEA_USER=knso` bleibt, als Besitzer der bestehenden Lead-Repos. `TEMPLATE_OWNER` und `TEMPLATE_REPO` bleiben ebenfalls. +- **`GITEA_TOKEN`** wird der Schlüssel `lead-automatik` des Kontos `webklar-system`, mit den Rechten `write:repository,write:organization,read:user`. +- **Voreinstellungen:** + - Fehlt `GITEA_AUTH_USER`, gilt `GITEA_USER`. + - Fehlt `GITEA_NEW_REPO_OWNER`, gilt `GITEA_USER`. + - Ohne die neuen Schlüssel ist das Verhalten also genau wie heute. +- **Besitzer eines Lead-Repos** (`lead_repo_owner`), in dieser Reihenfolge: + 1. `lead["repo_owner"]`, falls gesetzt, + 2. sonst der Besitzer aus `lead["repo_url"]` (`https://host//`), + 3. sonst `GITEA_NEW_REPO_OWNER`. + + Das präzisiert Spec 8 („bestehende Leads gelten als knso“): Alle bestehenden Leads mit Repo tragen `repo_url` auf knso, und nur Leads ohne Repo kommen neu nach WEBklar. +- **Einzel-Webhook:** Er wird nur dann **nicht** angelegt, wenn der Besitzer `GITEA_NEW_REPO_OWNER` ist und dieser von `GITEA_USER` abweicht. Das ist der Fall der Organisation, in dem ihr eigener Webhook greift. + +**Sicherheit und Betrieb** + +- Schlüssel stehen nie in Logs, Tests oder Ausgaben. +- `config.env` wird nie committet. +- Der Verarbeitungsdienst arbeitet alle 5 Minuten. Umgestellt wird darum nur, während er gestoppt ist (siehe Livegang). +- Nach jeder Code-Änderung werden beide Dienste neu gestartet, weil sie die Module beim Start laden. + +**Git** + +- `/opt/webklar-automation` ist bisher kein Git-Repo. Task 1 legt ein **lokales** Repo ohne Remote an, nur für Nachvollziehbarkeit und Review. `.gitignore` schützt Geheimnisse und Daten. +- Immer `git add `, nie `-A`. + +## Review Focus + +1. **Alter Lead mit `repo_url` auf knso, ohne `repo_owner`.** Weitere Pushes, etwa aus der Anreicherung, gehen weiter nach `knso/`. Test: Task 2, „alter Lead bleibt bei knso“. +2. **Ein neuer Lead wird erneut verarbeitet, und sein Repo existiert schon in WEBklar (409).** Das Repo wird weiterverwendet, ohne Einzel-Webhook. Tests: Task 2 (`needs_repo_webhook`) und Task 3 (Prüfschritt). +3. **Git-Adresse.** Benutzer ist `GITEA_AUTH_USER`, der Pfad kommt aber vom Besitzer des Leads, nicht vom Benutzer. Test: Task 2, `git_url`. +4. **Ohne die neuen Einstellungen** bleiben Pfade, Anlage über `/user/repos` und Einzel-Webhook genau wie heute. Test: Task 2, „ohne neue Einstellungen wie heute“. +5. **Die Vorlage liegt bei knso** (`TEMPLATE_OWNER`) und wird mit dem System-Konto geklont. Geprüft wird das im Livegang, Probelauf L3. + +--- + +## Dateien + +| Datei | Aufgabe | +|---|---| +| `.gitignore` (neu) | schließt `config.env*`, Daten, Logs und Sicherungen aus | +| `repo_paths.py` (neu) | `lead_repo_owner`, `repo_full`, `web_url`, `git_url`, `repo_create_path`, `needs_repo_webhook` | +| `tests/test_repo_paths.py` (neu) | Tests | +| `worker.py` | Einstellungen, `ensure_repo(repo_name, firma, owner)`, Git- und Web-Adressen, Lookups, `_mark_processed(lead_id, repo_url, repo_owner)` | +| `processor.py`, `enrich.py` | Adressen mit Besitzer | +| `backfill_leads.py`, `migrate_previews.py` | Pfade über `W.lead_repo_full(lead)` | + +--- + +### Task 1: Lokales Git-Repo und Ausgangsstand + +- [ ] **Step 1: Write `.gitignore`** + +``` +config.env* +*.bak* +logs/ +queue/ +status/ +tmp/ +backups/ +master*.json +master*.xlsx +standorte.* +__pycache__/ +.pytest_cache/ +``` + +- [ ] **Step 2: Create the repo and commit the baseline** + +```bash +cd /opt/webklar-automation && git init -q +git add .gitignore *.py DOKUMENTATION.md ROUTINE_ANREICHERUNG_PROMPT.txt fallback-assets +git -c user.name=Claude -c user.email=claude@webklar.local commit -qm "Ausgangsstand Lead-Automatik" +git ls-files | grep -c "config.env\|master\|\.bak" +``` + +Expected: `0`. `git status --short` shows no `.py` file as untracked. + +--- + +### Task 2: Pfad-Regeln als reine Funktionen + +**Files:** +- Create: `repo_paths.py`, `tests/test_repo_paths.py` + +**Interfaces:** +- Produces: + - `lead_repo_owner(lead: dict, legacy_owner: str, new_owner: str) -> str` + - `repo_full(owner: str, repo_name: str) -> str` + - `web_url(base_url: str, owner: str, repo_name: str) -> str` + - `git_url(base_url: str, auth_user: str, token: str, owner: str, repo_name: str) -> str` + - `repo_create_path(owner: str, auth_user: str, legacy_owner: str) -> str | None`. The cases, checked in this order and ignoring case: + 1. `owner == auth_user` → `'/user/repos'`, + 2. `owner == legacy_owner` → `None`, meaning the repo must already exist, + 3. otherwise → `'/orgs//repos'`. + - `needs_repo_webhook(owner: str, new_owner: str, legacy_owner: str) -> bool` + +- [ ] **Step 1: Write the failing tests** + +```python +B = "https://git.webklar.com" +def test_alter_lead_bleibt_bei_knso(): + lead = {"repo_name": "preview-x", "repo_url": B + "/knso/preview-x"} + assert lead_repo_owner(lead, "knso", "WEBklar") == "knso" +def test_repo_owner_hat_vorrang(): + assert lead_repo_owner({"repo_owner": "WEBklar", "repo_url": B + "/knso/x"}, "knso", "WEBklar") == "WEBklar" +def test_neuer_lead_kommt_nach_webklar(): + assert lead_repo_owner({"repo_name": "preview-neu", "repo_url": ""}, "knso", "WEBklar") == "WEBklar" +def test_ohne_neue_einstellungen_wie_heute(): + assert lead_repo_owner({"repo_name": "preview-neu"}, "knso", "knso") == "knso" + assert repo_create_path("knso", "knso", "knso") == "/user/repos" + assert needs_repo_webhook("knso", "knso", "knso") is True +def test_adressen(): + assert repo_full("WEBklar", "preview-x") == "WEBklar/preview-x" + assert web_url(B, "WEBklar", "preview-x") == B + "/WEBklar/preview-x" + assert git_url(B, "webklar-system", "TOKEN", "knso", "preview-x") == "https://webklar-system:TOKEN@git.webklar.com/knso/preview-x.git" +def test_anlegen(): + assert repo_create_path("WEBklar", "webklar-system", "knso") == "/orgs/WEBklar/repos" + assert repo_create_path("knso", "webklar-system", "knso") is None + assert repo_create_path("Webklar-System", "webklar-system", "knso") == "/user/repos" +def test_einzel_webhook(): + assert needs_repo_webhook("WEBklar", "WEBklar", "knso") is False + assert needs_repo_webhook("webklar", "WEBklar", "knso") is False + assert needs_repo_webhook("knso", "WEBklar", "knso") is True +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd /opt/webklar-automation && python3 -m pytest -q tests` +Expected: FAIL (`ModuleNotFoundError: repo_paths`) + +- [ ] **Step 3: Implement `repo_paths.py`** + +- It uses only the standard library and loads no configuration. +- `git_url` builds the host from `base_url` without the scheme. +- The owner is extracted from `repo_url` with `^https?://[^/]+/([^/]+)/[^/]+/?$`. + +- [ ] **Step 4: Run to verify it passes** + +Run: `python3 -m pytest -q tests` +Expected: `7 passed` + +- [ ] **Step 5: Commit** + +```bash +git add repo_paths.py tests/test_repo_paths.py && git -c user.name=Claude -c user.email=claude@webklar.local commit -qm "Pfad-Regeln für Lead-Repos mit Tests" +``` + +--- + +### Task 3: worker.py umstellen + +**Files:** +- Modify: `worker.py` + +**Interfaces:** +- Consumes: `repo_paths` (Task 2) +- Produces, for the other files: + - `W.GITEA_AUTH_USER` + - `W.NEW_REPO_OWNER` + - `W.lead_repo_owner(lead) -> str` + - `W.lead_repo_full(lead) -> str` + - `W.lead_git_url(owner, repo_name) -> str` + - `W.lead_web_url(owner, repo_name) -> str` + - `W.ensure_repo(repo_name, firma, owner)` + - `W._mark_processed(lead_id, repo_url, repo_owner="")` + +- [ ] **Step 1: Implement** + +1. **Settings:** `GITEA_AUTH_USER = cfg.get("GITEA_AUTH_USER") or GITEA_USER` and `NEW_REPO_OWNER = cfg.get("GITEA_NEW_REPO_OWNER") or GITEA_USER`. Add the four `lead_*` wrappers, which fill the `repo_paths` functions with these values. +2. **`ensure_repo(repo_name, firma, owner)`:** + - `path = repo_create_path(owner, GITEA_AUTH_USER, GITEA_USER)`. + - If `path` is `None`, check `GET /repos//`. On 200, reuse the repo; otherwise raise `RuntimeError("Repo %s/%s fehlt und kann dort nicht angelegt werden" % (owner, repo_name))`. + - Otherwise POST to `path`. As today, 409 means reuse. + - Only call `ensure_repo_webhook(repo_full(owner, repo_name))` if `needs_repo_webhook(owner, NEW_REPO_OWNER, GITEA_USER)`. +3. **Git and web addresses:** + - The push URL in the fill step (today line 531) becomes `lead_git_url(owner, repo_name)`, so that function gets `owner` as a parameter. + - `get_template_cache` uses `GITEA_AUTH_USER` as the user; owner and repo stay `TEMPLATE_OWNER`/`TEMPLATE_REPO`. + - Every `"%s/%s/%s" % (GITEA_URL, GITEA_USER, repo_name)` becomes `lead_web_url(owner, repo_name)`. +4. **Lookups:** In the three functions at the former lines 331, 373 and 415, use `lead_repo_full(lead)`. +5. **`process_lead`:** + - `owner = lead_repo_owner(lead)`, set `lead["repo_owner"] = owner`, then `ensure_repo(repo_name, firma, owner)`. + - `_mark_processed(lead_id, repo_url, owner)` also stores `repo_owner` in `master.json`. + - The caller around the former line 621 passes the owner along. + +- [ ] **Step 2: Check** + +```bash +python3 -m py_compile worker.py && python3 -m pytest -q tests +grep -n "GITEA_USER, repo_name\|GITEA_USER, (lead" worker.py +``` + +Expected: compiles, `7 passed`, and grep finds nothing. + +- [ ] **Step 3: Commit** + +```bash +git add worker.py && git -c user.name=Claude -c user.email=claude@webklar.local commit -qm "worker: Repo-Besitzer pro Lead, System-Konto für Git-Adressen" +``` + +--- + +### Task 4: processor.py, enrich.py und Wartungsskripte umstellen + +**Files:** +- Modify: `processor.py`, `enrich.py`, `backfill_leads.py`, `migrate_previews.py` + +**Interfaces:** +- Consumes: the `W.*` functions from Task 3 + +- [ ] **Step 1: Implement** + +- **`processor.py`:** + - `_repo_url(owner, repo_name)` returns `worker.lead_git_url(owner, repo_name)`. + - `_clone_repo(owner, repo_name)`. + - In `process_lead`: `owner = worker.lead_repo_owner(lead)`, then `worker.ensure_repo(repo_name, firma, owner)`, `worker._mark_processed(lead_id, repo_url, owner)`, and `repo_url = worker.lead_web_url(owner, repo_name)`. +- **`enrich.py`:** + - `_repo_push_url(owner, repo_name)` and `_clone_repo(owner, repo_name)`. + - `_apply_to_repo(owner, repo_name, p, ctx=None)`. The caller determines `owner = worker.lead_repo_owner(lead)` inside the `master_lock` block, together with `repo_name`. + - In `get_file`, the API path uses `worker.lead_repo_owner(lead)` instead of `worker.GITEA_USER`. + - `repo_url` (the former line 1049) becomes `worker.lead_web_url(owner, repo_name)`. +- **`backfill_leads.py` and `migrate_previews.py`:** `W.lead_repo_full(lead)` instead of `"%s/%s" % (W.GITEA_USER, repo_name)`. + +- [ ] **Step 2: Check** + +```bash +python3 -m py_compile processor.py enrich.py backfill_leads.py migrate_previews.py hook_server.py && python3 -m pytest -q tests +grep -n "GITEA_USER" processor.py enrich.py backfill_leads.py migrate_previews.py +``` + +Expected: compiles, `7 passed`. `GITEA_USER` appears in these four files nowhere, or only in comments. + +- [ ] **Step 3: Restart with unchanged `config.env` (the code alone changes nothing)** + +With the user's OK: + +```bash +systemctl restart webklar-automation-hook webklar-automation-processor +systemctl is-active webklar-automation-hook webklar-automation-processor +``` + +Expected: `active` twice, and the log files in `logs/` show no traceback in the next 10 minutes. + +- [ ] **Step 4: Commit** + +```bash +git add processor.py enrich.py backfill_leads.py migrate_previews.py && git -c user.name=Claude -c user.email=claude@webklar.local commit -qm "processor/enrich: Adressen mit Repo-Besitzer des Leads" +``` + +- [ ] **Step 5: Review** + +Run a fresh code review of `git diff ..HEAD` against Spec 8 and this plan (`superpowers:requesting-code-review`). + +--- + +## Livegang – nur mit OK des Nutzers, erst nach Plan 1 L5 + +**L0 Sicherung** + +```bash +cp /opt/webklar-automation/config.env /opt/webklar-automation/config.env.bak-gitea-org +``` + +**L1 Schlüssel und Einstellungen.** The processor is stopped, so no lead is in the middle of processing: + +```bash +systemctl stop webklar-automation-processor +docker exec -u git webklar-gitea gitea admin user generate-access-token --username webklar-system --token-name lead-automatik --scopes write:repository,write:organization,read:user --raw \ + | python3 -c 'import re,sys; t=sys.stdin.read().strip(); p="/opt/webklar-automation/config.env"; s=open(p).read(); s=re.sub(r"^GITEA_TOKEN=.*$", "GITEA_TOKEN="+t, s, flags=re.M); open(p,"w").write(s.rstrip("\n")+"\nGITEA_AUTH_USER=webklar-system\nGITEA_NEW_REPO_OWNER=WEBklar\n")' +grep -c "^GITEA_AUTH_USER=webklar-system$\|^GITEA_NEW_REPO_OWNER=WEBklar$" /opt/webklar-automation/config.env +systemctl start webklar-automation-processor && systemctl restart webklar-automation-hook +``` + +Expected: `2`, and both services `active`. The token only passes through the pipe. + +**L2 Zugang prüfen:** + +```bash +cd /opt/webklar-automation && python3 -c "import worker as W; c,u=W.gitea_api('GET','/user'); print(c,u.get('login'),u.get('is_admin'))" +``` + +Expected: `200 webklar-system True`. + +**L3 Probelauf mit Wegwerf-Repo.** Only with explicit OK: + +1. With the worker functions, create `WEBklar/preview-probe-gitea-org` (`W.ensure_repo('preview-probe-gitea-org', 'Probe', W.NEW_REPO_OWNER)`), then `W.set_repo_file('WEBklar/preview-probe-gitea-org', 'index.html', '

Probe

', 'Probe')`. +2. Clone the template once with `W.get_template_cache()`. This checks that the system account can read `knso/`. +3. Expected: + - the repo exists in WEBklar, + - `GET /repos/WEBklar/preview-probe-gitea-org/hooks` returns `[]`, + - within a minute the portal has a `websiteProjects` entry `WEBklar/preview-probe-gitea-org` with status `synced`, created by the organisation webhook, + - no new arbeitsblatt. +4. Clean up: + - `DELETE /repos/WEBklar/preview-probe-gitea-org`, + - delete the `websiteProjects` entry with a script in the portal container, + - check that both are gone. + +**L4 Beobachten.** Check the next real lead: its repo is created in WEBklar, the preview deploys, and there is no per-repo webhook. Update the memory note. + +**Rückweg:** + +```bash +cp /opt/webklar-automation/config.env.bak-gitea-org /opt/webklar-automation/config.env +systemctl restart webklar-automation-hook webklar-automation-processor +``` + +The code can stay: without the new keys it behaves as it does today. Remove the `lead-automatik` token in Gitea under Settings → Applications of the account, as an admin.