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 <noreply@anthropic.com>
1118 lines
58 KiB
Markdown
1118 lines
58 KiB
Markdown
# 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<T>(keys: string[], fn: () => Promise<T>, { waitMs = Infinity } = {}): Promise<T>`
|
||
|
||
- [ ] **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<any>`. 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=<baseUrl>` 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<Push[]>`.
|
||
|
||
- [ ] **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<boolean>`. 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<string>`
|
||
|
||
- [ ] **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<string, string>`. 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<Array<{ from, to, backfilled, warnings, previewConfigWritten }>>`
|
||
- **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-<ISO>.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/<repo>.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(<entry>, { 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/<r>/transfer` with `new_owner`), then recreate the hooks from `hooks-<ISO>.json` with the secret `GITEA_WEBHOOK_TOKEN`. Use a script in the container, so the secret is never printed.
|