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>
58 KiB
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 bisherigegiteaFetch, ausgiteaAdmin.jsherausgelöst.
- Fachlogik:
pushHistory.jsliest die Push-Historie aus den Gitea-Aktivitäten,repoMove.jserledigt den Umzug und erkennt interne Repos,projectAssignment.jsbü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. KonfigurationGITEA_ORG, VoreinstellungWEBklar. - System-Konto:
webklar-system. KonfigurationGITEA_SYSTEM_USER, Voreinstellungwebklar-system. E-Mailwebklar-system@noreply.webklar.com. - Schlüssel des Portals: Name
portal, Rechtewrite:repository,write:organization,read:user. - Portal-Webhook-URL:
${config.publicBaseUrl}/webhook/gitea, in Produktionhttps://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 vonGITEA_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_MESSAGESsteht, alsoUpdate preview config from ticket systemoderAutomatisch 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, Uhrzeithhmm, 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_URLundAPPWRITE_ENDPOINTzeigen in Tests auf Nachbauten unter127.0.0.1.PREVIEW_DEPLOY_ROOTundTRAEFIK_DYNAMIC_DIRsind leer. npm testmuss 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-orgauf dem Branchfeature/gitea-organisation, ohne Upstream. - Niemals
git add -A, denn dernode_modules-Symlink ist untracked. - Ein Push nach
maingeht 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
- 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“.
- Ein Push mit mehreren Commits. Beim Nachtragen muss
commitShader HeadCommit sein, also derselbe Wert wieafterim Webhook. Sonst entsteht bei einer späteren Zustellung desselben Pushes ein zweites Arbeitsblatt. Test: Task 4, „HeadCommit bestimmt commitSha“. - Ein Doppelklick auf „Zuweisen“. Das ergibt genau eine Übertragung und keine doppelten Arbeitsblätter. Test: Task 6, „Doppelklick: ein Umzug, keine doppelten Arbeitsblätter“.
- 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“.
- 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): stringberlinDateTime(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:- Lowercase the input.
- Map ä→ae, ö→oe, ü→ue, ß→ss.
- Remove accents with
normalize('NFD')followed by removing\p{M}. - Remove
',’,‘and`. - Turn
[^a-z0-9-]+into-, strip leading and trailing-, thenslice(0, 100).
-
berlinDateTime: UseIntl.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 assembledd.mm.yyyyandhhmmfrom the parts. -
withLocks: Keep one promise chain per key in a module-levelMap.- Wait on the current tails of all keys with
Promise.race([Promise.allSettled(tails), sleep(waitMs)]). - Run
fn. - Put a new tail for every key that resolves in
finally. - Delete a key only when its tail is still the stored one.
- Wait on the current tails of all keys with
-
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 localgiteaHeaders/giteaFetch/slugify; import fromgiteaClient.jsandslug.js),server/config.js(giteablock) - Test:
test/giteaClient.test.js
Interfaces:
-
Consumes:
slugify(Task 1) -
Produces:
giteaFetch(path: string, options?: RequestInit): Promise<any>. Same behaviour as today: it addsAuthorization: token …, throws anErrorwith.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: numberstartFakeGitea(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 firstcontents: { [fullName + ':' + path]: string }orgs: string[]user = 'webklar-system'
- Routes, all under
/api/v1:GET /userGET /repos/search({ ok, data }, withlimit/page)GETandPATCH /repos/:o/:r. For a moved repo it answers307to the new path, like Gitea.POST /repos/:o/:r/transfer. On success202with 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/:idGET /repos/:o/:r/activities/feeds?limit&pageGET/POST/PUT /repos/:o/:r/contents/:pathGET /orgs/:org,POST /orgsGET /orgs/:org/teams, which returns Owners with id 1 for created orgsGET /teams/:id/members,PUT /teams/:id/members/:userGET/POST /orgs/:org/hooks
- Names are case-insensitive.
setDown(true)destroys every connection.
- Options:
startFakeAppwrite({ seed })→{ endpoint, all(col), get(col, id), calls, close() }.endpointends in/v1.- Documents under
/databases/:db/collections/:col/documents: list withqueries[](equal,limit,orderAsc,orderDesc,greaterThan), create from{ documentId, data }, get, patch from{ data }, delete. GET /account:X-Appwrite-JWT: jwt-adminreturns{ $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
giteaHeadersandgiteaFetchintogiteaClient.jsunchanged, plussplitRepo.giteaAdmin.jsimports them from there. -
Replace the local
slugifyingiteaAdmin.jswith the one fromslug.js. -
Build the fakes as specified under Interfaces.
-
In
config.js, add insidegitea:org: process.env.GITEA_ORG || 'WEBklar'systemUser: process.env.GITEA_SYSTEM_USER || 'webklar-system'keepInPlace: parsed fromGITEA_KEEP_IN_PLACE(comma-separated, trimmed), defaulting to the list in Global ConstraintsbackfillDays: 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 ispusher.username || pusher.login, compared withconfig.gitea.systemUsercase-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
formatTodayandformatTimeNowwithberlinDateTime(pushedAt). It setsstartDate,endDateandendTime;startTimestays''. -
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
Pushhas 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[]>.
- A
-
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:
- Keep only entries with
op_type === 'commit_repo'andnew Date(created) >= sincewhose parsedcontenthas a non-emptyCommitsarray. IfJSON.parsefails, skip the entry. branchisref_namewithoutrefs/heads/.commitShaisHeadCommit?.Sha1 || Commits[0].Sha1.- The commits keep the order of the feed.
- The pusher is
{ login: act_user.login, username: act_user.login, full_name: act_user.full_name || '' }. - Sort the result by
pushedAtin ascending order.
listRecentPushes:
- Fetch
GET /repos/{o}/{r}/activities/feeds?limit=50&page=Nstarting withN=1. - Stop at an empty page, at a page with fewer than 50 entries, or when the oldest
createdof the page is older thansince = now − days. - 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. ExportpreviewWorkDir(repoFullName), which returnspath.join(process.cwd(), 'preview-data', repoFullName.replace(/\//g, '_')), and use it inrunDeploy. - 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): booleanrepoLockKey(repoFullName): string, which returns'repo:' + repoFullName.toLowerCase().isInternalRepo(repoFullName): Promise<boolean>. It is true if the repo is inkeepInPlace(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' }]andknso/hytail-ark: [{ id: 7, url: 'https://webklar.com/webhook/hytail-ark/deploy' }].
- repos
- Fake Appwrite: one
websiteProjectsdocument per repo, withrepoFullNameas it is stored today. For von-hand that is stillknso/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
- Let
from = project.repoFullName. IfisOrgRepo(from), return{ moved: null, warnings: [] }. GET /repos/{from}.fetchfollows Gitea's 307 redirect. If the answer hasfull_name !== from, update the document (step 6 without the transfer) and returnmoved: { from, to: full_name }.- If
isInternalRepo(from), return theinternalwarning. - Look at
GET /repos/{org}/{name}. On 200, returnnameTaken(name); on 404, continue. - Inside
withLocks([repoLockKey(from), repoLockKey(to)]),POST /repos/{from}/transferwith{ new_owner: config.gitea.org }.tois thefull_namefrom the response. - Call
updateDocument(websiteProjects, project.$id, { repoFullName: to, giteaRepoUrl:${config.gitea.baseUrl}/${to}, giteaRepoName: name, updatedAt }). If this fails, returnmovedwithdocNotUpdated(err.message). - Call
invalidateProjectCache(from)andinvalidateProjectCache(to). - For every hook of
towhose URL starts with the portal webhook URL, sendDELETE /repos/{to}/hooks/{id}. Errors only get logged. 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):
- If a
websiteProjectsdocument withrepoFullName === nameexists, returnname. - Otherwise return
(await giteaFetch(/repos/${name})).full_name. - 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 underwithLocks(['project:' + projectId]).assignProject(projectId, { customerId = '', ticketId = '' }, { now } = {}): Promise<{ ok: true, project, moved, backfilled, warnings }>. It throws anErrorwith.status = 404for an unknown project.SYSTEM_COMMIT_MESSAGES: string[](see Global Constraints).
-
Step 1: Write the failing tests
Setup:
- Fake Appwrite:
-
websiteProjects:p1→JUSN/D-ner-Nationp2→JUSN/leerp3→JUSN/nur-kundep4→JUSN/doppelt
All four have
customerId: ''andticketId: ''. -
workorders:t1withwoid: '100115'. -
customers:c1with nameDöner Nation.
-
- Fake Gitea feeds:
JUSN/D-ner-Nation, newest first:- a
webklar-systempush at2026-09-27T14:50:00Zwith commits1, - a knso push at
14:48:00Zwith commitk1and the messageUpdate preview config from ticket system, an old system commit that must not be backfilled, - a JUSN push at
14:46:34Zwith commit26bc22e…, - an empty-content push at
14:46:33Z, create_repo.
- a
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):
- Load the pushes with
listRecentPushes(project.repoFullName, { now }). Drop the ones whereisSystemPusheris true and the ones where the first line of every commit message is inSYSTEM_COMMIT_MESSAGES. If Gitea fails, returngiteaDown(err.message). - If no pushes remain, return
{ backfilled: 0, warnings: [] }. ticketId = project.ticketId || await ensureProjectTicket(project, { repoFullName: project.repoFullName }). Without a ticket, return 0.- For each push, oldest first:
createGitPushWorksheet({ ticketId, repoFullName: project.repoFullName, ...push }). Count the results withcreated.
reconcileProject runs under the project lock:
getDocument, thenmoveRepoToOrg.- If something moved, load the document again.
- Run
backfillPushes. - Merge the warnings.
assignProject:
getDocument(404 → anErrorwithstatus 404).updateDocument({ customerId, ticketId, updatedAt }).reconcileProject.- 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 underwithLocks(['import']).syncReposToAppwrite()keeps its signature, but no longer setssubdomainorpreviewUrl.ensureRepoPreviewConfig(repoFullName, branch)returnsnullwhen!isHostedProject(doc).
-
Step 1: Write the failing tests
Setup:
- Fake Gitea repos:
knso/alt, which has a document,JUSN/neuwith descriptionDöner Nation,JUSN/archivwitharchived: true,knso/privatwithprivate: true, andknso/synced-sub,knso/lead. - Fake Appwrite:
knso/altwith subdomainalt,knso/synced-subwithstatus: 'synced',subdomain: 'synced-sub',knso/leadwithstatus: '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:- Load all repos with
listAllAccessibleRepos()and all documents withlistDocuments(websiteProjects, [Query.limit(5000)]). Compare case-insensitively onrepoFullName. - For every non-archived repo without a document, take
withLocks([repoLockKey(full_name)]), check again, thenupsertWebsiteProjectByRepo(full_name, { projectName: description || name, giteaRepoUrl: html_url, giteaRepoName: name, repoFullName: full_name, archived: false, status: 'synced', giteaSyncedAt: now }).
- Load all repos with
-
syncReposToAppwrite: Delete the call toresolveSubdomainForSyncand thesubdomain/previewUrllines, and delete the now unused function. -
ensureRepoPreviewConfig: Check withisHostedProject(doc)instead ofhasWebsite. KeepderiveProjectTypefor 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-reposreturns200 { ok: true, created }.POST /api/admin/website-projects/:id/assignwith 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/pushesreturns200 { 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()withexpress.json({ verify: (req, _res, buf) => { req.rawBody = buf } }), mounted under the same paths asserver/index.js. -
Environment:
GITEA_WEBHOOK_TOKEN=s3cretandPREVIEW_DEPLOY_ROOT=(empty). -
Signature:
X-Gitea-Signature = createHmac('sha256', 's3cret').update(body).digest('hex'). -
Data: the same as in Task 6 for
p1/t1, plusp5→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 || 500and{ error: err.message }. -
In the webhook handler, put the existing body into
withLocksand usecurrentRepoFullNamebeforefetchPreviewConfig. 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 readsKEY=VALUElines, 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 usesGITEA_SYSTEM_TOKENandGITEA_WEBHOOK_TOKENfrom the file,baseUrlhttps://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.backupDiris/app/upload-data/gitea-org. - Order inside the migration:
- First write
hooks-<ISO>.jsonwith the hooks of all repos (GET /repos/{r}/hooks: id, url, events). - Then, for each repo: find the document by
repoFullName; if it is missing, returnwarnings: ['kein Projekt-Eintrag']and continue. - Call
reconcileProject(doc.$id, { now }). - If
isHostedProjectis true andfetchPreviewConfig(to, defaultBranch)returns null, callensureRepoPreviewConfig(to, defaultBranch)and setpreviewConfigWritten: true.
- First write
- Setup script, exports:
-
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.jsonpresent, document withprojectType: 'website'. -
JUSN/D-ner-Nation: no config, document withprojectType: '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:
GET /orgs/{org}. On 404,POST /orgs.GET /orgs/{org}/teamsand pick Owners. Add every member that is not yet in it withPUT /teams/{id}/members/{user}. Check first withGET /teams/{id}/members.GET /orgs/{org}/hooks. If no hook hasconfig.url === hookUrl,POSTone.
- 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
webklarexists, - the Owners team contains
webklar-system,knso,jusnandandj, - there is one webhook with
owner_id= the organisation and a URL starting withhttps://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=WEBklarandGITEA_SYSTEM_USER=webklar-system
Then run cd /home/webklar/apps && docker compose up -d --no-deps kundenbereich-server.
Check:
-
The log shows
[startup] Preview-TLS-Sync: {"synced":true,…,"publicSource":"appwrite"}. -
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
mainin the meantime. In that case rebase (git rebase origin/main), runnpm testagain, 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:
build/is at the new commit, and the container restarted without errors.curl -s -o /dev/null -w '%{http_code}' -X POST https://project.webklar.com/api/admin/gitea/import-reposreturns401.- The Preview sync reports 104 hosts or more, with
publicSourceappwrite.
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.txtare unchanged when measured again. - The 6 project documents carry the new names.
- Ticket 100115 has an arbeitsblatt from
JUSNwith 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.
- Create
knso/umzug-probewith knso's old key (the value ofGITEA_WEBHOOK_TOKENin the container):POST /user/reposwith{ name: 'umzug-probe' }, then createindex.htmlthrough the contents API. That is a push by knso. - Call
importNewRepos(). There is then an entry forknso/umzug-probe. - 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. - 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/hooksreturns[]. - Clean up: delete the repo (
DELETE /repos/WEBklar/umzug-probe), thewebsiteProjectsentry, 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-organd rundocker compose up -d --no-deps kundenbereich-server. - L5:
git revertthe commits on a branch, then push after the OK. - L6: transfer every repo back to its old owner (
POST /repos/WEBklar/<r>/transferwithnew_owner), then recreate the hooks fromhooks-<ISO>.jsonwith the secretGITEA_WEBHOOK_TOKEN. Use a script in the container, so the secret is never printed.