Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-09-27-gitea-organisation-1-kundenportal.md
ClaudeandClaude Opus 5.5 a3faa1aff1 Umsetzungspläne: Kundenportal, Ticketsystem, Lead-Automatik
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>
2026-09-30 10:21:34 +00:00

58 KiB
Raw Permalink Blame History

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

// 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
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.

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
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:

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' }
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
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",…}}.

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
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.
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
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').

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
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'.
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
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).

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
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

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
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:

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

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:

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)

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:

    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.