Compare commits
17
Commits
8c9d28c30f
...
d5c70a5bd6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5c70a5bd6 | ||
|
|
febac3c2a3 | ||
|
|
ed8c0f82d5 | ||
|
|
a5da4849e5 | ||
|
|
5c39640224 | ||
|
|
5ce8aa6fbb | ||
|
|
a960701d48 | ||
|
|
405b2a0485 | ||
|
|
6e6c9a4c24 | ||
|
|
51c4852488 | ||
|
|
e409c11d5c | ||
|
|
8d5de87ead | ||
|
|
531ee2a02b | ||
|
|
ea71b3f1c3 | ||
|
|
679f996597 | ||
|
|
a3faa1aff1 | ||
|
|
3edb5f8bb2 |
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,344 @@
|
||||
# Plan 2 – Ticketsystem: neue Repos sehen, Zuweisen mit Umzug, Preview und Pushes per Knopf
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Ziel:** Der Dialog „Projekte bearbeiten“ zeigt neue Gitea-Repos sofort. „Zuweisen“ läuft über das Portal, das das Repo verschiebt und Pushes nachträgt, und zeigt danach eine Zusammenfassung. Zugewiesene Projekte bekommen die Knöpfe „Preview einschalten“ und „Pushes nachtragen“. „Kunde zuordnen“ auf der Projekte-Seite nutzt denselben Weg.
|
||||
|
||||
**Aufbau:**
|
||||
|
||||
- **Reine Logik** in zwei neuen Dateien:
|
||||
- `src/lib/subdomain.js`: Subdomain-Vorschlag,
|
||||
- `src/lib/projectAssign.js`: Organisation, Texte der Zusammenfassung, Zuweisen nacheinander.
|
||||
Beide werden mit `node --test` geprüft, geladen über esbuild. Das Ticketsystem hat bisher keinen Testlauf.
|
||||
- **Portal-Aufrufe** kommen nach `src/lib/projectAdminApi.js`.
|
||||
- **Oberfläche:** Der Hook `useWebsiteProjects` und drei Komponenten nutzen das.
|
||||
- **Oberflächen-Prüfung:** Playwright gegen einen echten vite-Build mit nachgebautem Appwrite und Portal. Sie liegt im Scratchpad und wird nicht committet.
|
||||
|
||||
**Technik:**
|
||||
|
||||
- React 18, Vite 5
|
||||
- `appwrite` 13 (Web-SDK)
|
||||
- Node 24 auf dem Host für `node --test`
|
||||
- esbuild aus `node_modules`, genutzt über die JS-Schnittstelle, nicht über `bin/esbuild`
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-27-gitea-organisation-design.md` im Kundenportal-Repo. Maßgeblich sind die Abschnitte 5.7, 6 und 11.
|
||||
|
||||
**Voraussetzung:** Plan 1 ist live (dort Livegang L5), sonst fehlen die Schnittstellen. Der Dialog muss trotzdem bedienbar bleiben, wenn `import-repos` scheitert.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
**Werte und Texte, wörtlich**
|
||||
|
||||
- Organisation `WEBklar`, in `src/lib/projectAssign.js` als `GITEA_ORG` festgelegt.
|
||||
- Portal-Schnittstellen, jeweils mit `Authorization: Bearer <Appwrite-JWT>` über das vorhandene `adminFetch`:
|
||||
- `POST /api/admin/gitea/import-repos` → `{ ok, created }`
|
||||
- `POST /api/admin/website-projects/:id/assign` mit `{ customerId, ticketId }` → `{ ok, project, moved, backfilled, warnings }`
|
||||
- `POST /api/admin/website-projects/:id/pushes` → `{ ok, moved, backfilled, warnings }`
|
||||
- `PATCH /api/admin/website-projects/:id` mit `{ projectType: 'preview', subdomain }` → `{ ok, previewUrl, … }`
|
||||
- Texte:
|
||||
- `Suche neue Repos …`
|
||||
- `wird beim Zuweisen nach WEBklar verschoben`
|
||||
- `Preview einschalten`, `Einschalten`, `Pushes nachtragen`
|
||||
- `` `Preview läuft: ${previewUrl}` ``
|
||||
- `` `Neue Repos konnten nicht geladen werden (${message}).` ``
|
||||
- `` `${projectName}: Zuweisen fehlgeschlagen – ${error}` ``
|
||||
- Die Zusammenfassung (`assignSummary`) besteht aus zwei Teilen, getrennt durch ` · `:
|
||||
- „nach WEBklar verschoben“, wenn verschoben wurde,
|
||||
- danach „keine neuen Pushes“, „1 Push nachgetragen“ oder `` `${n} Pushes nachgetragen` ``.
|
||||
- Beispiel aus dem Spec: `Döner Nation: nach WEBklar verschoben · 1 Push nachgetragen`.
|
||||
- **Subdomain-Vorschlag:**
|
||||
1. Grundlage ist der Kundenname des Tickets, sonst der Projektname.
|
||||
2. Kleinschreibung.
|
||||
3. ä→ae, ö→oe, ü→ue, ß→ss.
|
||||
4. Akzente entfernen (NFD).
|
||||
5. Apostrophe `'` `’` `‘` `` ` `` entfernen.
|
||||
6. `[^a-z0-9]+` → `-`, Bindestriche am Rand entfernen.
|
||||
7. Höchstens 63 Zeichen, danach nochmals Bindestriche am Ende entfernen.
|
||||
- **„Preview einschalten“** erscheint, wenn `!project.previewUrl || project.status !== 'deployed'`, und nur, wenn die Karte `showActions` hat (also im Dialog).
|
||||
|
||||
**Git und Deploy – wichtig**
|
||||
|
||||
- Das Deploy-Webhook des Ticketsystems setzt **jeden** gepushten Branch live. Den Feature-Branch deshalb niemals pushen. Live geht es nur mit OK des Nutzers per `git push origin feature/gitea-organisation:test`.
|
||||
- `node_modules` ist im Repo **versioniert** (etwa 8900 Dateien), und im Worktree ist es ein Symlink. Deshalb nur `git add <Dateien>`, niemals `-A` oder `.`.
|
||||
- Eine andere Sitzung arbeitet parallel in `dev-entschlackung`, gerade an `App.jsx`, `InvoicePanel.jsx`, `useInvoices.js`, `integrationsApi.js` und `FinancePage.jsx`. Diese Dateien fasst Plan 2 nicht an. Vor dem Push `git rebase origin/test`.
|
||||
- Neue Texte werden in UTF-8 mit echten Umlauten geschrieben. Die berührten Dateien sind bisher reines ASCII und bleiben gültiges UTF-8.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **Das Portal ist noch nicht aktualisiert.** `import-repos` antwortet dann mit 404 oder 500. Der Dialog zeigt nur den Hinweis, und die Listen laden trotzdem. Test: Task 4, Prüfung 7.
|
||||
2. **Beim Zuweisen mehrerer Projekte scheitert eines.** Die anderen werden trotzdem zugewiesen, und die Zusammenfassung nennt den Fehler beim betroffenen Projekt. Test: Task 1, „nacheinander und weiter nach Fehler“.
|
||||
3. **Das Ticket hat keinen Kundennamen.** Der Vorschlag nimmt dann den Projektnamen. Sind beide leer, bleibt das Feld leer, und „Einschalten“ ist gesperrt, bis etwas eingetragen ist. Tests: Task 1 (`suggestSubdomain('', '')`) und Task 4, Prüfung 4.
|
||||
4. **Namen mit Zeichen wie „&“, „+“, „ß“ oder „é“.** Das ergibt eine gültige DNS-Subdomain ohne doppelte Bindestriche. Test: Task 1, „Sonderzeichen“.
|
||||
5. **Die Subdomain ist schon vergeben (409 vom Portal).** Die Meldung des Portals erscheint, und die Eingabe bleibt stehen. Test: Task 4, Prüfung 5.
|
||||
|
||||
---
|
||||
|
||||
## Dateien
|
||||
|
||||
| Datei | Aufgabe |
|
||||
|---|---|
|
||||
| `src/lib/subdomain.js` (neu) | `suggestSubdomain` |
|
||||
| `src/lib/projectAssign.js` (neu) | `GITEA_ORG`, `isOutsideOrg`, `needsPreviewButton`, `assignSummary`, `assignSequentially` |
|
||||
| `src/lib/projectAdminApi.js` | `importNewRepos`, `assignWebsiteProject`, `backfillProjectPushes` |
|
||||
| `src/hooks/useWebsiteProjects.js` | `assignProjects` über das Portal, nacheinander |
|
||||
| `src/components/TicketProjectsModal.jsx` | Import beim Öffnen, Hinweis, Zusammenfassung, Daten für die Karte |
|
||||
| `src/components/AssignedProjectCard.jsx` | Knöpfe „Preview einschalten“ und „Pushes nachtragen“ (nur mit `showActions`) |
|
||||
| `src/pages/ProjectsPage.jsx` | „Kunde zuordnen“ zeigt die Zusammenfassung |
|
||||
| `package.json` | Skript `"test": "node --test tests/unit/*.test.mjs"` (Muster statt Ordner, damit Node 20 und 24 gleich laufen) |
|
||||
| `tests/unit/load.mjs`, `tests/unit/*.test.mjs` (neu) | Laden der Quellen über esbuild, Tests |
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Worktree anlegen
|
||||
|
||||
- [ ] **Step 1: Create the worktree from the current `test`**
|
||||
|
||||
```bash
|
||||
cd /home/webklar/apps/ticket.webklar.com/build && git fetch -q origin
|
||||
git worktree add -b feature/gitea-organisation ../dev-gitea-org origin/test
|
||||
cd ../dev-gitea-org && git branch --unset-upstream; rm -rf node_modules && ln -s ../build/node_modules node_modules
|
||||
git log --oneline -1
|
||||
```
|
||||
|
||||
Expected: HEAD = `origin/test`. `git status --short | grep -v node_modules` shows nothing.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Reine Logik mit Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/subdomain.js`, `src/lib/projectAssign.js`, `tests/unit/load.mjs`, `tests/unit/subdomain.test.mjs`, `tests/unit/projectAssign.test.mjs`
|
||||
- Modify: `package.json` (only `scripts.test`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `suggestSubdomain(customerName?: string, projectName?: string): string`
|
||||
- `GITEA_ORG = 'WEBklar'`
|
||||
- `isOutsideOrg(repoFullName: string): boolean`. It is `false` for an empty name, and the comparison ignores case.
|
||||
- `needsPreviewButton(project): boolean`
|
||||
- `assignSummary(projectName: string, result: { moved, backfilled, warnings } | { error: string }): { text: string, warnings: string[] }`
|
||||
- `assignSequentially(projectIds: string[], ctx, assignFn: (id, ctx) => Promise<object>): Promise<Array<{ projectId, ok: boolean, result?, error? }>>`
|
||||
- `loadModule(relPathFromRepoRoot): Promise<module>` in `tests/unit/load.mjs`. It uses esbuild `build({ bundle: true, format: 'esm', write: false, platform: 'neutral' })` and `import()` from a `data:` URL.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```js
|
||||
// tests/unit/subdomain.test.mjs
|
||||
const { suggestSubdomain } = await loadModule('src/lib/subdomain.js')
|
||||
test('Kundenname vor Projektname', () => assert.equal(suggestSubdomain('Döner Nation', 'D-ner-Nation'), 'doener-nation'))
|
||||
test('Apostroph', () => assert.equal(suggestSubdomain("Emma's Schatzkiste", ''), 'emmas-schatzkiste'))
|
||||
test('Sonderzeichen', () => assert.equal(suggestSubdomain('', 'Café Müller & Söhne + Straße'), 'cafe-mueller-soehne-strasse'))
|
||||
test('leer', () => assert.equal(suggestSubdomain('', ''), ''))
|
||||
test('63 Zeichen ohne Bindestrich am Ende', () => assert.equal(suggestSubdomain('a'.repeat(62) + ' b', ''), 'a'.repeat(62)))
|
||||
|
||||
// tests/unit/projectAssign.test.mjs
|
||||
test('isOutsideOrg', () => {
|
||||
assert.equal(isOutsideOrg('JUSN/D-ner-Nation'), true)
|
||||
assert.equal(isOutsideOrg('webklar/x'), false)
|
||||
assert.equal(isOutsideOrg(''), false)
|
||||
})
|
||||
test('needsPreviewButton', () => {
|
||||
assert.equal(needsPreviewButton({ previewUrl: 'https://x', status: 'deployed' }), false)
|
||||
assert.equal(needsPreviewButton({ previewUrl: '', status: 'synced' }), true)
|
||||
assert.equal(needsPreviewButton({ previewUrl: 'https://x', status: 'pending' }), true)
|
||||
})
|
||||
test('assignSummary', () => {
|
||||
assert.deepEqual(assignSummary('Döner Nation', { moved: { from: 'JUSN/D-ner-Nation', to: 'WEBklar/D-ner-Nation' }, backfilled: 1, warnings: [] }),
|
||||
{ text: 'Döner Nation: nach WEBklar verschoben · 1 Push nachgetragen', warnings: [] })
|
||||
assert.deepEqual(assignSummary('X', { moved: null, backfilled: 0, warnings: ['Internes Repo, bleibt wo es ist.'] }),
|
||||
{ text: 'X: keine neuen Pushes', warnings: ['Internes Repo, bleibt wo es ist.'] })
|
||||
assert.equal(assignSummary('X', { moved: null, backfilled: 3, warnings: [] }).text, 'X: 3 Pushes nachgetragen')
|
||||
assert.deepEqual(assignSummary('X', { error: 'boom' }), { text: 'X: Zuweisen fehlgeschlagen – boom', warnings: [] })
|
||||
})
|
||||
test('nacheinander und weiter nach Fehler', async () => {
|
||||
const log = []
|
||||
const fn = async (id) => { log.push(id + ':start'); await sleep(10); log.push(id + ':end'); if (id === 'b') throw new Error('boom'); return { backfilled: 0 } }
|
||||
const r = await assignSequentially(['a', 'b', 'c'], { ticketId: 't1' }, fn)
|
||||
assert.deepEqual(log, ['a:start', 'a:end', 'b:start', 'b:end', 'c:start', 'c:end'])
|
||||
assert.deepEqual(r.map((x) => [x.projectId, x.ok, x.error]), [['a', true, undefined], ['b', false, 'boom'], ['c', true, undefined]])
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: FAIL, because the modules are missing. The script is already added.
|
||||
|
||||
- [ ] **Step 3: Implement both modules**
|
||||
|
||||
Follow the rules in Global Constraints. `assignSequentially` runs the calls strictly one after another. It catches every error as `{ ok: false, error: err.message }` and continues with the next ID.
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/subdomain.js src/lib/projectAssign.js tests/unit/load.mjs tests/unit/subdomain.test.mjs tests/unit/projectAssign.test.mjs package.json
|
||||
git commit -m "Subdomain-Vorschlag und Zuweisungs-Logik mit Tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Portal-Aufrufe und Hook
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/projectAdminApi.js`, `src/hooks/useWebsiteProjects.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `assignSequentially` (Task 1)
|
||||
- Produces:
|
||||
- `importNewRepos(): Promise<{ ok, created }>`
|
||||
- `assignWebsiteProject(id, { customerId, ticketId }): Promise<{ ok, project, moved, backfilled, warnings }>`
|
||||
- `backfillProjectPushes(id): Promise<{ ok, moved, backfilled, warnings }>`
|
||||
- `useWebsiteProjects().assignProjects(projectIds, { customerId, ticketId })` returns `{ success: boolean, error?: string, results }`.
|
||||
- `results` comes from `assignSequentially` with `assignWebsiteProject`.
|
||||
- `success` is true only if every result is `ok`.
|
||||
- `error` is the first error text.
|
||||
- `unassignProject` stays as it is, directly in Appwrite.
|
||||
|
||||
- [ ] **Step 1: Implement**
|
||||
|
||||
Use the existing `adminFetch` pattern, with method POST and body `JSON.stringify(...)`. `assignProject`, the single direct Appwrite update, is removed from the hook. It has no other callers: check with `git grep -n "assignProject\b" src`.
|
||||
|
||||
- [ ] **Step 2: Build check**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node -e "require('esbuild').build({entryPoints:['src/hooks/useWebsiteProjects.js'],bundle:true,write:false,format:'esm',platform:'browser',external:['react','appwrite'],define:{'import.meta.env.VITE_PROJECT_ADMIN_URL':'\"https://project.webklar.com\"'},loader:{'.js':'jsx','.css':'empty','.ttf':'empty','.woff2':'empty','.png':'empty','.svg':'empty'}}).then(()=>console.log('ok'))"
|
||||
```
|
||||
|
||||
Expected: `ok`. `npm test` stays green.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/projectAdminApi.js src/hooks/useWebsiteProjects.js
|
||||
git commit -m "Zuweisen über das Portal, Import neuer Repos, Pushes nachtragen"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Dialog, Projektkarte, Projekte-Seite
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/TicketProjectsModal.jsx`, `src/components/AssignedProjectCard.jsx`, `src/pages/ProjectsPage.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 1 and 2; `updateWebsiteProject` (already in `projectAdminApi.js`)
|
||||
- Produces:
|
||||
- `AssignedProjectCard` gets the new optional props `customerName` and `onChanged()`. Without `showActions` it looks exactly as before.
|
||||
|
||||
- [ ] **Step 1: Implement `TicketProjectsModal`**
|
||||
|
||||
1. **When the dialog opens:**
|
||||
- set `importing = true` and show `Suche neue Repos …`,
|
||||
- call `await importNewRepos()`; on an error, set `importHint` to `` `Neue Repos konnten nicht geladen werden (${err.message}).` `` and carry on,
|
||||
- then call `loadProjects()`.
|
||||
2. **List entries:** For `isOutsideOrg(p.repoFullName)`, add a grey note to the entry: `wird beim Zuweisen nach WEBklar verschoben`.
|
||||
3. **`handleAssign`:**
|
||||
- call `assignProjects(selectedIds, { customerId: ticket.customerId || '', ticketId: ticket.$id })`,
|
||||
- build a list of `assignSummary(projectName, result.ok ? result.result : { error: result.error })` from `results` and show it under the button: one line per project, warnings indented,
|
||||
- clear the selection, then `loadProjects()` and `onUpdated?.()`.
|
||||
4. **Cards:** Pass `customerName={ticket.customerName || ''}` and `onChanged={() => { loadProjects(); onUpdated?.() }}` to `AssignedProjectCard`.
|
||||
|
||||
- [ ] **Step 2: Implement `AssignedProjectCard`**
|
||||
|
||||
These controls appear only with `showActions`:
|
||||
|
||||
- **„Preview einschalten“** appears if `needsPreviewButton(project)`.
|
||||
- A click shows an input prefilled with `suggestSubdomain(customerName, project.projectName)` and the button `Einschalten`. It is disabled while the input is empty or a request is running.
|
||||
- `Einschalten` calls `updateWebsiteProject(project.$id, { projectType: 'preview', subdomain })`.
|
||||
- On success: the message `` `Preview läuft: ${r.previewUrl}` `` and `onChanged()`.
|
||||
- On an error: `err.message`, and the input stays.
|
||||
- **„Pushes nachtragen“** calls `backfillProjectPushes(project.$id)` and shows `assignSummary(project.projectName, r)`, then `onChanged()`.
|
||||
- Messages appear inside the card, below the status line. Buttons use `className="btn btn-sm"`, like the existing trash button.
|
||||
|
||||
- [ ] **Step 3: Implement `ProjectsPage`**
|
||||
|
||||
`handleAssignCustomer`:
|
||||
|
||||
- With a customer: the result of `assignProjects([project.$id], …)` shows as `setNotice(assignSummary(project.projectName, r.results[0].ok ? r.results[0].result : { error: r.results[0].error }).text)`. Warnings are appended with ` – `.
|
||||
- Without a customer: unchanged.
|
||||
|
||||
- [ ] **Step 4: Build check**
|
||||
|
||||
Run the same esbuild call as in Task 2 Step 2, once each for `src/components/TicketProjectsModal.jsx` and `src/pages/ProjectsPage.jsx`, with `external:['react','react-dom','react-icons/*','appwrite','react-router-dom','react-markdown','@tabler/icons-react']`.
|
||||
|
||||
Expected: `ok` both times. `npm test` stays green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/TicketProjectsModal.jsx src/components/AssignedProjectCard.jsx src/pages/ProjectsPage.jsx
|
||||
git commit -m "Dialog: neue Repos, Zuweisen mit Umzug, Preview einschalten, Pushes nachtragen"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Oberflächen-Prüfung (Scratchpad, nicht committen)
|
||||
|
||||
**Files:** Scratchpad `…/scratchpad/woms-ui/` (build script, `ui.mjs`)
|
||||
|
||||
**Setup:**
|
||||
|
||||
- **vite-Build** like the deploy:
|
||||
|
||||
```bash
|
||||
git archive HEAD | tar -x -C <tmp>
|
||||
docker run --rm -v <tmp>:/w -w /w node:20-alpine sh -c "rm -rf node_modules && npm install && npm run build"
|
||||
```
|
||||
|
||||
- **Browser:** `playwright-core` from `/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core` (load it via `createRequire`) and Chromium at `~/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome`. Run it under `systemd-run --scope -p MemoryMax=1200M -p MemorySwapMax=0`, and check `free -m` beforehand.
|
||||
- **Page:** served from `https://ticket.test` via `page.route`.
|
||||
- **Appwrite mock:**
|
||||
- a logged-in admin session and `POST /v1/account/jwts` → `{ jwt: 'jwt-admin' }`,
|
||||
- `listDocuments` for workorders: ticket 100115, customer name „Döner Nation“,
|
||||
- `websiteProjects`:
|
||||
- `JUSN/Ben-Breidt` and `WEBklar/sauber`, both available,
|
||||
- `WEBklar/D-ner-Nation`, assigned to ticket 100115, without `previewUrl`, status `synced`,
|
||||
- plus employees and customers.
|
||||
- **Portal mock** under `https://project.webklar.com/api/admin/**`. It records every request with its start and end time.
|
||||
- If the Entschlackung harness still exists (`appsmoke.mjs` in scratchpad `c07881f0…/wstimes`), reuse its Appwrite mock.
|
||||
|
||||
**Prüfungen** (all must pass):
|
||||
|
||||
1. Opening „Projekte zuweisen / bearbeiten“ on ticket 100115 sends exactly one `POST /api/admin/gitea/import-repos`. While the answer is pending (delayed by 500 ms), the dialog shows `Suche neue Repos …`.
|
||||
2. `JUSN/Ben-Breidt` carries `wird beim Zuweisen nach WEBklar verschoben`. `WEBklar/sauber` does not.
|
||||
3. Selecting both and clicking „Zuweisen“ sends two `…/assign` requests. The second starts only after the first has answered. Two summary lines appear. The first is `Ben Breidt: nach WEBklar verschoben · 1 Push nachgetragen`, with the mock answer to match.
|
||||
4. The card of `D-ner-Nation` has „Preview einschalten“.
|
||||
- The input shows `doener-nation`.
|
||||
- Changed to `doener-test` and sent with „Einschalten“, it produces `PATCH …/website-projects/<id>` with the body `{"projectType":"preview","subdomain":"doener-test"}`.
|
||||
- The card then shows `Preview läuft: https://doener-test.project.webklar.com`.
|
||||
- Once the field is cleared, „Einschalten“ is disabled.
|
||||
5. When the PATCH answers `409 { error: 'Subdomain "doener-nation" ist bereits vergeben' }`, exactly that text appears and the input stays.
|
||||
6. „Pushes nachtragen“ sends `POST …/pushes` and shows `Döner Nation: keine neuen Pushes`.
|
||||
7. When `import-repos` answers 404, the dialog shows `Neue Repos konnten nicht geladen werden (…)`, and both lists still appear.
|
||||
8. On the Projekte page, „Kunde zuordnen“ sends `…/assign` with `{"customerId":"<id>","ticketId":""}`, and the notice shows the summary.
|
||||
9. The ticket's Details tab (`TicketAssignedProjectsPanel`) and the customer page show the card without the new buttons.
|
||||
|
||||
Expected: the script prints `9/9 ok`.
|
||||
|
||||
---
|
||||
|
||||
## Livegang – nur mit OK des Nutzers, erst nach Plan 1 L5
|
||||
|
||||
1. Update and test:
|
||||
|
||||
```bash
|
||||
git -C /home/webklar/apps/ticket.webklar.com/dev-gitea-org fetch origin && git -C … rebase origin/test
|
||||
```
|
||||
|
||||
Then run `npm test`, and the Task 4 check once more against the rebased state.
|
||||
2. Deploy-like check: `git archive` + `docker run node:20-alpine` (`rm -rf node_modules; npm install; npm run build`) finishes without errors.
|
||||
3. Push: `git -C /home/webklar/apps/ticket.webklar.com/dev-gitea-org push origin feature/gitea-organisation:test`.
|
||||
4. Check:
|
||||
- the `out/` bundle and the live https://ticket.webklar.com contain the string `Suche neue Repos`,
|
||||
- the deploy log shows `npm run build` without errors,
|
||||
- opening the dialog in the live ticket 100115 lists `JUSN/Ben-Breidt` and similar repos. Doing this is only reading.
|
||||
5. Update the memory note `woms-ticketsystem`.
|
||||
|
||||
**Rückweg:** `git revert` the Plan 2 commits on a branch, then push to `test` after the OK.
|
||||
@@ -0,0 +1,343 @@
|
||||
# Plan 3 – Lead-Automatik: neue Lead-Repos in WEBklar, Zugang über das System-Konto
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Ziel:** Die Lead-Automatik legt neue Lead-Previews in der Organisation WEBklar an und arbeitet mit dem Schlüssel `lead-automatik` des Kontos `webklar-system`. Bestehende Lead-Repos bei knso werden weiter unter ihrem bisherigen Pfad bedient.
|
||||
|
||||
**Aufbau:**
|
||||
|
||||
- **Neues Modul `repo_paths.py`:** reine Funktionen, die entscheiden:
|
||||
- wem ein Lead-Repo gehört,
|
||||
- wie Pfad, Web-Adresse und Git-Adresse lauten,
|
||||
- über welche Gitea-Schnittstelle ein Repo angelegt wird,
|
||||
- ob es einen Einzel-Webhook braucht.
|
||||
|
||||
Es wird mit pytest geprüft.
|
||||
- **Bestehende Dateien:** `worker.py`, `processor.py`, `enrich.py`, `backfill_leads.py` und `migrate_previews.py` nutzen diese Funktionen statt `GITEA_USER/<repo_name>`.
|
||||
- **Unveränderter Start:** Ohne neue Einstellungen verhält sich alles wie heute. Der Code geht deshalb zuerst live, die Umstellung folgt danach über `config.env`.
|
||||
|
||||
**Technik:**
|
||||
|
||||
- Python 3 (System), pytest 6.2.5
|
||||
- systemd-Dienste `webklar-automation-hook` und `webklar-automation-processor`
|
||||
- Cron-Lauf `worker.py` um 06:30 Uhr (Europe/Berlin)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-27-gitea-organisation-design.md` im Kundenportal-Repo. Maßgeblich sind die Abschnitte 8, 9, 11 und 12.
|
||||
|
||||
**Voraussetzung:** Plan 1 ist live (dort L5). Erst dann erzeugen Pushes des System-Kontos keine Arbeitsblätter mehr. Mit dem alten Portal-Code bekämen Lead-Tickets sonst Arbeitsblätter auf „webklar-system“.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
**Werte, die wörtlich gelten**
|
||||
|
||||
- **Neue Schlüssel in `config.env`:**
|
||||
- `GITEA_AUTH_USER=webklar-system`. Das ist der Benutzer in Git-Adressen.
|
||||
- `GITEA_NEW_REPO_OWNER=WEBklar`
|
||||
- **Unveränderte Schlüssel:** `GITEA_USER=knso` bleibt, als Besitzer der bestehenden Lead-Repos. `TEMPLATE_OWNER` und `TEMPLATE_REPO` bleiben ebenfalls.
|
||||
- **`GITEA_TOKEN`** wird der Schlüssel `lead-automatik` des Kontos `webklar-system`, mit den Rechten `write:repository,write:organization,read:user`.
|
||||
- **Voreinstellungen:**
|
||||
- Fehlt `GITEA_AUTH_USER`, gilt `GITEA_USER`.
|
||||
- Fehlt `GITEA_NEW_REPO_OWNER`, gilt `GITEA_USER`.
|
||||
- Ohne die neuen Schlüssel ist das Verhalten also genau wie heute.
|
||||
- **Besitzer eines Lead-Repos** (`lead_repo_owner`), in dieser Reihenfolge:
|
||||
1. `lead["repo_owner"]`, falls gesetzt,
|
||||
2. sonst der Besitzer aus `lead["repo_url"]` (`https://host/<besitzer>/<repo>`),
|
||||
3. sonst `GITEA_NEW_REPO_OWNER`.
|
||||
|
||||
Das präzisiert Spec 8 („bestehende Leads gelten als knso“): Alle bestehenden Leads mit Repo tragen `repo_url` auf knso, und nur Leads ohne Repo kommen neu nach WEBklar.
|
||||
- **Einzel-Webhook:** Er wird nur dann **nicht** angelegt, wenn der Besitzer `GITEA_NEW_REPO_OWNER` ist und dieser von `GITEA_USER` abweicht. Das ist der Fall der Organisation, in dem ihr eigener Webhook greift.
|
||||
|
||||
**Sicherheit und Betrieb**
|
||||
|
||||
- Schlüssel stehen nie in Logs, Tests oder Ausgaben.
|
||||
- `config.env` wird nie committet.
|
||||
- Der Verarbeitungsdienst arbeitet alle 5 Minuten. Umgestellt wird darum nur, während er gestoppt ist (siehe Livegang).
|
||||
- Nach jeder Code-Änderung werden beide Dienste neu gestartet, weil sie die Module beim Start laden.
|
||||
|
||||
**Git**
|
||||
|
||||
- `/opt/webklar-automation` ist bisher kein Git-Repo. Task 1 legt ein **lokales** Repo ohne Remote an, nur für Nachvollziehbarkeit und Review. `.gitignore` schützt Geheimnisse und Daten.
|
||||
- Immer `git add <Dateien>`, nie `-A`.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **Alter Lead mit `repo_url` auf knso, ohne `repo_owner`.** Weitere Pushes, etwa aus der Anreicherung, gehen weiter nach `knso/<repo>`. Test: Task 2, „alter Lead bleibt bei knso“.
|
||||
2. **Ein neuer Lead wird erneut verarbeitet, und sein Repo existiert schon in WEBklar (409).** Das Repo wird weiterverwendet, ohne Einzel-Webhook. Tests: Task 2 (`needs_repo_webhook`) und Task 3 (Prüfschritt).
|
||||
3. **Git-Adresse.** Benutzer ist `GITEA_AUTH_USER`, der Pfad kommt aber vom Besitzer des Leads, nicht vom Benutzer. Test: Task 2, `git_url`.
|
||||
4. **Ohne die neuen Einstellungen** bleiben Pfade, Anlage über `/user/repos` und Einzel-Webhook genau wie heute. Test: Task 2, „ohne neue Einstellungen wie heute“.
|
||||
5. **Die Vorlage liegt bei knso** (`TEMPLATE_OWNER`) und wird mit dem System-Konto geklont. Geprüft wird das im Livegang, Probelauf L3.
|
||||
|
||||
---
|
||||
|
||||
## Dateien
|
||||
|
||||
| Datei | Aufgabe |
|
||||
|---|---|
|
||||
| `.gitignore` (neu) | schließt `config.env*`, Daten, Logs und Sicherungen aus |
|
||||
| `repo_paths.py` (neu) | `lead_repo_owner`, `repo_full`, `web_url`, `git_url`, `repo_create_path`, `needs_repo_webhook` |
|
||||
| `tests/test_repo_paths.py` (neu) | Tests |
|
||||
| `worker.py` | Einstellungen, `ensure_repo(repo_name, firma, owner)`, Git- und Web-Adressen, Lookups, `_mark_processed(lead_id, repo_url, repo_owner)` |
|
||||
| `processor.py`, `enrich.py` | Adressen mit Besitzer |
|
||||
| `backfill_leads.py`, `migrate_previews.py` | Pfade über `W.lead_repo_full(lead)` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lokales Git-Repo und Ausgangsstand
|
||||
|
||||
- [ ] **Step 1: Write `.gitignore`**
|
||||
|
||||
```
|
||||
config.env*
|
||||
*.bak*
|
||||
logs/
|
||||
queue/
|
||||
status/
|
||||
tmp/
|
||||
backups/
|
||||
master*.json
|
||||
master*.xlsx
|
||||
standorte.*
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the repo and commit the baseline**
|
||||
|
||||
```bash
|
||||
cd /opt/webklar-automation && git init -q
|
||||
git add .gitignore *.py DOKUMENTATION.md ROUTINE_ANREICHERUNG_PROMPT.txt fallback-assets
|
||||
git -c user.name=Claude -c user.email=claude@webklar.local commit -qm "Ausgangsstand Lead-Automatik"
|
||||
git ls-files | grep -c "config.env\|master\|\.bak"
|
||||
```
|
||||
|
||||
Expected: `0`. `git status --short` shows no `.py` file as untracked.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Pfad-Regeln als reine Funktionen
|
||||
|
||||
**Files:**
|
||||
- Create: `repo_paths.py`, `tests/test_repo_paths.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `lead_repo_owner(lead: dict, legacy_owner: str, new_owner: str) -> str`
|
||||
- `repo_full(owner: str, repo_name: str) -> str`
|
||||
- `web_url(base_url: str, owner: str, repo_name: str) -> str`
|
||||
- `git_url(base_url: str, auth_user: str, token: str, owner: str, repo_name: str) -> str`
|
||||
- `repo_create_path(owner: str, auth_user: str, legacy_owner: str) -> str | None`. The cases, checked in this order and ignoring case:
|
||||
1. `owner == auth_user` → `'/user/repos'`,
|
||||
2. `owner == legacy_owner` → `None`, meaning the repo must already exist,
|
||||
3. otherwise → `'/orgs/<owner>/repos'`.
|
||||
- `needs_repo_webhook(owner: str, new_owner: str, legacy_owner: str) -> bool`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
B = "https://git.webklar.com"
|
||||
def test_alter_lead_bleibt_bei_knso():
|
||||
lead = {"repo_name": "preview-x", "repo_url": B + "/knso/preview-x"}
|
||||
assert lead_repo_owner(lead, "knso", "WEBklar") == "knso"
|
||||
def test_repo_owner_hat_vorrang():
|
||||
assert lead_repo_owner({"repo_owner": "WEBklar", "repo_url": B + "/knso/x"}, "knso", "WEBklar") == "WEBklar"
|
||||
def test_neuer_lead_kommt_nach_webklar():
|
||||
assert lead_repo_owner({"repo_name": "preview-neu", "repo_url": ""}, "knso", "WEBklar") == "WEBklar"
|
||||
def test_ohne_neue_einstellungen_wie_heute():
|
||||
assert lead_repo_owner({"repo_name": "preview-neu"}, "knso", "knso") == "knso"
|
||||
assert repo_create_path("knso", "knso", "knso") == "/user/repos"
|
||||
assert needs_repo_webhook("knso", "knso", "knso") is True
|
||||
def test_adressen():
|
||||
assert repo_full("WEBklar", "preview-x") == "WEBklar/preview-x"
|
||||
assert web_url(B, "WEBklar", "preview-x") == B + "/WEBklar/preview-x"
|
||||
assert git_url(B, "webklar-system", "TOKEN", "knso", "preview-x") == "https://webklar-system:TOKEN@git.webklar.com/knso/preview-x.git"
|
||||
def test_anlegen():
|
||||
assert repo_create_path("WEBklar", "webklar-system", "knso") == "/orgs/WEBklar/repos"
|
||||
assert repo_create_path("knso", "webklar-system", "knso") is None
|
||||
assert repo_create_path("Webklar-System", "webklar-system", "knso") == "/user/repos"
|
||||
def test_einzel_webhook():
|
||||
assert needs_repo_webhook("WEBklar", "WEBklar", "knso") is False
|
||||
assert needs_repo_webhook("webklar", "WEBklar", "knso") is False
|
||||
assert needs_repo_webhook("knso", "WEBklar", "knso") is True
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd /opt/webklar-automation && python3 -m pytest -q tests`
|
||||
Expected: FAIL (`ModuleNotFoundError: repo_paths`)
|
||||
|
||||
- [ ] **Step 3: Implement `repo_paths.py`**
|
||||
|
||||
- It uses only the standard library and loads no configuration.
|
||||
- `git_url` builds the host from `base_url` without the scheme.
|
||||
- The owner is extracted from `repo_url` with `^https?://[^/]+/([^/]+)/[^/]+/?$`.
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `python3 -m pytest -q tests`
|
||||
Expected: `7 passed`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add repo_paths.py tests/test_repo_paths.py && git -c user.name=Claude -c user.email=claude@webklar.local commit -qm "Pfad-Regeln für Lead-Repos mit Tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: worker.py umstellen
|
||||
|
||||
**Files:**
|
||||
- Modify: `worker.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `repo_paths` (Task 2)
|
||||
- Produces, for the other files:
|
||||
- `W.GITEA_AUTH_USER`
|
||||
- `W.NEW_REPO_OWNER`
|
||||
- `W.lead_repo_owner(lead) -> str`
|
||||
- `W.lead_repo_full(lead) -> str`
|
||||
- `W.lead_git_url(owner, repo_name) -> str`
|
||||
- `W.lead_web_url(owner, repo_name) -> str`
|
||||
- `W.ensure_repo(repo_name, firma, owner)`
|
||||
- `W._mark_processed(lead_id, repo_url, repo_owner="")`
|
||||
|
||||
- [ ] **Step 1: Implement**
|
||||
|
||||
1. **Settings:** `GITEA_AUTH_USER = cfg.get("GITEA_AUTH_USER") or GITEA_USER` and `NEW_REPO_OWNER = cfg.get("GITEA_NEW_REPO_OWNER") or GITEA_USER`. Add the four `lead_*` wrappers, which fill the `repo_paths` functions with these values.
|
||||
2. **`ensure_repo(repo_name, firma, owner)`:**
|
||||
- `path = repo_create_path(owner, GITEA_AUTH_USER, GITEA_USER)`.
|
||||
- If `path` is `None`, check `GET /repos/<owner>/<repo_name>`. On 200, reuse the repo; otherwise raise `RuntimeError("Repo %s/%s fehlt und kann dort nicht angelegt werden" % (owner, repo_name))`.
|
||||
- Otherwise POST to `path`. As today, 409 means reuse.
|
||||
- Only call `ensure_repo_webhook(repo_full(owner, repo_name))` if `needs_repo_webhook(owner, NEW_REPO_OWNER, GITEA_USER)`.
|
||||
3. **Git and web addresses:**
|
||||
- The push URL in the fill step (today line 531) becomes `lead_git_url(owner, repo_name)`, so that function gets `owner` as a parameter.
|
||||
- `get_template_cache` uses `GITEA_AUTH_USER` as the user; owner and repo stay `TEMPLATE_OWNER`/`TEMPLATE_REPO`.
|
||||
- Every `"%s/%s/%s" % (GITEA_URL, GITEA_USER, repo_name)` becomes `lead_web_url(owner, repo_name)`.
|
||||
4. **Lookups:** In the three functions at the former lines 331, 373 and 415, use `lead_repo_full(lead)`.
|
||||
5. **`process_lead`:**
|
||||
- `owner = lead_repo_owner(lead)`, set `lead["repo_owner"] = owner`, then `ensure_repo(repo_name, firma, owner)`.
|
||||
- `_mark_processed(lead_id, repo_url, owner)` also stores `repo_owner` in `master.json`.
|
||||
- The caller around the former line 621 passes the owner along.
|
||||
|
||||
- [ ] **Step 2: Check**
|
||||
|
||||
```bash
|
||||
python3 -m py_compile worker.py && python3 -m pytest -q tests
|
||||
grep -n "GITEA_USER, repo_name\|GITEA_USER, (lead" worker.py
|
||||
```
|
||||
|
||||
Expected: compiles, `7 passed`, and grep finds nothing.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add worker.py && git -c user.name=Claude -c user.email=claude@webklar.local commit -qm "worker: Repo-Besitzer pro Lead, System-Konto für Git-Adressen"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: processor.py, enrich.py und Wartungsskripte umstellen
|
||||
|
||||
**Files:**
|
||||
- Modify: `processor.py`, `enrich.py`, `backfill_leads.py`, `migrate_previews.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the `W.*` functions from Task 3
|
||||
|
||||
- [ ] **Step 1: Implement**
|
||||
|
||||
- **`processor.py`:**
|
||||
- `_repo_url(owner, repo_name)` returns `worker.lead_git_url(owner, repo_name)`.
|
||||
- `_clone_repo(owner, repo_name)`.
|
||||
- In `process_lead`: `owner = worker.lead_repo_owner(lead)`, then `worker.ensure_repo(repo_name, firma, owner)`, `worker._mark_processed(lead_id, repo_url, owner)`, and `repo_url = worker.lead_web_url(owner, repo_name)`.
|
||||
- **`enrich.py`:**
|
||||
- `_repo_push_url(owner, repo_name)` and `_clone_repo(owner, repo_name)`.
|
||||
- `_apply_to_repo(owner, repo_name, p, ctx=None)`. The caller determines `owner = worker.lead_repo_owner(lead)` inside the `master_lock` block, together with `repo_name`.
|
||||
- In `get_file`, the API path uses `worker.lead_repo_owner(lead)` instead of `worker.GITEA_USER`.
|
||||
- `repo_url` (the former line 1049) becomes `worker.lead_web_url(owner, repo_name)`.
|
||||
- **`backfill_leads.py` and `migrate_previews.py`:** `W.lead_repo_full(lead)` instead of `"%s/%s" % (W.GITEA_USER, repo_name)`.
|
||||
|
||||
- [ ] **Step 2: Check**
|
||||
|
||||
```bash
|
||||
python3 -m py_compile processor.py enrich.py backfill_leads.py migrate_previews.py hook_server.py && python3 -m pytest -q tests
|
||||
grep -n "GITEA_USER" processor.py enrich.py backfill_leads.py migrate_previews.py
|
||||
```
|
||||
|
||||
Expected: compiles, `7 passed`. `GITEA_USER` appears in these four files nowhere, or only in comments.
|
||||
|
||||
- [ ] **Step 3: Restart with unchanged `config.env` (the code alone changes nothing)**
|
||||
|
||||
With the user's OK:
|
||||
|
||||
```bash
|
||||
systemctl restart webklar-automation-hook webklar-automation-processor
|
||||
systemctl is-active webklar-automation-hook webklar-automation-processor
|
||||
```
|
||||
|
||||
Expected: `active` twice, and the log files in `logs/` show no traceback in the next 10 minutes.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add processor.py enrich.py backfill_leads.py migrate_previews.py && git -c user.name=Claude -c user.email=claude@webklar.local commit -qm "processor/enrich: Adressen mit Repo-Besitzer des Leads"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Review**
|
||||
|
||||
Run a fresh code review of `git diff <Ausgangsstand>..HEAD` against Spec 8 and this plan (`superpowers:requesting-code-review`).
|
||||
|
||||
---
|
||||
|
||||
## Livegang – nur mit OK des Nutzers, erst nach Plan 1 L5
|
||||
|
||||
**L0 Sicherung**
|
||||
|
||||
```bash
|
||||
cp /opt/webklar-automation/config.env /opt/webklar-automation/config.env.bak-gitea-org
|
||||
```
|
||||
|
||||
**L1 Schlüssel und Einstellungen.** The processor is stopped, so no lead is in the middle of processing:
|
||||
|
||||
```bash
|
||||
systemctl stop webklar-automation-processor
|
||||
docker exec -u git webklar-gitea gitea admin user generate-access-token --username webklar-system --token-name lead-automatik --scopes write:repository,write:organization,read:user --raw \
|
||||
| python3 -c 'import re,sys; t=sys.stdin.read().strip(); p="/opt/webklar-automation/config.env"; s=open(p).read(); s=re.sub(r"^GITEA_TOKEN=.*$", "GITEA_TOKEN="+t, s, flags=re.M); open(p,"w").write(s.rstrip("\n")+"\nGITEA_AUTH_USER=webklar-system\nGITEA_NEW_REPO_OWNER=WEBklar\n")'
|
||||
grep -c "^GITEA_AUTH_USER=webklar-system$\|^GITEA_NEW_REPO_OWNER=WEBklar$" /opt/webklar-automation/config.env
|
||||
systemctl start webklar-automation-processor && systemctl restart webklar-automation-hook
|
||||
```
|
||||
|
||||
Expected: `2`, and both services `active`. The token only passes through the pipe.
|
||||
|
||||
**L2 Zugang prüfen:**
|
||||
|
||||
```bash
|
||||
cd /opt/webklar-automation && python3 -c "import worker as W; c,u=W.gitea_api('GET','/user'); print(c,u.get('login'),u.get('is_admin'))"
|
||||
```
|
||||
|
||||
Expected: `200 webklar-system True`.
|
||||
|
||||
**L3 Probelauf mit Wegwerf-Repo.** Only with explicit OK:
|
||||
|
||||
1. With the worker functions, create `WEBklar/preview-probe-gitea-org` (`W.ensure_repo('preview-probe-gitea-org', 'Probe', W.NEW_REPO_OWNER)`), then `W.set_repo_file('WEBklar/preview-probe-gitea-org', 'index.html', '<h1>Probe</h1>', 'Probe')`.
|
||||
2. Clone the template once with `W.get_template_cache()`. This checks that the system account can read `knso/<TEMPLATE_REPO>`.
|
||||
3. Expected:
|
||||
- the repo exists in WEBklar,
|
||||
- `GET /repos/WEBklar/preview-probe-gitea-org/hooks` returns `[]`,
|
||||
- within a minute the portal has a `websiteProjects` entry `WEBklar/preview-probe-gitea-org` with status `synced`, created by the organisation webhook,
|
||||
- no new arbeitsblatt.
|
||||
4. Clean up:
|
||||
- `DELETE /repos/WEBklar/preview-probe-gitea-org`,
|
||||
- delete the `websiteProjects` entry with a script in the portal container,
|
||||
- check that both are gone.
|
||||
|
||||
**L4 Beobachten.** Check the next real lead: its repo is created in WEBklar, the preview deploys, and there is no per-repo webhook. Update the memory note.
|
||||
|
||||
**Rückweg:**
|
||||
|
||||
```bash
|
||||
cp /opt/webklar-automation/config.env.bak-gitea-org /opt/webklar-automation/config.env
|
||||
systemctl restart webklar-automation-hook webklar-automation-processor
|
||||
```
|
||||
|
||||
The code can stay: without the new keys it behaves as it does today. Remove the `lead-automatik` token in Gitea under Settings → Applications of the account, as an admin.
|
||||
@@ -0,0 +1,305 @@
|
||||
# Gemeinsame Gitea-Organisation für alle Mitarbeiter – Konzept
|
||||
|
||||
Stand 27.09.2026 · Entwurf zur Durchsicht · umgesetzt ist noch nichts
|
||||
|
||||
Betrifft Gitea (git.webklar.com), das Kundenportal (project.webklar.com), das Ticketsystem (ticket.webklar.com) und die Lead-Automatik (`/opt/webklar-automation`).
|
||||
|
||||
## 1. Ausgangslage
|
||||
|
||||
Anlass: Justins Repo `JUSN/D-ner-Nation` erschien nicht in der Projektliste des Ticketsystems, und sein Push am 27.09. um 16:46 Uhr erzeugte kein Arbeitsblatt. Bei Andrej (`knso/emmas-schatzkiste`, Kunde „Martin“, Ticket #100111) klappte beides.
|
||||
|
||||
Ursachen:
|
||||
|
||||
1. Das Kundenportal spricht mit Gitea über Kensos persönliches Konto `knso`. In Justins und Andrejs eigenen Repos darf knso nur lesen.
|
||||
2. Pushes erreichen das Portal nur über Webhooks, die in jedem Repo einzeln eingerichtet sein müssen. Heute gibt es 100 davon, angelegt von Hand oder von der Lead-Automatik. Auch „Neues Projekt“ im Ticketsystem legt Repos ohne Webhook an (z. B. `knso/gradient-component`, `knso/lackwerk-kl`).
|
||||
3. Neue Repos erscheinen im Ticketsystem erst, nachdem jemand auf der Projekte-Seite „Gitea-Repos synchronisieren“ gedrückt hat.
|
||||
4. Ein Push wird nur dann ein Arbeitsblatt, wenn das Projekt zum Zeitpunkt des Pushs schon einem Ticket oder Kunden zugewiesen ist. Frühere Pushes gehen für die Abrechnung verloren.
|
||||
|
||||
Außerdem gefunden:
|
||||
|
||||
- Push-Arbeitsblätter tragen Datum und Uhrzeit in UTC, weil der Portal-Container keine Zeitzone hat. Im Sommer stehen sie 2 Stunden hinter der deutschen Zeit.
|
||||
- Pushes, die das System selbst macht (z. B. die Preview-Einstellung ins Repo schreiben), erzeugen Arbeitsblätter auf knso.
|
||||
- Der Gitea-Abgleich gibt jedem Repo eine Subdomain. Die Preview-Selbstheilung behandelt jedes Projekt mit Subdomain als Preview. Mit einem Webhook für viele Repos würde dadurch jeder Push in ein abgeglichenes Repo eine Preview anlegen.
|
||||
- Im Portal kommen der Gitea-Schlüssel (`GITEA_API_TOKEN`) und das Webhook-Geheimnis (`GITEA_WEBHOOK_TOKEN`) aus derselben Variable `GITEA_WEBHOOK_TOKEN` in `/home/webklar/apps/.env`. Kensos Schlüssel ist also zugleich das Geheimnis aller Webhooks. Mehrere Deploy-Container klonen ebenfalls damit.
|
||||
|
||||
## 2. Ziel
|
||||
|
||||
1. Alle Mitarbeiter sind gleichberechtigt. Niemand braucht Sonderrechte, und niemand muss einem anderen Rechte geben, damit etwas funktioniert.
|
||||
2. Alle Mitarbeiter können in jedem Kundenprojekt arbeiten.
|
||||
3. Egal wer ein Repo anlegt und wo: Es erscheint im Ticketsystem, lässt sich einem Ticket oder Kunden zuweisen, läuft auf Knopfdruck als Preview, und jeder Push wird ein Arbeitsblatt für den, der gepusht hat. So lässt sich die Zeit für die Abrechnung eintragen.
|
||||
4. Kundenportal, Ticketsystem und Lead-Automatik hängen nicht mehr an Kensos Konto.
|
||||
|
||||
Nicht Teil dieses Konzepts:
|
||||
|
||||
- die rund 90 bestehenden Lead-Repos bei knso. Sie ziehen erst um, wenn sie einem Ticket zugewiesen werden.
|
||||
- interne Repos (Ticketsystem, Kundenportal, Website, Emailsorter usw.) und ihre Deploy-Container. Sie bleiben, wo sie sind, und nutzen weiter den bisherigen Schlüssel.
|
||||
- ein eigenes Webhook-Geheimnis getrennt von Kensos Schlüssel (siehe 9).
|
||||
- ein Gitea-Konto für Nico.
|
||||
- das Umbenennen von Repos (z. B. `D-ner-Nation`).
|
||||
|
||||
## 3. Entscheidungen
|
||||
|
||||
| Thema | Entscheidung | Herkunft |
|
||||
|---|---|---|
|
||||
| Mitarbeit | Alle Mitarbeiter können in allen Kundenprojekten arbeiten, mit denselben Rechten | Frage, bestätigt |
|
||||
| Ort der Kunden-Repos | Gitea-Organisation „WEBklar“, alle Mitarbeiter im Team „Owners“ | Vorschlag, bestätigt |
|
||||
| Zugang des Systems | neutrales Konto `webklar-system` (Gitea-Admin) statt knso | Vorschlag, bestätigt |
|
||||
| Pushes melden | ein Webhook an der Organisation (Weg A) | Vorschlag, bestätigt |
|
||||
| Repos in eigenen Konten | werden beim Zuweisen automatisch nach WEBklar verschoben | Frage, bestätigt |
|
||||
| Umzug jetzt | 6 Kundenprojekte (siehe 7); Lead-Previews bleiben bei knso | Frage, bestätigt |
|
||||
| Neue Lead-Previews | entstehen in WEBklar | Frage, bestätigt |
|
||||
| Pushes vor der Zuweisung | werden beim Zuweisen nachgetragen, letzte 30 Tage | Frage, bestätigt |
|
||||
| Preview | Knopf „Preview einschalten“ mit änderbarem Subdomain-Vorschlag | Frage, bestätigt |
|
||||
| Uhrzeit der Push-Arbeitsblätter | deutsche Zeit (Europe/Berlin) | Vorschlag, bestätigt |
|
||||
| Pushes des System-Kontos | erzeugen kein Arbeitsblatt | Vorschlag, bestätigt |
|
||||
| Subdomains beim Abgleich | werden nicht mehr automatisch vergeben | Vorschlag, bestätigt |
|
||||
| Interne Repos | werden nie automatisch verschoben (Regel in 5.3) | Vorschlag, bitte prüfen |
|
||||
| Sichtbarkeit der Organisation | öffentlich, wie die Kunden-Repos heute | Vorschlag, bitte prüfen |
|
||||
| Private Repos in der Projektliste | erscheinen mit (heute 13, alle bei knso) | Vorschlag, bitte prüfen |
|
||||
|
||||
## 4. Gitea-Grundlage (Phase 1)
|
||||
|
||||
### 4.1 System-Konto
|
||||
|
||||
- Benutzername `webklar-system`, E-Mail `webklar-system@noreply.webklar.com`, E-Mail-Benachrichtigungen aus.
|
||||
- Gitea-Admin. Nur so kann es Repos aus fremden Konten verschieben und private Repos lesen.
|
||||
- Zufälliges Passwort, das nirgends gespeichert wird. Niemand meldet sich mit dem Konto an.
|
||||
- Zwei Schlüssel, damit sich jeder einzeln sperren lässt: `portal` für das Kundenportal und `lead-automatik` für die Lead-Automatik. Rechte je Schlüssel: `write:repository`, `write:organization`, `read:user`. Keine Admin-Schnittstelle.
|
||||
- Angelegt über die Gitea-Befehlszeile im Container `webklar-gitea` (`gitea admin user create --admin …`, `gitea admin user generate-access-token --scopes … --raw`). Die Schlüssel werden direkt in die Konfiguration geschrieben und nie angezeigt.
|
||||
|
||||
### 4.2 Organisation
|
||||
|
||||
- Name `WEBklar`, Adresse `https://git.webklar.com/WEBklar/…`, Sichtbarkeit öffentlich.
|
||||
- Team „Owners“: `webklar-system`, `knso`, `jusn`, `andj`. Alle haben dieselben Rechte: Repos anlegen, pushen, Einstellungen ändern, löschen.
|
||||
- Angelegt über die Gitea-Schnittstelle mit dem System-Konto.
|
||||
- Bekommt Nico ein Gitea-Konto, kommt er ins Team „Owners“. Sein Konto muss als Namen „Nico Rech“ oder als Benutzernamen sein Kürzel `NIRE` tragen, damit seine Pushes ihm zugeordnet werden.
|
||||
|
||||
### 4.3 Webhook der Organisation
|
||||
|
||||
- URL `https://project.webklar.com/webhook/gitea`, Inhalt JSON, nur Push-Ereignisse, alle Branches.
|
||||
- Geheimnis: der heutige Wert von `GITEA_WEBHOOK_TOKEN`. Das Portal prüft ihn wie bisher.
|
||||
- Angelegt von einem Skript im Portal-Container, damit das Geheimnis nie ausgegeben wird.
|
||||
- Er gilt für jedes Repo in WEBklar, auch für künftige. Einzel-Webhooks braucht es dort nicht mehr.
|
||||
|
||||
### 4.4 Einstellungen des Portals
|
||||
|
||||
Dienst `kundenbereich-server` in `/home/webklar/apps/docker-compose.yml`:
|
||||
|
||||
- `GITEA_API_TOKEN=${GITEA_SYSTEM_TOKEN}` (bisher `${GITEA_WEBHOOK_TOKEN}`). `GITEA_SYSTEM_TOKEN` kommt neu in `/home/webklar/apps/.env`.
|
||||
- `GITEA_WEBHOOK_TOKEN=${GITEA_WEBHOOK_TOKEN}` bleibt unverändert.
|
||||
- `GITEA_DEFAULT_OWNER=WEBklar`. Neu sind `GITEA_ORG=WEBklar` und `GITEA_SYSTEM_USER=webklar-system`.
|
||||
- `GITEA_TEMPLATE_OWNER` bleibt `knso`, weil die Vorlage `knso/webklar-preview-template` nicht umzieht.
|
||||
- Neu, aber ohne Eintrag in der Compose-Datei: `GITEA_KEEP_IN_PLACE`. Die Voreinstellung steht im Code (siehe 5.3).
|
||||
- Übernahme mit `docker compose up -d --no-deps kundenbereich-server`. Ein `docker restart` übernimmt neue Werte nicht.
|
||||
|
||||
## 5. Kundenportal (Phase 2)
|
||||
|
||||
### 5.1 Neue Repos finden
|
||||
|
||||
- Neue Funktion `importNewRepos()`. Sie holt alle Repos, die das System-Konto sieht, also alle, auch private. Für jedes nicht archivierte Repo ohne Projekt-Eintrag legt sie einen an:
|
||||
- Name = Gitea-Beschreibung, sonst der Repo-Name,
|
||||
- keine Subdomain, keine Preview-Adresse,
|
||||
- Status `synced`.
|
||||
- Bestehende Einträge bleiben unverändert.
|
||||
- Schnittstelle `POST /api/admin/gitea/import-repos` mit der Antwort `{ created }`.
|
||||
- Der Import läuft im Portal-Prozess nie doppelt. Ein zweiter Aufruf wartet auf den ersten.
|
||||
- Projekt-Einträge anlegen (Import und Webhook) läuft pro Repo unter derselben Sperre wie der Umzug (5.3). So entsteht kein doppelter Eintrag, wenn Import und erster Push zusammenfallen.
|
||||
|
||||
### 5.2 Zuweisen
|
||||
|
||||
- Neue Schnittstelle `POST /api/admin/website-projects/:id/assign` mit `{ customerId, ticketId }`.
|
||||
- Ablauf:
|
||||
1. Kunde und Ticket am Projekt speichern.
|
||||
2. Falls nötig, das Repo nach WEBklar verschieben (5.3).
|
||||
3. Pushes nachtragen (5.4).
|
||||
4. Antwort `{ ok, project, moved: { from, to } | null, backfilled, warnings: [] }`.
|
||||
- Schritt 1 ist die eigentliche Zuweisung. Scheitert Schritt 2 oder 3, bleibt sie bestehen, und `warnings` beschreibt in einem deutschen Satz, was fehlt.
|
||||
- Eine Zuweisung entfernen bleibt wie heute: direkt in Appwrite, ohne Gitea.
|
||||
|
||||
### 5.3 Umzug nach WEBklar
|
||||
|
||||
- Ein Repo außerhalb von WEBklar überträgt das Portal mit `POST /repos/{owner}/{repo}/transfer` (`new_owner: WEBklar`). Weil das System-Konto Gitea-Admin ist, geschieht das sofort. Alte Links und Push-Adressen leiten weiter.
|
||||
- Danach:
|
||||
- bekommt der Projekt-Eintrag den neuen Namen (`repoFullName`, `giteaRepoUrl`, `giteaRepoName`),
|
||||
- werden die Zwischenspeicher für alten und neuen Namen geleert,
|
||||
- wird ein alter Einzel-Webhook ans Portal gelöscht (URL beginnt mit `https://project.webklar.com/webhook/gitea`),
|
||||
- wird das alte Arbeitsverzeichnis `preview-data/<alter Name>` gelöscht.
|
||||
- Während eines Umzugs wartet die Webhook-Verarbeitung für dieses Repo bis zu 10 Sekunden. Dafür gibt es eine Sperre im Portal-Prozess. So entsteht kein doppelter Projekt-Eintrag, falls genau dann ein Push ankommt.
|
||||
- Nie automatisch verschoben werden interne Repos. Als intern gilt ein Repo,
|
||||
- wenn es einen Webhook hat, der nicht ans Portal geht. Heute sind das `knso/Webklar.com`, `ANDJ/Emailsorter`, `JUSN/Webklar`, `knso/hytail-ark`, `JUSN/tickte-system` und `knso/Webklar-Kundenbereich`.
|
||||
- oder wenn es in `GITEA_KEEP_IN_PLACE` steht. Voreinstellung: die Repos der Deploy-Container, also `ANDJ/Emailsorter`, `ANDJ/Steuerungstechnik`, `JUSN/tickte-system`, `JUSN/Webklar`, `knso/eship` und `knso/Webklar-Kundenbereich`.
|
||||
- Solche Repos werden trotzdem zugewiesen und bekommen ihre Pushes nachgetragen. Die Warnung lautet: „Internes Repo, bleibt wo es ist.“
|
||||
- Gibt es in WEBklar schon ein Repo mit dem Namen, findet kein Umzug statt. Die Warnung lautet: „In WEBklar gibt es schon ein Repo namens X. Bitte in Gitea umbenennen und dann ‚Pushes nachtragen‘ klicken.“
|
||||
- Selbstheilung: Meldet Gitea für den gespeicherten Namen ein Repo unter einem anderen Namen (Weiterleitung nach einem Umzug), korrigiert das Portal den Projekt-Eintrag.
|
||||
|
||||
### 5.4 Pushes nachtragen
|
||||
|
||||
- Quelle sind die Gitea-Aktivitäten des Repos (`GET /repos/{owner}/{repo}/activities/feeds`, seitenweise). Berücksichtigt werden Einträge `commit_repo` der letzten 30 Tage, die Commits enthalten. Einträge ohne Commits, etwa ein neu angelegter Branch, werden übersprungen.
|
||||
- Für jeden Push entsteht ein Arbeitsblatt wie beim Webhook:
|
||||
- Mitarbeiter = Gitea-Anzeigename oder Benutzername dessen, der gepusht hat,
|
||||
- die Commits mit Titel und Text,
|
||||
- Ende = Push-Zeitpunkt in deutscher Zeit, Start leer. Damit erscheint „Zeit nachtragen“.
|
||||
- Übersprungen werden:
|
||||
- Pushes des System-Kontos,
|
||||
- Pushes, deren Commit im Ticket schon ein Arbeitsblatt hat. Das ist dieselbe Prüfung wie heute (`Commit: <7 Zeichen>` in den Details).
|
||||
- Die ältesten Pushes kommen zuerst, damit die Arbeitsblatt-Nummern mit der Zeit steigen.
|
||||
- Hat das Projekt nur einen Kunden und kein Ticket, wird wie beim Webhook zuerst ein Webpage-Ticket angelegt (`ensureProjectTicket`). Das geschieht nur, wenn es mindestens einen Push nachzutragen gibt.
|
||||
- Schnittstelle für den Knopf „Pushes nachtragen“: `POST /api/admin/website-projects/:id/pushes`. Sie holt zuerst einen fehlgeschlagenen Umzug nach und trägt dann nach. Antwort `{ moved, backfilled, warnings }`.
|
||||
|
||||
### 5.5 Push-Arbeitsblätter
|
||||
|
||||
- Datum und Uhrzeit in Europe/Berlin statt UTC, beim Webhook und beim Nachtragen. Vorhandene Arbeitsblätter bleiben unverändert.
|
||||
- Pushes von `GITEA_SYSTEM_USER` erzeugen kein Arbeitsblatt. Deploy und Projektdaten laufen trotzdem.
|
||||
- Pro Ticket und Commit legt der Portal-Prozess immer nur ein Arbeitsblatt gleichzeitig an. Zusammen mit der bestehenden Prüfung entsteht auch bei doppelter Zustellung nie ein doppeltes.
|
||||
|
||||
### 5.6 Absicherung der Previews
|
||||
|
||||
- Der Abgleich `syncReposToAppwrite` (Knopf „Gitea-Repos synchronisieren“) vergibt neuen oder leeren Einträgen keine Subdomain und keine Preview-Adresse mehr. Vorhandene Subdomains bleiben.
|
||||
- Die Preview-Selbstheilung (`ensureRepoPreviewConfig`) greift nur noch für Projekte, die wirklich gehostet sind: Typ `preview`, `website` oder `template`, oder Status `deployed` oder `pending`. Heute reicht eine vorhandene Subdomain.
|
||||
- `slugify` wandelt Umlaute um (ä→ae, ö→oe, ü→ue, ß→ss) und entfernt Apostrophe, bevor andere Sonderzeichen zu Bindestrichen werden.
|
||||
|
||||
### 5.7 Schnittstellen
|
||||
|
||||
| Methode und Pfad | Zweck | neu |
|
||||
|---|---|---|
|
||||
| `POST /api/admin/gitea/import-repos` | neue Repos als Projekte anlegen | ja |
|
||||
| `POST /api/admin/website-projects/:id/assign` | zuweisen, verschieben, nachtragen | ja |
|
||||
| `POST /api/admin/website-projects/:id/pushes` | Umzug nachholen, Pushes nachtragen | ja |
|
||||
| `PATCH /api/admin/website-projects/:id` | Preview einschalten (Typ `preview` + Subdomain) | nein |
|
||||
| `POST /webhook/gitea` | Pushes aus Gitea | nein, Verhalten nach 5.3 und 5.5 |
|
||||
|
||||
Alle Admin-Schnittstellen sind wie heute nur für Mitarbeiter (`requireAppwriteStaff`).
|
||||
|
||||
## 6. Ticketsystem (Phase 3)
|
||||
|
||||
Die Änderungen entstehen auf dem dann aktuellen Stand von `test`, also nach „Paket B“ der Entschlackung. Diese Arbeit läuft parallel; dort wandern die Projektinhalte des Tickets in den Tab „Details“.
|
||||
|
||||
Dialog „Projekte bearbeiten“ (`TicketProjectsModal`):
|
||||
|
||||
- Beim Öffnen ruft er `import-repos` auf und lädt danach die Listen. Solange steht dort „Suche neue Repos …“.
|
||||
- In der Liste „Verfügbar“ tragen Repos außerhalb von WEBklar den Zusatz „wird beim Zuweisen nach WEBklar verschoben“.
|
||||
- „Zuweisen“ ruft für jedes gewählte Projekt nacheinander `assign` auf und zeigt eine Zusammenfassung, z. B. „Döner Nation: nach WEBklar verschoben · 1 Push nachgetragen“. Warnungen erscheinen darunter.
|
||||
|
||||
Zugewiesene Projekte (`AssignedProjectCard`):
|
||||
|
||||
- **Preview einschalten** erscheint, wenn keine Preview läuft, also kein `previewUrl` vorhanden ist oder der Status nicht `deployed` ist.
|
||||
- Ein Eingabefeld mit Vorschlag und der Knopf „Einschalten“ senden `PATCH /api/admin/website-projects/:id` mit `projectType: 'preview'` und `subdomain`.
|
||||
- Fehler, etwa eine schon vergebene Subdomain, erscheinen im Dialog.
|
||||
- **Pushes nachtragen** sendet `POST …/:id/pushes` und zeigt das Ergebnis als Meldung.
|
||||
|
||||
Subdomain-Vorschlag (neue Datei `src/lib/subdomain.js`):
|
||||
|
||||
- Grundlage ist der Kundenname des Tickets, sonst der Projektname.
|
||||
- Umlaute werden umgewandelt, Apostrophe entfallen, alles wird kleingeschrieben.
|
||||
- Alles außer a–z und 0–9 wird zu einem Bindestrich. Höchstens 63 Zeichen.
|
||||
- Beispiele: „Döner Nation“ → `doener-nation`, „Emma's Schatzkiste“ → `emmas-schatzkiste`.
|
||||
|
||||
Projekte-Seite:
|
||||
|
||||
- „Kunde zuordnen“ nutzt ebenfalls `assign`.
|
||||
- Der Knopf „Gitea-Repos synchronisieren“ bleibt und verhält sich nach 5.6.
|
||||
|
||||
## 7. Umzug der Kundenprojekte (Phase 4)
|
||||
|
||||
| Projekt | heute | danach | alter Einzel-Webhook |
|
||||
|---|---|---|---|
|
||||
| Emma's Schatzkiste | `knso/emmas-schatzkiste` | `WEBklar/emmas-schatzkiste` | Nr. 110, wird gelöscht |
|
||||
| Anelia Wolf | `knso/Anelia-Wolf` | `WEBklar/Anelia-Wolf` | Nr. 100, wird gelöscht |
|
||||
| Unger Reisen | `ANDJ/Unger-Bus-Unternehmen` | `WEBklar/Unger-Bus-Unternehmen` | Nr. 13, wird gelöscht |
|
||||
| Döner Nation | `JUSN/D-ner-Nation` | `WEBklar/D-ner-Nation` | keiner |
|
||||
| videogen | `knso/videogen` | `WEBklar/videogen` | Nr. 59, wird gelöscht |
|
||||
| kfz | `knso/kfz` | `WEBklar/kfz` | keiner |
|
||||
|
||||
Das siebte Kundenprojekt aus der Zählung, `JUSN/tickte-system`, ist ein internes Repo und bleibt.
|
||||
|
||||
Ablauf:
|
||||
|
||||
1. Die Webhooks der 6 Repos sichern (Nummer, URL, Ereignisse), damit sie sich wiederherstellen lassen.
|
||||
2. Ein Skript im Portal-Container ruft Repo für Repo dieselbe Funktion wie „Pushes nachtragen“ auf: verschieben, Projekt-Eintrag anpassen, alten Webhook löschen, Pushes der letzten 30 Tage nachtragen. Dabei entsteht auch Justins Arbeitsblatt für den Push vom 27.09. um 16:46 Uhr.
|
||||
3. Für Döner Nation schreibt das Skript danach die Preview-Einstellung `.webklar-preview.json` ins Repo. Justin macht vor seinem nächsten Push einmal `git pull`.
|
||||
4. Prüfung je Projekt:
|
||||
- Der Projekt-Eintrag zeigt den neuen Namen.
|
||||
- Die Preview antwortet wie vorher: 302 zum Login, bei den öffentlichen Seiten 200.
|
||||
- Die Änderungsliste im Kundenportal lädt.
|
||||
|
||||
Für die Mitarbeiter: Alte Push-Adressen leiten weiter. Sauberer ist `git remote set-url origin https://git.webklar.com/WEBklar/<repo>.git`, die Befehle kommen pro Repo.
|
||||
|
||||
Kunden merken nichts. Preview-Adressen und die Domains `emmas-schatzkiste.de` und `anelia-wolf.com` bleiben gleich, weil die Previews nach Subdomain abgelegt sind und nicht nach Repo-Namen.
|
||||
|
||||
## 8. Lead-Automatik (Phase 5)
|
||||
|
||||
Diese Phase hängt nur von Phase 1 ab und kann auch später kommen.
|
||||
|
||||
- `config.env`:
|
||||
- `GITEA_TOKEN` wird der Schlüssel `lead-automatik` des System-Kontos,
|
||||
- neu `GITEA_AUTH_USER=webklar-system` für die Git-Adressen,
|
||||
- neu `GITEA_NEW_REPO_OWNER=WEBklar`.
|
||||
- `GITEA_USER=knso` bleibt als Besitzer der bestehenden Lead-Repos.
|
||||
- Jeder Lead speichert künftig `repo_owner`. Bestehende Leads ohne dieses Feld gelten als `knso`, neue bekommen `WEBklar`.
|
||||
- Alle Stellen, die heute Repo-Pfade und Git-Adressen aus `GITEA_USER` bilden, nutzen stattdessen die Funktionen `lead_repo_full(lead)` und `lead_git_url(lead)`. Das betrifft `worker.py`, `processor.py`, `enrich.py` und alle weiteren Dateien mit diesem Muster.
|
||||
- `ensure_repo` legt neue Repos in WEBklar an (`POST /orgs/WEBklar/repos`) und setzt dort keinen Einzel-Webhook. Für Repos bei knso bleibt `ensure_repo_webhook` wie heute.
|
||||
- Pushes der Automatik laufen dann über das System-Konto und erzeugen keine Arbeitsblätter auf knso mehr.
|
||||
- Umgestellt wird direkt nach einem Morgenlauf (06:30 Uhr). Der nächste Lauf wird geprüft.
|
||||
|
||||
## 9. Sicherheit
|
||||
|
||||
- `webklar-system` ist Gitea-Admin, damit es fremde Repos verschieben kann. Seine Schlüssel dürfen nur Repos und die Organisation bearbeiten und Benutzer lesen. Die Admin-Schnittstelle ist ausgeschlossen, und Portal und Lead-Automatik haben getrennte Schlüssel.
|
||||
- Das Portal kann damit alle Repos lesen und schreiben, auch private. Heute kann es das nur bei knso.
|
||||
- Das Webhook-Geheimnis bleibt der heutige Wert, also Kensos Schlüssel. Wird der Schlüssel in Gitea gesperrt, fallen die Deploy-Container aus, die damit klonen. Die Webhooks prüfen nur den Wert und laufen weiter. Soll der Wert geändert werden, müssen alle Webhooks mit. Empfehlung für später: ein eigenes Geheimnis und eigene Deploy-Schlüssel.
|
||||
- Admin-Schnittstellen sind wie heute nur für Mitarbeiter.
|
||||
|
||||
## 10. Fehlerfälle
|
||||
|
||||
| Fall | Verhalten |
|
||||
|---|---|
|
||||
| Name existiert schon in WEBklar | kein Umzug; Warnung mit der Bitte, umzubenennen |
|
||||
| internes Repo (5.3) | kein Umzug; Zuweisung und Nachtragen laufen; Warnung „Internes Repo, bleibt wo es ist.“ |
|
||||
| Gitea nicht erreichbar | Zuweisung gespeichert; Warnung; später „Pushes nachtragen“ |
|
||||
| Umzug gelungen, Projekt-Eintrag nicht aktualisiert | Warnung; „Pushes nachtragen“ erkennt den neuen Namen über die Weiterleitung und korrigiert |
|
||||
| Push kommt während eines Umzugs | Webhook-Verarbeitung wartet bis zu 10 Sekunden auf den Umzug |
|
||||
| Push kommt doppelt an | ein Arbeitsblatt |
|
||||
| Schlüssel des System-Kontos fehlt oder ist ungültig | Fehlermeldung wie heute („GITEA_API_TOKEN fehlt“ bzw. Gitea 401) |
|
||||
|
||||
## 11. Tests
|
||||
|
||||
Kundenportal, automatisch (`npm test`, ohne echte Daten):
|
||||
|
||||
- Umwandlung der Gitea-Aktivitäten in Pushes: nur `commit_repo`, 30-Tage-Grenze, Einträge ohne Commits, System-Konto.
|
||||
- deutsche Zeit für Sommer, Winter und kurz nach Mitternacht UTC.
|
||||
- `slugify` mit Umlauten und Apostrophen.
|
||||
- `isHostedProject`.
|
||||
- Umzug mit einem nachgebauten Gitea: gelungen, Name existiert, schon in WEBklar, internes Repo, Weiterleitung nach Umzug.
|
||||
- Zuweisen mit nachgebautem Appwrite und Gitea: Reihenfolge der Schritte, Webhook gelöscht, Nachtragen ohne Doppelte, Warnungen bei Gitea-Ausfall.
|
||||
- Sperre: zwei gleichzeitige Arbeitsblätter für denselben Commit ergeben eines.
|
||||
|
||||
Kundenportal, Ablauf: Das Portal läuft isoliert mit nachgebautem Appwrite und Gitea und wird über die neuen Schnittstellen von Anfang bis Ende geprüft.
|
||||
|
||||
Ticketsystem: Oberflächentest mit dem bestehenden esbuild- und Playwright-Aufbau:
|
||||
|
||||
- Beim Öffnen des Dialogs wird `import-repos` aufgerufen.
|
||||
- Der Zusatz „wird verschoben“ erscheint.
|
||||
- Die Zusammenfassung nach dem Zuweisen erscheint.
|
||||
- „Preview einschalten“ schlägt `doener-nation` vor und lässt den Vorschlag ändern.
|
||||
- „Pushes nachtragen“ funktioniert.
|
||||
- Der Subdomain-Vorschlag hat eigene Tests.
|
||||
|
||||
Lead-Automatik: Die Pfad-Funktionen werden mit alten Leads (ohne `repo_owner`) und neuen Leads geprüft. Dazu kommt ein Probelauf gegen ein Wegwerf-Repo in WEBklar.
|
||||
|
||||
Nach dem Livegang, nur mit ausdrücklichem OK: Ein Wegwerf-Repo wird in einem eigenen Konto angelegt und einem Test-Ticket zugewiesen. Geprüft wird, dass es nach WEBklar zieht, dass ein Push darin ankommt und dass daraus ein Arbeitsblatt wird. Danach wird alles wieder entfernt.
|
||||
|
||||
## 12. Reihenfolge, Livegang und Rückweg
|
||||
|
||||
1. **Phase 1, Gitea.** Solange WEBklar leer ist, ändert sich nichts. Prüfung: Organisation, Team und Webhook sind vorhanden, der Schlüssel antwortet auf `/api/v1/user`.
|
||||
2. **Phase 2, Kundenportal.** Push nach `main`, dann die neuen Einstellungen übernehmen. Prüfung: Start ohne Fehler, Preview-Abgleich, `import-repos` legt die fehlenden Einträge an.
|
||||
3. **Phase 3, Ticketsystem.** Push nach `test`. Prüfung: Die ausgelieferte Oberfläche enthält den neuen Dialog.
|
||||
4. **Phase 4, Umzug** der 6 Kundenprojekte nach 7.
|
||||
5. **Phase 5, Lead-Automatik** nach 8.
|
||||
|
||||
Pushes nach `main` und `test` erfolgen wie bisher erst nach deinem OK. Gitea- und Konfigurationsschritte führe ich aus, soweit die Rechteprüfung es zulässt. Sonst bekommst du die fertigen Befehle.
|
||||
|
||||
Rückweg:
|
||||
|
||||
- Phase 2: `GITEA_API_TOKEN=${GITEA_WEBHOOK_TOKEN}` wiederherstellen und `docker compose up -d --no-deps kundenbereich-server` ausführen.
|
||||
- Phase 4: Die Repos an ihre alten Besitzer zurück übertragen und die Einzel-Webhooks aus der Sicherung neu anlegen.
|
||||
- Phase 5: `config.env` aus der Sicherung zurückholen.
|
||||
- Die Organisation kann bleiben. Leer schadet sie nicht.
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Zieht die sechs Kundenprojekte nach "WEBklar" um: sichert vorher die Webhooks aller
|
||||
* betroffenen Repos in eine JSON-Datei, ruft dann je Repo `reconcileProject` (Task 6) auf -
|
||||
* das verschiebt das Repo und traegt liegen gebliebene Pushes nach - und stellt danach eine
|
||||
* fehlende `.webklar-preview.json` fuer gehostete Projekte wieder her.
|
||||
*
|
||||
* Laeuft spaeter INNERHALB des Portal-Containers (nicht auf dem Host): nutzt server/config.js
|
||||
* und die dort konfigurierten Appwrite-/Gitea-Zugaenge direkt.
|
||||
*
|
||||
* Usage (im Container): node scripts/gitea-organisation-migrate.mjs
|
||||
*/
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { config } from '../server/config.js'
|
||||
import { giteaFetch, splitRepo } from '../server/services/giteaClient.js'
|
||||
import { listDocuments, Query } from '../server/services/appwriteAdmin.js'
|
||||
import { reconcileProject } from '../server/services/projectAssignment.js'
|
||||
import { isHostedProject, ensureRepoPreviewConfig, getRepoInfo } from '../server/services/giteaAdmin.js'
|
||||
import { fetchPreviewConfig } from '../server/services/previewDeploy.js'
|
||||
|
||||
export const CUSTOMER_REPOS = [
|
||||
'knso/emmas-schatzkiste',
|
||||
'knso/Anelia-Wolf',
|
||||
'ANDJ/Unger-Bus-Unternehmen',
|
||||
'JUSN/D-ner-Nation',
|
||||
'knso/videogen',
|
||||
'knso/kfz',
|
||||
]
|
||||
|
||||
/**
|
||||
* Schreibt `hooks-<ISO>.json` mit den aktuellen Webhooks aller uebergebenen Repos
|
||||
* (`{id, url, events}` je Hook - `url` kommt aus `config.url`, echtes Gitea traegt das Ziel
|
||||
* NIRGENDS sonst). Ein Repo, dessen Hooks nicht gelesen werden koennen, bekommt eine leere
|
||||
* Liste - die Sicherung soll nie an einem einzelnen kaputten Repo scheitern.
|
||||
*/
|
||||
async function backupHooks(repoFullNames, backupDir, now) {
|
||||
const backup = {}
|
||||
for (const repoFullName of repoFullNames) {
|
||||
const [owner, repo] = splitRepo(repoFullName)
|
||||
let hooks = []
|
||||
try {
|
||||
const result = await giteaFetch(`/repos/${owner}/${repo}/hooks`)
|
||||
hooks = Array.isArray(result) ? result : []
|
||||
} catch (err) {
|
||||
console.error(`[gitea-organisation-migrate] Webhooks von ${repoFullName} konnten nicht gesichert werden:`, err.message)
|
||||
}
|
||||
backup[repoFullName] = hooks.map((h) => ({ id: h.id, url: h.config?.url, events: h.events }))
|
||||
}
|
||||
|
||||
await fs.mkdir(backupDir, { recursive: true })
|
||||
const fileName = `hooks-${now.toISOString().replace(/[:.]/g, '-')}.json`
|
||||
await fs.writeFile(path.join(backupDir, fileName), JSON.stringify(backup, null, 2))
|
||||
return fileName
|
||||
}
|
||||
|
||||
export async function migrateRepos({ repoFullNames = CUSTOMER_REPOS, backupDir, now = new Date() } = {}) {
|
||||
await backupHooks(repoFullNames, backupDir, now)
|
||||
|
||||
const results = []
|
||||
for (const repoFullName of repoFullNames) {
|
||||
const docs = await listDocuments(config.collections.websiteProjects, [
|
||||
Query.equal('repoFullName', repoFullName),
|
||||
Query.limit(1),
|
||||
])
|
||||
const doc = docs[0]
|
||||
if (!doc) {
|
||||
results.push({
|
||||
from: repoFullName,
|
||||
to: repoFullName,
|
||||
backfilled: 0,
|
||||
warnings: ['kein Projekt-Eintrag'],
|
||||
previewConfigWritten: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
let reconciled
|
||||
try {
|
||||
// Bewusst OHNE eigene Sperre aufgerufen - `reconcileProject` sperrt sich selbst
|
||||
// (Controller-Ruling): ein zweiter, gleichzeitiger Aufruf (z. B. aus dem Portal
|
||||
// waehrend der Migration) darf nicht doppelt umziehen.
|
||||
reconciled = await reconcileProject(doc.$id, { now })
|
||||
} catch (err) {
|
||||
results.push({
|
||||
from: repoFullName,
|
||||
to: repoFullName,
|
||||
backfilled: 0,
|
||||
warnings: [err.message],
|
||||
previewConfigWritten: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const to = reconciled.project.repoFullName
|
||||
let previewConfigWritten = false
|
||||
if (isHostedProject(reconciled.project)) {
|
||||
const branch = (await getRepoInfo(to))?.defaultBranch || 'main'
|
||||
const existingPreviewConfig = await fetchPreviewConfig(to, branch)
|
||||
if (!existingPreviewConfig) {
|
||||
await ensureRepoPreviewConfig(to, branch)
|
||||
previewConfigWritten = true
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
from: repoFullName,
|
||||
to,
|
||||
backfilled: reconciled.backfilled,
|
||||
warnings: reconciled.warnings,
|
||||
previewConfigWritten,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const results = await migrateRepos({ backupDir: '/app/upload-data/gitea-org' })
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((err) => {
|
||||
console.error(err.message)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Richtet die Gitea-Organisation "WEBklar" fuer den Umzug der Kundenprojekte ein: legt die
|
||||
* Organisation an (falls sie fehlt), fuegt die Mitglieder knso/jusn/andj zum Owners-Team hinzu
|
||||
* und legt den organisationsweiten Webhook auf das Portal an. Idempotent - ein zweiter Aufruf
|
||||
* aendert nichts mehr, wenn Organisation/Mitglieder/Hook bereits vorhanden sind.
|
||||
*
|
||||
* Haengt bewusst NICHT an server/config.js - laeuft spaeter auf dem HOST (nicht im Container),
|
||||
* wo dessen Appwrite-/Preview-Umgebungsvariablen nicht gesetzt sind. baseUrl/token/secret kommen
|
||||
* stattdessen als Parameter bzw. (im CLI-Teil) direkt aus der uebergebenen .env-Datei.
|
||||
*
|
||||
* Usage (auf dem Host): node scripts/gitea-organisation-setup.mjs --env /home/webklar/apps/.env
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
/**
|
||||
* Liest eine simple .env-Datei (KEY=VALUE je Zeile, `#`-Kommentare und Leerzeilen werden
|
||||
* ignoriert, umschliessende `"`/`'` werden entfernt) ein. Gibt NIE etwas auf der Konsole aus -
|
||||
* die Datei kann Secrets (GITEA_SYSTEM_TOKEN, GITEA_WEBHOOK_TOKEN) enthalten.
|
||||
*/
|
||||
export function readEnvFile(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf8')
|
||||
const result = {}
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
if (!line || line.startsWith('#')) continue
|
||||
const eq = line.indexOf('=')
|
||||
if (eq === -1) continue
|
||||
const key = line.slice(0, eq).trim()
|
||||
let value = line.slice(eq + 1).trim()
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Ruft `${baseUrl}/api/v1<path>` mit `Authorization: token <token>` auf. Ein 404 wird NICHT geworfen (Aufrufer prueft `status`), alles andere Nicht-2xx schon. */
|
||||
async function giteaJson(baseUrl, token, path, { method = 'GET', body } = {}) {
|
||||
const response = await fetch(`${baseUrl}/api/v1${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
const text = await response.text()
|
||||
let data = null
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
data = { message: text }
|
||||
}
|
||||
}
|
||||
if (!response.ok && response.status !== 404) {
|
||||
const error = new Error(data?.message || `Gitea ${response.status}`)
|
||||
error.status = response.status
|
||||
throw error
|
||||
}
|
||||
return { status: response.status, data }
|
||||
}
|
||||
|
||||
export async function setupOrganisation({
|
||||
baseUrl,
|
||||
token,
|
||||
org = 'WEBklar',
|
||||
members = ['knso', 'jusn', 'andj'],
|
||||
hookUrl = 'https://project.webklar.com/webhook/gitea',
|
||||
secret,
|
||||
} = {}) {
|
||||
// 1. Organisation anlegen, falls sie noch nicht existiert.
|
||||
let orgCreated = false
|
||||
const orgGet = await giteaJson(baseUrl, token, `/orgs/${org}`)
|
||||
if (orgGet.status === 404) {
|
||||
await giteaJson(baseUrl, token, '/orgs', {
|
||||
method: 'POST',
|
||||
body: { username: org, full_name: org, visibility: 'public' },
|
||||
})
|
||||
orgCreated = true
|
||||
}
|
||||
|
||||
// 2. Fehlende Mitglieder im Owners-Team ergaenzen.
|
||||
const membersAdded = []
|
||||
const teamsResult = await giteaJson(baseUrl, token, `/orgs/${org}/teams`)
|
||||
const owners = (teamsResult.data || []).find((t) => t.name === 'Owners')
|
||||
if (owners) {
|
||||
const existingResult = await giteaJson(baseUrl, token, `/teams/${owners.id}/members`)
|
||||
const existingLogins = new Set(
|
||||
(existingResult.data || []).map((m) => String(m.login || '').toLowerCase())
|
||||
)
|
||||
for (const member of members) {
|
||||
if (existingLogins.has(member.toLowerCase())) continue
|
||||
await giteaJson(baseUrl, token, `/teams/${owners.id}/members/${member}`, { method: 'PUT' })
|
||||
membersAdded.push(member)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Organisationsweiten Webhook aufs Portal anlegen, falls er noch fehlt.
|
||||
let hookCreated = false
|
||||
const hooksResult = await giteaJson(baseUrl, token, `/orgs/${org}/hooks`)
|
||||
const hasHook = (hooksResult.data || []).some((h) => h.config?.url === hookUrl)
|
||||
if (!hasHook) {
|
||||
await giteaJson(baseUrl, token, `/orgs/${org}/hooks`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'gitea',
|
||||
active: true,
|
||||
events: ['push'],
|
||||
branch_filter: '*',
|
||||
config: { url: hookUrl, content_type: 'json', secret },
|
||||
},
|
||||
})
|
||||
hookCreated = true
|
||||
}
|
||||
|
||||
return { orgCreated, membersAdded, hookCreated }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
const envIdx = args.indexOf('--env')
|
||||
const envPath = envIdx !== -1 ? args[envIdx + 1] : undefined
|
||||
if (!envPath) {
|
||||
throw new Error('--env <pfad-zur-.env> erforderlich')
|
||||
}
|
||||
const env = readEnvFile(envPath)
|
||||
const result = await setupOrganisation({
|
||||
baseUrl: 'https://git.webklar.com',
|
||||
token: env.GITEA_SYSTEM_TOKEN,
|
||||
secret: env.GITEA_WEBHOOK_TOKEN,
|
||||
})
|
||||
// Nur das Ergebnis - nie Token/Secret ausgeben.
|
||||
console.log(JSON.stringify(result))
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((err) => {
|
||||
console.error(err.message)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -235,6 +235,23 @@ export const config = {
|
||||
templateOwner: process.env.GITEA_TEMPLATE_OWNER || 'knso',
|
||||
templateRepo: process.env.GITEA_TEMPLATE_REPO || 'webklar-preview-template',
|
||||
defaultOwner: process.env.GITEA_DEFAULT_OWNER || 'knso',
|
||||
// Ziel-Organisation des Umzugs (siehe docs/superpowers/plans/2026-09-27-gitea-organisation-1-kundenportal.md)
|
||||
org: process.env.GITEA_ORG || 'WEBklar',
|
||||
// Login des System-Kontos, das automatische Commits/Pushes ausfuehrt (kein Arbeitsblatt dafuer)
|
||||
systemUser: process.env.GITEA_SYSTEM_USER || 'webklar-system',
|
||||
// Interne Repos, die trotz Zuweisung nicht nach GITEA_ORG umziehen
|
||||
keepInPlace: (
|
||||
process.env.GITEA_KEEP_IN_PLACE ||
|
||||
'ANDJ/Emailsorter,ANDJ/Steuerungstechnik,JUSN/tickte-system,JUSN/Webklar,knso/eship,knso/Webklar-Kundenbereich'
|
||||
)
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
// Beim Zuweisen eines Projekts werden Pushes der letzten so-vielen Tage nachgetragen
|
||||
backfillDays: Number(process.env.GITEA_BACKFILL_DAYS) || 30,
|
||||
// Zeitlimit fuer jeden Gitea-API-Aufruf (giteaFetch, fetchPreviewConfig); verhindert ein
|
||||
// haengendes Portal, wenn Gitea nicht antwortet (Controller-Ruling final-fix Punkt 1).
|
||||
timeoutMs: Number(process.env.GITEA_TIMEOUT_MS) || 30000,
|
||||
},
|
||||
|
||||
cors: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router } from 'express'
|
||||
import { syncReposToAppwrite, fetchRepoReadme } from '../../services/giteaAdmin.js'
|
||||
import { syncReposToAppwrite, fetchRepoReadme, importNewRepos } from '../../services/giteaAdmin.js'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -16,6 +16,18 @@ router.post('/sync-repos', async (_req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Neue Repos aus Gitea als minimale Projektdokumente anlegen (vom Projekt-Dialog im
|
||||
// Ticketsystem bei jedem Oeffnen aufgerufen).
|
||||
router.post('/import-repos', async (_req, res) => {
|
||||
try {
|
||||
const result = await importNewRepos()
|
||||
return res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
const status = err.status || 500
|
||||
return res.status(status).json({ error: err.message || 'Import fehlgeschlagen' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/repo-readme', async (req, res) => {
|
||||
const repoFullName = String(req.query.repoFullName || '').trim()
|
||||
if (!repoFullName) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
updateWebsiteProject,
|
||||
archiveRepo,
|
||||
} from '../../services/giteaAdmin.js'
|
||||
import { assignProject, reconcileProject } from '../../services/projectAssignment.js'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -67,4 +68,40 @@ router.post('/:id/unarchive', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Projekt einem Kunden/Ticket zuweisen -> stoesst Umzug nach GITEA_ORG + Nachtragen an
|
||||
router.post('/:id/assign', async (req, res) => {
|
||||
const { customerId, ticketId } = req.body || {}
|
||||
if (!customerId && !ticketId) {
|
||||
return res.status(400).json({ error: 'customerId oder ticketId erforderlich' })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await assignProject(req.params.id, {
|
||||
customerId: String(customerId || ''),
|
||||
ticketId: String(ticketId || ''),
|
||||
})
|
||||
return res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
if (err.status === 404) {
|
||||
return res.status(404).json({ error: 'Projekt nicht gefunden' })
|
||||
}
|
||||
const status = err.status || 500
|
||||
return res.status(status).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Umzug + Pushes eines bereits zugewiesenen Projekts erneut nachtragen
|
||||
router.post('/:id/pushes', async (req, res) => {
|
||||
try {
|
||||
const result = await reconcileProject(req.params.id)
|
||||
return res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
if (err.status === 404) {
|
||||
return res.status(404).json({ error: 'Projekt nicht gefunden' })
|
||||
}
|
||||
const status = err.status || 500
|
||||
return res.status(status).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { upsertWebsiteProjectByRepo } from '../../services/appwriteAdmin.js'
|
||||
import { upsertWebsiteProjectByRepo, getDocument } from '../../services/appwriteAdmin.js'
|
||||
import {
|
||||
fetchPreviewConfig,
|
||||
defaultPreviewConfig,
|
||||
@@ -12,6 +12,8 @@ import { ensureRepoPreviewConfig } from '../../services/giteaAdmin.js'
|
||||
import { createGitPushWorksheet } from '../../services/gitWorksheet.js'
|
||||
import { ensureProjectTicket } from '../../services/ticketAdmin.js'
|
||||
import { invalidateProjectCache } from '../../services/projectInsights.js'
|
||||
import { withLocks } from '../../services/keyedLock.js'
|
||||
import { repoLockKey, currentRepoFullName } from '../../services/repoMove.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -44,36 +46,45 @@ router.post('/gitea', async (req, res) => {
|
||||
const payload = req.body || {}
|
||||
const ref = payload.ref || ''
|
||||
const repo = payload.repository || {}
|
||||
const repoFullName = repo.full_name || ''
|
||||
const payloadName = repo.full_name || ''
|
||||
const commits = payload.commits || []
|
||||
const pusher = payload.pusher || {}
|
||||
const commitSha = payload.after || commits[commits.length - 1]?.id || ''
|
||||
|
||||
if (!repoFullName) {
|
||||
if (!payloadName) {
|
||||
return res.status(400).json({ error: 'repository.full_name fehlt' })
|
||||
}
|
||||
|
||||
const branch = ref.replace('refs/heads/', '') || 'main'
|
||||
|
||||
try {
|
||||
let previewConfigFile = await fetchPreviewConfig(repoFullName, branch)
|
||||
// Phase 1: aktuellen Repo-Namen aufloesen - wartet auf einen laufenden Umzug DESSELBEN
|
||||
// Repos (dieselbe Sperre wie moveRepoToOrg/importNewRepos, Controller-Ruling R17), damit
|
||||
// der Webhook nie mit einem veralteten Namen weiterlaeuft. Keine Sperre bleibt darueber
|
||||
// hinaus gehalten - ein Deploy kann lange dauern und darf sie nicht blockieren.
|
||||
const name = await withLocks([repoLockKey(payloadName)], () => currentRepoFullName(payloadName), {
|
||||
waitMs: 10000,
|
||||
})
|
||||
|
||||
// Phase 2: Preview-Config + Deploy wie bisher, mit `name` - ohne gehaltene Sperre.
|
||||
let previewConfigFile = await fetchPreviewConfig(name, branch)
|
||||
// Selbstheilung: Wurde .webklar-preview.json im Repo geloescht (z.B. beim
|
||||
// Leerraeumen/Ueberarbeiten der Seite), aber das Projekt ist in Appwrite als
|
||||
// gehostet bekannt, dann Config aus dem gespeicherten Stand wiederherstellen
|
||||
// (Datei ins Repo zurueckschreiben) und normal weiter-deployen.
|
||||
if (!previewConfigFile) {
|
||||
try {
|
||||
const restored = await ensureRepoPreviewConfig(repoFullName, branch)
|
||||
const restored = await ensureRepoPreviewConfig(name, branch)
|
||||
if (restored) previewConfigFile = restored
|
||||
} catch (err) {
|
||||
console.error('[webhook] preview-config-Wiederherstellung fehlgeschlagen:', err.message)
|
||||
}
|
||||
}
|
||||
const previewConfig = previewConfigFile || defaultPreviewConfig(repoFullName)
|
||||
const previewConfig = previewConfigFile || defaultPreviewConfig(name)
|
||||
const hasPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile)
|
||||
// Nur Projekte mit aktivierter Preview bekommen eine Subdomain zugewiesen -
|
||||
// Repo-only-Projekte (enabled:false, keine Subdomain) bleiben ohne Website.
|
||||
const subdomain = hasPreview ? previewConfig.subdomain || repoFullName.split('/').pop() : ''
|
||||
const subdomain = hasPreview ? previewConfig.subdomain || name.split('/').pop() : ''
|
||||
const previewUrl = subdomain ? buildPreviewUrl(subdomain) : ''
|
||||
|
||||
let deployResult = { deployed: false, skipped: true }
|
||||
@@ -81,63 +92,101 @@ router.post('/gitea', async (req, res) => {
|
||||
|
||||
if (hasPreview && previewConfig.enabled !== false && branch === configBranch) {
|
||||
try {
|
||||
deployResult = await runDeploy(repoFullName, branch, previewConfig)
|
||||
deployResult = await runDeploy(name, branch, previewConfig)
|
||||
} catch (deployErr) {
|
||||
console.error('[webhook] deploy failed:', deployErr.message)
|
||||
deployResult = { deployed: false, error: deployErr.message }
|
||||
}
|
||||
}
|
||||
|
||||
const projectPayload = {
|
||||
projectName: previewConfig.displayName || repo.name || subdomain,
|
||||
templateName: previewConfig.templateName || config.gitea.templateRepo,
|
||||
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
|
||||
giteaRepoName: repo.name || subdomain,
|
||||
repoFullName,
|
||||
}
|
||||
// Phase 3: Dokument unter derselben Repo-Sperre wie Import/Umzug anlegen/aktualisieren.
|
||||
// Namen dabei ERNEUT aufloesen - deckt einen Umzug ab, der waehrend des Deploys oben (ohne
|
||||
// gehaltene Sperre) passiert ist. `current` ab hier fuer alle weiteren Schritte.
|
||||
let current
|
||||
const project = await withLocks(
|
||||
[repoLockKey(name)],
|
||||
async () => {
|
||||
current = await currentRepoFullName(name)
|
||||
|
||||
if (subdomain) projectPayload.subdomain = subdomain
|
||||
if (previewUrl) projectPayload.previewUrl = previewUrl
|
||||
// Ein spaet zugestellter Webhook (z. B. nach einem Umzug) traegt noch den ALTEN
|
||||
// Repo-Namen in repo.html_url/repo.name - diese Payload-Felder duerfen das Dokument
|
||||
// dann nicht ueberschreiben, sonst zeigt es mit repoFullName=current trotzdem auf die
|
||||
// alte URL (Fix-Runde 1). Nur wenn `payloadName` noch zu `current` passt, gelten sie.
|
||||
const payloadNameStillCurrent = payloadName.toLowerCase() === current.toLowerCase()
|
||||
|
||||
if (deployResult.deployed) {
|
||||
projectPayload.status = 'deployed'
|
||||
projectPayload.provisioningStatus = 'ready'
|
||||
} else if (hasPreview) {
|
||||
projectPayload.status = 'pending'
|
||||
projectPayload.provisioningStatus = deployResult.error ? 'deploy_failed' : 'pending'
|
||||
} else {
|
||||
projectPayload.status = 'synced'
|
||||
}
|
||||
const projectPayload = {
|
||||
projectName: previewConfig.displayName || (payloadNameStillCurrent ? repo.name : '') || subdomain,
|
||||
templateName: previewConfig.templateName || config.gitea.templateRepo,
|
||||
giteaRepoUrl: payloadNameStillCurrent
|
||||
? repo.html_url || `${config.gitea.baseUrl}/${current}`
|
||||
: `${config.gitea.baseUrl}/${current}`,
|
||||
giteaRepoName: payloadNameStillCurrent ? repo.name || subdomain : current.split('/').pop(),
|
||||
repoFullName: current,
|
||||
}
|
||||
|
||||
const project = await upsertWebsiteProjectByRepo(repoFullName, projectPayload)
|
||||
invalidateProjectCache(repoFullName)
|
||||
if (subdomain) projectPayload.subdomain = subdomain
|
||||
if (previewUrl) projectPayload.previewUrl = previewUrl
|
||||
|
||||
if (deployResult.deployed) {
|
||||
projectPayload.status = 'deployed'
|
||||
projectPayload.provisioningStatus = 'ready'
|
||||
} else if (hasPreview) {
|
||||
projectPayload.status = 'pending'
|
||||
projectPayload.provisioningStatus = deployResult.error ? 'deploy_failed' : 'pending'
|
||||
} else {
|
||||
projectPayload.status = 'synced'
|
||||
}
|
||||
|
||||
return upsertWebsiteProjectByRepo(current, projectPayload)
|
||||
},
|
||||
{ waitMs: 10000 }
|
||||
)
|
||||
|
||||
// Phase 4
|
||||
invalidateProjectCache(current)
|
||||
|
||||
// Phase 5: nur bei einem echten Commit - Ticket + Arbeitsblatt unter der Projekt-Sperre
|
||||
// (derselben wie assignProject/reconcileProject), damit ein gleichzeitiges Zuweisen nicht
|
||||
// dieselbe ticketId-Aufloesung doppelt anstoesst.
|
||||
let gitWorksheet = null
|
||||
if (commitSha && commitSha !== '0000000000000000000000000000000000000000') {
|
||||
// Projekt ohne Ticket: automatisch ein Webpage-Ticket anlegen (nur mit Kunde)
|
||||
let ticketId = project.ticketId
|
||||
if (!ticketId) {
|
||||
try {
|
||||
ticketId = await ensureProjectTicket(project, { repoFullName })
|
||||
} catch (err) {
|
||||
console.error('[webhook] Auto-Ticket fehlgeschlagen:', err.message)
|
||||
}
|
||||
}
|
||||
if (ticketId) {
|
||||
gitWorksheet = await createGitPushWorksheet({
|
||||
ticketId,
|
||||
repoFullName,
|
||||
branch,
|
||||
commits,
|
||||
pusher,
|
||||
commitSha,
|
||||
})
|
||||
}
|
||||
gitWorksheet = await withLocks(
|
||||
['project:' + project.$id],
|
||||
async () => {
|
||||
let fresh = project
|
||||
try {
|
||||
fresh = await getDocument(config.collections.websiteProjects, project.$id)
|
||||
} catch {
|
||||
// Nachlesen fehlgeschlagen: mit dem Upsert-Ergebnis von oben weitermachen (R17).
|
||||
}
|
||||
|
||||
// Projekt ohne Ticket: automatisch ein Webpage-Ticket anlegen (nur mit Kunde)
|
||||
let ticketId = fresh.ticketId
|
||||
if (!ticketId) {
|
||||
try {
|
||||
ticketId = await ensureProjectTicket(fresh, { repoFullName: current })
|
||||
} catch (err) {
|
||||
console.error('[webhook] Auto-Ticket fehlgeschlagen:', err.message)
|
||||
}
|
||||
}
|
||||
if (!ticketId) return null
|
||||
|
||||
return createGitPushWorksheet({
|
||||
ticketId,
|
||||
repoFullName: current,
|
||||
branch,
|
||||
commits,
|
||||
pusher,
|
||||
commitSha,
|
||||
})
|
||||
},
|
||||
{ waitMs: 10000 }
|
||||
)
|
||||
}
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
repoFullName,
|
||||
repoFullName: current,
|
||||
subdomain,
|
||||
previewUrl,
|
||||
deploy: deployResult,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Datum und Uhrzeit in der Berliner Zeitzone (beruecksichtigt MESZ/MEZ automatisch),
|
||||
* fuer Anzeige und Dateinamen. date: 'tt.mm.jjjj', time: 'hhmm' (24h, '0030' statt '2430').
|
||||
*/
|
||||
export function berlinDateTime(date = new Date()) {
|
||||
const parts = new 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)
|
||||
|
||||
const value = (type) => parts.find((part) => part.type === type)?.value
|
||||
|
||||
return {
|
||||
date: `${value('day')}.${value('month')}.${value('year')}`,
|
||||
time: `${value('hour')}${value('minute')}`,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { config } from '../config.js'
|
||||
import { berlinDateTime } from './berlinTime.js'
|
||||
import { withLocks } from './keyedLock.js'
|
||||
import {
|
||||
listDocuments,
|
||||
getDocument,
|
||||
@@ -11,17 +13,10 @@ const WSID_START = 100000
|
||||
// worksheets.details ist auf 4000 Zeichen begrenzt
|
||||
const DETAILS_MAX_LENGTH = 3900
|
||||
|
||||
function formatToday() {
|
||||
// dd.mm.yyyy - gleiches Format wie CreateWorksheetModal der Ticketsystem-SPA
|
||||
const d = new Date()
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
return `${day}.${m}.${d.getFullYear()}`
|
||||
}
|
||||
|
||||
function formatTimeNow() {
|
||||
const d = new Date()
|
||||
return `${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}`
|
||||
/** Pusht das System-Konto selbst (automatische Commits) - dafuer kein Arbeitsblatt. */
|
||||
export function isSystemPusher(pusher) {
|
||||
const name = pusher?.username || pusher?.login || ''
|
||||
return name.toLowerCase() === config.gitea.systemUser.toLowerCase()
|
||||
}
|
||||
|
||||
async function generateNextWsid() {
|
||||
@@ -72,9 +67,14 @@ export async function createGitPushWorksheet({
|
||||
commits = [],
|
||||
pusher,
|
||||
commitSha,
|
||||
pushedAt = new Date(),
|
||||
}) {
|
||||
if (!ticketId) return null
|
||||
|
||||
if (isSystemPusher(pusher)) {
|
||||
return { skipped: true, reason: 'system_pusher' }
|
||||
}
|
||||
|
||||
let workorder
|
||||
try {
|
||||
workorder = await getDocument(config.collections.workorders, ticketId)
|
||||
@@ -86,51 +86,57 @@ export async function createGitPushWorksheet({
|
||||
const woid = workorder.woid
|
||||
if (!woid) return null
|
||||
|
||||
if (await gitWorksheetExists(woid, commitSha)) {
|
||||
return { skipped: true, reason: 'duplicate_commit' }
|
||||
}
|
||||
// Gesperrt je Ticket+Commit, damit zwei gleichzeitige Zustellungen desselben Webhooks
|
||||
// (Gitea liefert bei Bedarf erneut zu) nicht zweimal denselben Duplikat-Check bestehen
|
||||
// und zwei Arbeitsblaetter anlegen.
|
||||
const lockKey = `ws:${woid}:${(commitSha || '').slice(0, 7)}`
|
||||
return withLocks([lockKey], async () => {
|
||||
if (await gitWorksheetExists(woid, commitSha)) {
|
||||
return { skipped: true, reason: 'duplicate_commit' }
|
||||
}
|
||||
|
||||
const wsid = await generateNextWsid()
|
||||
const pusherName = pusher?.full_name || pusher?.username || pusher?.login || 'Gitea'
|
||||
const latestCommit = commits[commits.length - 1] || commits[0]
|
||||
let details = [
|
||||
`Git Push auf \`${repoFullName}\` (Branch: ${branch})`,
|
||||
`Commit: ${commitSha?.slice(0, 7) || latestCommit?.id?.slice(0, 7) || '?'}`,
|
||||
'',
|
||||
formatCommitLines(commits, repoFullName),
|
||||
].join('\n')
|
||||
if (details.length > DETAILS_MAX_LENGTH) {
|
||||
details = `${details.slice(0, DETAILS_MAX_LENGTH)}\n… (gekuerzt)`
|
||||
}
|
||||
const wsid = await generateNextWsid()
|
||||
const pusherName = pusher?.full_name || pusher?.username || pusher?.login || 'Gitea'
|
||||
const latestCommit = commits[commits.length - 1] || commits[0]
|
||||
let details = [
|
||||
`Git Push auf \`${repoFullName}\` (Branch: ${branch})`,
|
||||
`Commit: ${commitSha?.slice(0, 7) || latestCommit?.id?.slice(0, 7) || '?'}`,
|
||||
'',
|
||||
formatCommitLines(commits, repoFullName),
|
||||
].join('\n')
|
||||
if (details.length > DETAILS_MAX_LENGTH) {
|
||||
details = `${details.slice(0, DETAILS_MAX_LENGTH)}\n… (gekuerzt)`
|
||||
}
|
||||
|
||||
// Endzeit = Push-Zeitpunkt. Startzeit bleibt leer und wird im Ticketsystem
|
||||
// nachgetragen ("Zeit nachtragen") - erst dann zaehlt die Arbeitszeit.
|
||||
const today = formatToday()
|
||||
const worksheet = await createDocument(
|
||||
config.collections.worksheets,
|
||||
{
|
||||
wsid,
|
||||
woid: String(woid),
|
||||
workorderId: ticketId,
|
||||
employeeId: 'gitea',
|
||||
employeeName: pusherName,
|
||||
employeeShort: 'GIT',
|
||||
serviceType: 'GIT',
|
||||
oldStatus: workorder.status || '',
|
||||
newStatus: workorder.status || '',
|
||||
oldResponseLevel: workorder.response || '',
|
||||
newResponseLevel: workorder.response || '',
|
||||
totalTime: 0,
|
||||
startDate: today,
|
||||
startTime: '',
|
||||
endDate: today,
|
||||
endTime: formatTimeNow(),
|
||||
details,
|
||||
isComment: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
ID.unique()
|
||||
)
|
||||
// Endzeit = Push-Zeitpunkt in deutscher Zeit (MESZ/MEZ). Startzeit bleibt leer und wird
|
||||
// im Ticketsystem nachgetragen ("Zeit nachtragen") - erst dann zaehlt die Arbeitszeit.
|
||||
const { date, time } = berlinDateTime(pushedAt)
|
||||
const worksheet = await createDocument(
|
||||
config.collections.worksheets,
|
||||
{
|
||||
wsid,
|
||||
woid: String(woid),
|
||||
workorderId: ticketId,
|
||||
employeeId: 'gitea',
|
||||
employeeName: pusherName,
|
||||
employeeShort: 'GIT',
|
||||
serviceType: 'GIT',
|
||||
oldStatus: workorder.status || '',
|
||||
newStatus: workorder.status || '',
|
||||
oldResponseLevel: workorder.response || '',
|
||||
newResponseLevel: workorder.response || '',
|
||||
totalTime: 0,
|
||||
startDate: date,
|
||||
startTime: '',
|
||||
endDate: date,
|
||||
endTime: time,
|
||||
details,
|
||||
isComment: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
ID.unique()
|
||||
)
|
||||
|
||||
return { created: true, wsid, worksheetId: worksheet.$id }
|
||||
return { created: true, wsid, worksheetId: worksheet.$id }
|
||||
})
|
||||
}
|
||||
@@ -13,53 +13,15 @@ import {
|
||||
syncPreviewTls,
|
||||
fetchPreviewConfig,
|
||||
} from './previewDeploy.js'
|
||||
import { giteaFetch } from './giteaClient.js'
|
||||
import { slugify } from './slug.js'
|
||||
import { withLocks } from './keyedLock.js'
|
||||
import { repoLockKey } from './repoMove.js'
|
||||
|
||||
function giteaHeaders() {
|
||||
if (!config.gitea.apiToken) {
|
||||
throw new Error('GITEA_API_TOKEN fehlt in .env')
|
||||
}
|
||||
return {
|
||||
Authorization: `token ${config.gitea.apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
async function giteaFetch(path, options = {}) {
|
||||
const response = await fetch(`${config.gitea.baseUrl}/api/v1${path}`, {
|
||||
...options,
|
||||
headers: { ...giteaHeaders(), ...options.headers },
|
||||
})
|
||||
|
||||
const text = await response.text()
|
||||
let data = null
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
data = { message: text }
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let message = data?.message || `Gitea ${response.status}`
|
||||
if (response.status === 409 && /same name already exists/i.test(message)) {
|
||||
message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen w�hlen.'
|
||||
}
|
||||
const error = new Error(message)
|
||||
error.status = response.status
|
||||
throw error
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 100)
|
||||
}
|
||||
// Sicherheitsnetz wie bei listRecentPushes (siehe pushHistory.js, Controller-Ruling R8): sollte
|
||||
// Gitea "page" ignorieren und immer dieselbe volle Seite liefern, blaettert die Schleife nicht
|
||||
// endlos weiter (40 x 50 = 2000 Repos).
|
||||
const MAX_REPO_PAGES = 40
|
||||
|
||||
export async function listAllAccessibleRepos() {
|
||||
const repos = []
|
||||
@@ -72,40 +34,67 @@ export async function listAllAccessibleRepos() {
|
||||
if (data.length === 0) break
|
||||
repos.push(...data)
|
||||
if (data.length < limit) break
|
||||
if (page >= MAX_REPO_PAGES) {
|
||||
console.warn(
|
||||
`[giteaAdmin] mehr als ${MAX_REPO_PAGES} Seiten Repos – weitere Repos werden nicht gelistet`
|
||||
)
|
||||
break
|
||||
}
|
||||
page += 1
|
||||
}
|
||||
|
||||
return repos
|
||||
}
|
||||
|
||||
async function resolveSubdomainForSync(repo, existingDoc) {
|
||||
if (existingDoc?.subdomain) return existingDoc.subdomain
|
||||
/**
|
||||
* Findet Repos, die in Gitea liegen, aber noch kein Appwrite-Projektdokument haben, und legt fuer
|
||||
* jedes nicht archivierte ein minimales Dokument an (status "synced", ohne Hosting/Subdomain) -
|
||||
* ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Wird vom Projekt-Dialog bei jedem
|
||||
* Oeffnen aufgerufen, laeuft also oft: die aeussere `withLocks(['import'])`-Sperre serialisiert
|
||||
* parallele Laeufe komplett (ein zweiter Aufruf sieht danach garantiert den Stand des ersten), die
|
||||
* innere `withLocks([repoLockKey(...)])` je neuem Repo schuetzt zusaetzlich gegen andere
|
||||
* gleichzeitige Schreiber desselben Projektdokuments (z. B. Umzug/Zuweisung).
|
||||
*/
|
||||
export async function importNewRepos() {
|
||||
return withLocks(['import'], async () => {
|
||||
const [repos, docs] = await Promise.all([
|
||||
listAllAccessibleRepos(),
|
||||
listDocuments(config.collections.websiteProjects, [Query.limit(5000)]),
|
||||
])
|
||||
|
||||
const candidate = slugify(repo.name) || slugify(repo.full_name?.split('/').pop())
|
||||
if (!candidate) return ''
|
||||
// Ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Controller-Ruling).
|
||||
const existingNames = new Set(
|
||||
docs.map((d) => String(d.repoFullName || '').toLowerCase()).filter(Boolean)
|
||||
)
|
||||
|
||||
const conflict = await listDocuments(config.collections.websiteProjects, [
|
||||
Query.equal('subdomain', candidate),
|
||||
Query.limit(1),
|
||||
])
|
||||
let created = 0
|
||||
for (const repo of repos) {
|
||||
const fullName = repo.full_name
|
||||
if (!fullName || repo.archived) continue
|
||||
if (existingNames.has(fullName.toLowerCase())) continue
|
||||
|
||||
if (!conflict[0] || conflict[0].repoFullName === repo.full_name) {
|
||||
return candidate
|
||||
}
|
||||
await withLocks([repoLockKey(fullName)], async () => {
|
||||
const recheck = await listDocuments(config.collections.websiteProjects, [
|
||||
Query.equal('repoFullName', fullName),
|
||||
Query.limit(1),
|
||||
])
|
||||
if (recheck[0]) return
|
||||
|
||||
const ownerSlug = slugify(repo.full_name?.replace('/', '-'))
|
||||
if (!ownerSlug) return ''
|
||||
await upsertWebsiteProjectByRepo(fullName, {
|
||||
projectName: repo.description || repo.name || fullName.split('/').pop(),
|
||||
giteaRepoUrl: repo.html_url,
|
||||
giteaRepoName: repo.name,
|
||||
repoFullName: fullName,
|
||||
archived: false,
|
||||
status: 'synced',
|
||||
giteaSyncedAt: new Date().toISOString(),
|
||||
})
|
||||
created += 1
|
||||
})
|
||||
}
|
||||
|
||||
const ownerConflict = await listDocuments(config.collections.websiteProjects, [
|
||||
Query.equal('subdomain', ownerSlug),
|
||||
Query.limit(1),
|
||||
])
|
||||
|
||||
if (!ownerConflict[0] || ownerConflict[0].repoFullName === repo.full_name) {
|
||||
return ownerSlug
|
||||
}
|
||||
|
||||
return ''
|
||||
return { created }
|
||||
})
|
||||
}
|
||||
|
||||
export async function syncReposToAppwrite() {
|
||||
@@ -122,10 +111,6 @@ export async function syncReposToAppwrite() {
|
||||
Query.limit(1),
|
||||
])
|
||||
|
||||
const branch = repo.default_branch || 'main'
|
||||
const subdomain = await resolveSubdomainForSync(repo, existing[0])
|
||||
const previewUrl = subdomain ? buildPreviewUrl(subdomain) : ''
|
||||
|
||||
const payload = {
|
||||
projectName: repo.description || repo.name || repoFullName.split('/').pop(),
|
||||
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
|
||||
@@ -135,9 +120,6 @@ export async function syncReposToAppwrite() {
|
||||
archived: Boolean(repo.archived),
|
||||
}
|
||||
|
||||
if (subdomain) payload.subdomain = subdomain
|
||||
if (previewUrl) payload.previewUrl = previewUrl
|
||||
|
||||
if (!existing[0]) {
|
||||
payload.status = 'synced'
|
||||
created += 1
|
||||
@@ -231,6 +213,20 @@ async function updatePreviewConfigFile(repoFullName, previewConfig, branch = 'ma
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* true, wenn zu diesem Projekt wirklich ein Hosting gehoert (Preview/Website/Vorlage, oder ein
|
||||
* bereits deployter bzw. anstehender Stand) - im Unterschied zu einem nur importierten/
|
||||
* abgeglichenen Repo ohne eigenes Hosting (z. B. status "synced" ohne Website). Bestimmt u. a.,
|
||||
* ob die Selbstheilung (ensureRepoPreviewConfig) fuer dieses Projekt ueberhaupt aktiv werden darf -
|
||||
* sonst wuerde mit dem organisationsweiten Webhook jeder Push in ein bloss abgeglichenes Repo eine
|
||||
* Preview anlegen.
|
||||
*/
|
||||
export function isHostedProject(doc) {
|
||||
if (['preview', 'website', 'template'].includes(doc?.projectType)) return true
|
||||
if (['deployed', 'pending'].includes(doc?.status)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Selbstheilung fuer eine geloeschte/fehlende .webklar-preview.json:
|
||||
* Rekonstruiert die Hosting-Config aus dem in Appwrite gespeicherten
|
||||
@@ -246,11 +242,11 @@ export async function ensureRepoPreviewConfig(repoFullName, branch = 'main') {
|
||||
])
|
||||
const doc = docs[0]
|
||||
if (!doc || doc.archived) return null
|
||||
if (!isHostedProject(doc)) return null
|
||||
|
||||
const type = deriveProjectType(doc)
|
||||
const hasWebsite = type === 'preview' || type === 'website' || type === 'template'
|
||||
const subdomain = doc.subdomain || ''
|
||||
if (!hasWebsite || !subdomain) return null
|
||||
if (!subdomain) return null
|
||||
|
||||
const previewConfig = {
|
||||
enabled: true,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { config } from '../config.js'
|
||||
|
||||
/** Zeitlimit fuer jeden Gitea-API-Aufruf, in ms (ueberschreibbar via `GITEA_TIMEOUT_MS`, s. config.js). */
|
||||
const GITEA_TIMEOUT_MS = config.gitea.timeoutMs
|
||||
|
||||
/**
|
||||
* Baut die Authorization-/Content-Type-Header fuer einen Gitea-API-Aufruf.
|
||||
* Wirft, wenn GITEA_API_TOKEN nicht gesetzt ist.
|
||||
*/
|
||||
function giteaHeaders() {
|
||||
if (!config.gitea.apiToken) {
|
||||
throw new Error('GITEA_API_TOKEN fehlt in .env')
|
||||
}
|
||||
return {
|
||||
Authorization: `token ${config.gitea.apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft `${config.gitea.baseUrl}/api/v1<path>` auf und liefert die geparste JSON-Antwort
|
||||
* (oder null ohne Body). Eine Nicht-2xx-Antwort wirft eine Error mit .status; ein 409
|
||||
* "same name already exists" wird in die deutsche Meldung uebersetzt (Subdomain-/Repo-Name).
|
||||
* Bricht nach `GITEA_TIMEOUT_MS` ab (Standard 30 s); der Abbruch wirft eine Error OHNE .status,
|
||||
* damit repoMove/projectAssignment ihn wie "Gitea nicht erreichbar" einordnen.
|
||||
*/
|
||||
export async function giteaFetch(path, options = {}) {
|
||||
let response
|
||||
try {
|
||||
response = await fetch(`${config.gitea.baseUrl}/api/v1${path}`, {
|
||||
...options,
|
||||
headers: { ...giteaHeaders(), ...options.headers },
|
||||
signal: options.signal || AbortSignal.timeout(GITEA_TIMEOUT_MS),
|
||||
})
|
||||
} catch (err) {
|
||||
if (err?.name === 'TimeoutError' || err?.name === 'AbortError') {
|
||||
throw new Error(`Gitea antwortet nicht (Zeitlimit ${Math.round(GITEA_TIMEOUT_MS / 1000)} s)`)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
let data = null
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
data = { message: text }
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let message = data?.message || `Gitea ${response.status}`
|
||||
if (response.status === 409 && /same name already exists/i.test(message)) {
|
||||
message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen w�hlen.'
|
||||
}
|
||||
const error = new Error(message)
|
||||
error.status = response.status
|
||||
throw error
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt "owner/repo" in [owner, repo]. Reines String-Splitting ohne Validierung -
|
||||
* fuer nicht "owner/repo"-foermige Eingaben ist das Ergebnis entsprechend kurz/lang.
|
||||
*/
|
||||
export function splitRepo(fullName) {
|
||||
return String(fullName || '').split('/')
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Sperren je Schluessel (z. B. Kundennummer, Ticket-Id), damit parallele Anfragen fuer
|
||||
* dieselbe Ressource nacheinander laufen, waehrend Anfragen zu anderen Schluesseln parallel
|
||||
* bleiben. Ein Aufruf kann mehrere Schluessel auf einmal sperren (alle muessen frei werden).
|
||||
* waitMs begrenzt nur, wie lange DIESER Aufruf auf die aktuellen Inhaber wartet, bevor er
|
||||
* trotzdem losläuft (Default: unbegrenzt) - spaetere Aufrufe warten trotzdem auf die gesamte
|
||||
* Kette bis hierhin, einschliesslich eines Inhabers, den dieser Aufruf per waitMs uebersprungen
|
||||
* hat.
|
||||
*
|
||||
* Umsetzung: je Schluessel eine Promise-Kette (das "tail") in einer modulweiten Map. Der
|
||||
* gespeicherte Wert ist immer "die gesamte Kette bis einschliesslich dieses Aufrufs ist fertig"
|
||||
* (Promise.allSettled der vorherigen tails + der eigene Abschluss) - nie nur der eigene
|
||||
* Abschluss allein. Sonst wuerde ein Aufruf, der per waitMs vorzeitig losgelaufen ist, beim
|
||||
* Aufraeumen die Spur eines noch laufenden, unbegrenzt wartenden frueheren Inhabers loeschen,
|
||||
* und ein dritter Aufruf wuerde faelschlich gar nicht mehr warten.
|
||||
*/
|
||||
const tailsByKey = new Map()
|
||||
|
||||
// setTimeout(fn, Infinity) wuerde Node zu 1 ms clampen (samt Warnung) - hier bewusst eine
|
||||
// Promise ohne echten Timer, damit "unbegrenzt warten" auch wirklich unbegrenzt ist und nichts
|
||||
// aufgeraeumt werden muss. Bei endlichem waitMs wird der Timer ueber cancel() wieder entfernt,
|
||||
// sobald das Rennen entschieden ist - sonst haelt ein liegen gebliebener Timer z. B.
|
||||
// `node --test` unnoetig am Leben.
|
||||
function waitMsTimeout(waitMs) {
|
||||
if (waitMs === Infinity) return { promise: new Promise(() => {}), cancel: () => {} }
|
||||
let timer
|
||||
const promise = new Promise((resolve) => {
|
||||
timer = setTimeout(resolve, waitMs)
|
||||
})
|
||||
return { promise, cancel: () => clearTimeout(timer) }
|
||||
}
|
||||
|
||||
export async function withLocks(keys, fn, { waitMs = Infinity } = {}) {
|
||||
const previousTails = keys.map((key) => tailsByKey.get(key)).filter(Boolean)
|
||||
|
||||
let resolveOwnDone
|
||||
const ownDone = new Promise((resolve) => {
|
||||
resolveOwnDone = resolve
|
||||
})
|
||||
// Gespeichert wird die gesamte Kette (siehe Kommentar oben), nicht nur ownDone.
|
||||
const tail = Promise.allSettled([...previousTails, ownDone])
|
||||
for (const key of keys) tailsByKey.set(key, tail)
|
||||
tail.then(() => {
|
||||
for (const key of keys) {
|
||||
if (tailsByKey.get(key) === tail) tailsByKey.delete(key)
|
||||
}
|
||||
})
|
||||
|
||||
const { promise: timeout, cancel: cancelTimeout } = waitMsTimeout(waitMs)
|
||||
try {
|
||||
await Promise.race([Promise.allSettled(previousTails), timeout])
|
||||
} finally {
|
||||
cancelTimeout()
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
resolveOwnDone()
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,9 @@ export async function fetchPreviewConfig(repoFullName, branch) {
|
||||
const url = `${config.gitea.baseUrl}/api/v1/repos/${owner}/${repo}/contents/.webklar-preview.json?ref=${encodeURIComponent(branch)}`
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `token ${config.gitea.apiToken}` },
|
||||
// Gleiches Zeitlimit wie giteaFetch (server/services/giteaClient.js) - sonst haengt ein
|
||||
// Preview-Deploy unbegrenzt, wenn Gitea nicht antwortet.
|
||||
signal: AbortSignal.timeout(config.gitea.timeoutMs),
|
||||
})
|
||||
|
||||
if (!response.ok) return null
|
||||
@@ -307,6 +310,11 @@ export function buildPreviewUrl(subdomain) {
|
||||
return `https://${subdomain}.${config.preview.baseHost}`
|
||||
}
|
||||
|
||||
/** Arbeitsverzeichnis fuer den flachen Klon eines Repos beim Preview-Deploy. */
|
||||
export function previewWorkDir(repoFullName) {
|
||||
return path.join(process.cwd(), 'preview-data', repoFullName.replace(/\//g, '_'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt pro deployter Preview-Subdomain einen Traefik-Router mit Host()-Regel
|
||||
* nach preview-hosts.yml, damit Let's Encrypt (HTTP-01) gueltige Zertifikate
|
||||
@@ -421,7 +429,7 @@ export async function runDeploy(repoFullName, branch, previewConfig) {
|
||||
|
||||
const subdomain = previewConfig.subdomain || repoFullName.split('/').pop()
|
||||
const cloneUrl = `${config.gitea.baseUrl}/${repoFullName}.git`
|
||||
const workDir = path.join(process.cwd(), 'preview-data', repoFullName.replace(/\//g, '_'))
|
||||
const workDir = previewWorkDir(repoFullName)
|
||||
const targetDir = path.join(config.preview.deployRoot, subdomain)
|
||||
|
||||
await cloneRepo(cloneUrl, workDir, branch, config.gitea.apiToken)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { config } from '../config.js'
|
||||
import { moveRepoToOrg, WARN } from './repoMove.js'
|
||||
import { splitRepo } from './giteaClient.js'
|
||||
import { listRecentPushes } from './pushHistory.js'
|
||||
import { createGitPushWorksheet, isSystemPusher } from './gitWorksheet.js'
|
||||
import { withLocks } from './keyedLock.js'
|
||||
import { ensureProjectTicket } from './ticketAdmin.js'
|
||||
import { getDocument, updateDocument } from './appwriteAdmin.js'
|
||||
|
||||
/**
|
||||
* Warnungen dieses Moduls, woertlich (Controller-Ruling R14, Fix-Runde 1) - Anfuehrungszeichen
|
||||
* wie die bestehenden Warnungen in repoMove.js (‚...‘ mit U+201A/U+2018).
|
||||
*/
|
||||
const WARN_BACKFILL_FAILED = (reason) =>
|
||||
`Pushes konnten nicht nachgetragen werden (${reason}). Bitte später ‚Pushes nachtragen‘ klicken.`
|
||||
const WARN_RECONCILE_FAILED = (reason) =>
|
||||
`Zuweisung gespeichert, aber Umzug und Nachtragen sind fehlgeschlagen (${reason}). Bitte später ‚Pushes nachtragen‘ klicken.`
|
||||
|
||||
/**
|
||||
* Commit-Nachrichten (erste Zeile), die frueher das System mit Kensos Schluessel geschrieben hat
|
||||
* (Preview-Config, automatisch erstellte Preview-Seite) - siehe Plan, Abschnitt "Global
|
||||
* Constraints". Beim Nachtragen zaehlt ein Push nicht, wenn die erste Zeile JEDER seiner
|
||||
* Commit-Nachrichten hier steht - unabhaengig vom Pusher, denn diese alten Commits tragen oft
|
||||
* "knso" als Pusher, nicht das heutige System-Konto (siehe `isSystemPusher`, das dafuer allein
|
||||
* NICHT greift).
|
||||
*/
|
||||
export const SYSTEM_COMMIT_MESSAGES = ['Update preview config from ticket system', 'Automatisch erstellte Preview-Seite']
|
||||
|
||||
function firstLine(message) {
|
||||
return String(message || '').split('\n')[0]
|
||||
}
|
||||
|
||||
/** true, wenn jede Commit-Nachricht des Pushes (erste Zeile) ein alter System-Commit ist. */
|
||||
function isOldSystemCommitPush(push) {
|
||||
const commits = push.commits || []
|
||||
if (commits.length === 0) return false
|
||||
return commits.every((commit) => SYSTEM_COMMIT_MESSAGES.includes(firstLine(commit.message)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Traegt die Pushes der letzten `GITEA_BACKFILL_DAYS` Tage eines Repos als Git-Arbeitsblaetter
|
||||
* nach (fuer neu zugewiesene Projekte, deren bisherige Pushes noch nicht abgerechnet sind).
|
||||
* Ueberspringt Systempushes: das aktuelle System-Konto (`isSystemPusher`) und alte, mit Kensos
|
||||
* Schluessel gemachte System-Commits (`SYSTEM_COMMIT_MESSAGES`). Legt bei Bedarf automatisch ein
|
||||
* Ticket an - aber erst, nachdem feststeht, dass tatsaechlich etwas nachzutragen ist, damit ein
|
||||
* Projekt ohne (relevante) Pushes kein Ticket aufgedraengt bekommt. Wirft nie wegen eines
|
||||
* Gitea-Problems (siehe `reconcileProject`, das sich - wie `moveRepoToOrg` - darauf verlaesst,
|
||||
* dass eine Zuweisung in jedem Fall bestehen bleibt).
|
||||
*/
|
||||
export async function backfillPushes(project, { now = new Date() } = {}) {
|
||||
let pushes
|
||||
try {
|
||||
pushes = await listRecentPushes(project.repoFullName, { now })
|
||||
} catch (err) {
|
||||
return { backfilled: 0, warnings: [WARN.giteaDown(err.message)], ticketId: project.ticketId || '' }
|
||||
}
|
||||
|
||||
const relevant = pushes.filter((push) => !isSystemPusher(push.pusher) && !isOldSystemCommitPush(push))
|
||||
if (relevant.length === 0) return { backfilled: 0, warnings: [], ticketId: project.ticketId || '' }
|
||||
|
||||
// `ensureProjectTicket` und die Arbeitsblatt-Schleife sind hier bewusst in EINEM try/catch
|
||||
// (Controller-Ruling R14): schlaegt eine der beiden Appwrite-Schreiboperationen fehl (z. B.
|
||||
// Ausfall waehrend POST workorders/worksheets), bleibt die Zuweisung trotzdem bestehen - siehe
|
||||
// `assignProject`, das sich darauf verlaesst, dass `backfillPushes` nie wirft.
|
||||
let ticketId = project.ticketId || ''
|
||||
let backfilled = 0
|
||||
try {
|
||||
ticketId = ticketId || (await ensureProjectTicket(project, { repoFullName: project.repoFullName }))
|
||||
if (!ticketId) return { backfilled: 0, warnings: [], ticketId: '' }
|
||||
|
||||
// Aelteste zuerst (siehe `listRecentPushes`), damit die Arbeitsblaetter in derselben
|
||||
// Reihenfolge entstehen, in der die Pushes tatsaechlich passiert sind.
|
||||
for (const push of relevant) {
|
||||
const result = await createGitPushWorksheet({ ticketId, repoFullName: project.repoFullName, ...push })
|
||||
if (result?.created) backfilled += 1
|
||||
}
|
||||
return { backfilled, warnings: [], ticketId }
|
||||
} catch (err) {
|
||||
return { backfilled, warnings: [WARN_BACKFILL_FAILED(err.message)], ticketId }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kern von `reconcileProject`, OHNE selbst die Sperre zu nehmen - fuer `assignProject`, das
|
||||
* seine eigene Lese-Pruef-Schreib-Folge und diesen Kern unter DERSELBEN Sperre laufen laesst
|
||||
* (Controller-Ruling R15), statt die Sperre zweimal zu nehmen. `reconcileProject` (unten) bleibt
|
||||
* der oeffentliche, selbst sperrende Einstieg (z. B. fuer die Migration, Task 9).
|
||||
*
|
||||
* Liest das Projekt, verschiebt sein Repo bei Bedarf nach `GITEA_ORG` und traegt danach dessen
|
||||
* Pushes nach. Wirft nur noch, wenn schon das ERSTE Lesen des Projekts fehlschlaegt (dann gibt es
|
||||
* nichts Sinnvolles zu verschieben/nachzutragen) - `assignProject` faengt das ab (R14).
|
||||
*/
|
||||
async function reconcileProjectLocked(projectId, { now } = {}) {
|
||||
let project = await getDocument(config.collections.websiteProjects, projectId)
|
||||
const { moved, warnings: moveWarnings } = await moveRepoToOrg(project)
|
||||
|
||||
try {
|
||||
// Immer neu lesen, nicht nur wenn `moved` gesetzt ist: der Selbstheilungs-Zweig in
|
||||
// `moveRepoToOrg` (von Hand verschobenes/umbenanntes Repo) kann das Dokument umbenennen und
|
||||
// dabei trotzdem `moved: null` liefern (Controller-Ruling R13).
|
||||
project = await getDocument(config.collections.websiteProjects, projectId)
|
||||
} catch {
|
||||
// R14: Schlaegt das Nachlesen fehl, mit dem VOR dem Umzug gelesenen Dokument weitermachen -
|
||||
// ist tatsaechlich etwas verschoben worden, von Hand dessen `repoFullName`/`giteaRepoUrl`/
|
||||
// `giteaRepoName` nachziehen (siehe `updateProjectDocument` in repoMove.js). Keine zusaetzliche
|
||||
// Warnung - `moveWarnings` deckt einen etwaigen Gitea-Ausfall bereits ab.
|
||||
if (moved) {
|
||||
project = {
|
||||
...project,
|
||||
repoFullName: moved.to,
|
||||
giteaRepoUrl: `${config.gitea.baseUrl}/${moved.to}`,
|
||||
giteaRepoName: splitRepo(moved.to)[1],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { backfilled, warnings: backfillWarnings, ticketId } = await backfillPushes(project, { now })
|
||||
// R15: die Antwort soll die (evtl. gerade erst automatisch angelegte) ticketId zeigen, auch
|
||||
// wenn `project` oben vor dem Anlegen gelesen wurde.
|
||||
if (ticketId && project.ticketId !== ticketId) project = { ...project, ticketId }
|
||||
|
||||
return { project, moved, backfilled, warnings: [...moveWarnings, ...backfillWarnings] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Oeffentlicher, selbst sperrender Einstieg fuer den Kern oben - damit ein Doppelklick auf
|
||||
* "Zuweisen" (oder ein gleichzeitiger Aufruf ueber die Migration, Task 9) nicht zweimal umzieht
|
||||
* oder doppelte Arbeitsblaetter erzeugt (Review Focus 3, Plan).
|
||||
*/
|
||||
export async function reconcileProject(projectId, { now } = {}) {
|
||||
return withLocks(['project:' + projectId], () => reconcileProjectLocked(projectId, { now }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticket-Id fuer die Zuweisung (Controller-Ruling R15): eine mitgegebene `ticketId` gewinnt immer.
|
||||
* Ist sie leer UND bleibt die Kunden-Id gleich, bleibt die gespeicherte `ticketId` erhalten - ein
|
||||
* Entfernen laeuft ueber "Zuweisung aufheben", nicht ueber diesen Endpunkt (sonst loescht ein
|
||||
* zweiter Klick ohne `ticketId` ein zwischenzeitlich automatisch angelegtes Ticket, Review I3).
|
||||
* Aendert sich die Kunden-Id und wird keine `ticketId` mitgegeben, wird sie geleert.
|
||||
*/
|
||||
function resolveTicketId(payloadTicketId, customerId, existing) {
|
||||
if (payloadTicketId) return payloadTicketId
|
||||
if (customerId === existing.customerId) return existing.ticketId || ''
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Weist ein Projekt einem Kunden/Ticket zu und stoesst danach Umzug + Nachtragen an. Laeuft
|
||||
* komplett unter der Projekt-Sperre (Controller-Ruling R15): Lesen (fuer die 404-Pruefung und die
|
||||
* `ticketId`-Aufloesung), Schreiben der Zuweisung und der Reconcile-Kern teilen sich dieselbe
|
||||
* Sperre, statt sie zweimal zu nehmen - ein zweiter, gleichzeitiger Aufruf sieht daher nie einen
|
||||
* veralteten Stand. Ein unbekanntes Projekt wirft weiterhin (`.status = 404`) BEVOR irgendetwas
|
||||
* geschrieben wird. Schlaegt danach der Reconcile-Kern (Umzug/Nachtragen) mit etwas anderem als
|
||||
* einem 404 fehl, bleibt die Zuweisung trotzdem gespeichert - Ruling R14 verlangt eine Warnung
|
||||
* statt eines Abbruchs, denn Spec 5.2 verlangt genau das.
|
||||
*/
|
||||
export async function assignProject(projectId, { customerId = '', ticketId = '' }, { now } = {}) {
|
||||
return withLocks(['project:' + projectId], async () => {
|
||||
// 404 fuer ein unbekanntes Projekt, bevor ueberhaupt geschrieben wird.
|
||||
const existing = await getDocument(config.collections.websiteProjects, projectId)
|
||||
|
||||
const nextTicketId = resolveTicketId(ticketId, customerId, existing)
|
||||
const saved = await updateDocument(config.collections.websiteProjects, projectId, {
|
||||
customerId,
|
||||
ticketId: nextTicketId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await reconcileProjectLocked(projectId, { now })
|
||||
return { ok: true, ...result }
|
||||
} catch (err) {
|
||||
if (err.status === 404) throw err
|
||||
return {
|
||||
ok: true,
|
||||
project: saved,
|
||||
moved: null,
|
||||
backfilled: 0,
|
||||
warnings: [WARN_RECONCILE_FAILED(err.message)],
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { config } from '../config.js'
|
||||
import { giteaFetch, splitRepo } from './giteaClient.js'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
// Sicherheitsnetz fuer listRecentPushes: sollte Gitea "page" ignorieren und immer dieselbe
|
||||
// volle, noch im Fenster liegende Seite liefern, blaettert die Schleife nicht endlos weiter
|
||||
// (40 x 50 = 2000 Eintraege).
|
||||
const MAX_FEED_PAGES = 40
|
||||
|
||||
/**
|
||||
* Baut aus Gitea-1.25-Aktivitaeten-Feed-Eintraegen (`GET .../activities/feeds`) Push-Objekte
|
||||
* im selben Zuschnitt wie der Push-Webhook (siehe `server/routes/webhook/gitea.js` und
|
||||
* `createGitPushWorksheet`). Nur `op_type === 'commit_repo'`-Eintraege ab `since` mit
|
||||
* mindestens einem Commit zaehlen. Der erste Push auf einen neuen Branch (Eintrag mit leerem
|
||||
* `content`) und andere Aktivitaeten (z. B. `create_repo`) werden uebersprungen; ein nicht
|
||||
* parsbarer `content` ebenso. Ergebnis aufsteigend nach `pushedAt` sortiert (aelteste
|
||||
* zuerst), unabhaengig von der Reihenfolge im Feed.
|
||||
*
|
||||
* @param {object[]} feed Feed-Eintraege wie von Gitea geliefert.
|
||||
* @param {{since: Date}} options
|
||||
* @returns {Array<{branch: string, commitSha: string, pushedAt: Date,
|
||||
* pusher: {login: string, username: string, full_name: string},
|
||||
* commits: Array<{id: string, message: string, author: {name: string}}>}>}
|
||||
*/
|
||||
export function pushesFromFeed(feed, { since } = {}) {
|
||||
const pushes = []
|
||||
|
||||
for (const entry of feed || []) {
|
||||
if (entry?.op_type !== 'commit_repo') continue
|
||||
if (!(new Date(entry.created) >= since)) continue
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(entry.content)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const rawCommits = parsed?.Commits
|
||||
if (!Array.isArray(rawCommits) || rawCommits.length === 0) continue
|
||||
|
||||
const actUser = entry.act_user || {}
|
||||
|
||||
pushes.push({
|
||||
branch: String(entry.ref_name || '').replace('refs/heads/', ''),
|
||||
commitSha: parsed.HeadCommit?.Sha1 || rawCommits[0].Sha1,
|
||||
pushedAt: new Date(entry.created),
|
||||
pusher: {
|
||||
login: actUser.login,
|
||||
username: actUser.login,
|
||||
full_name: actUser.full_name || '',
|
||||
},
|
||||
commits: rawCommits.map((c) => ({
|
||||
id: c.Sha1,
|
||||
message: c.Message,
|
||||
author: { name: c.AuthorName },
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return pushes.sort((a, b) => a.pushedAt - b.pushedAt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Push-Historie eines Repos der letzten `days` Tage aus dem Gitea-Aktivitaeten-Feed
|
||||
* (`GET /repos/{owner}/{repo}/activities/feeds`, seitenweise mit 50 pro Seite, neueste zuerst).
|
||||
* Blaettert nur so weit wie noetig: stoppt bei einer leeren Seite, einer Seite mit weniger als
|
||||
* 50 Eintraegen, oder sobald der aelteste Eintrag einer Seite vor `since` liegt. Bricht
|
||||
* zusaetzlich nach `MAX_FEED_PAGES` Seiten ab (mit einer Konsolen-Warnung), falls keine dieser
|
||||
* Bedingungen je eintritt.
|
||||
*
|
||||
* @param {string} repoFullName "owner/repo"
|
||||
* @param {{days?: number, now?: Date}} [options]
|
||||
* @returns {Promise<ReturnType<typeof pushesFromFeed>>}
|
||||
*/
|
||||
export async function listRecentPushes(repoFullName, { days = config.gitea.backfillDays, now = new Date() } = {}) {
|
||||
const [owner, repo] = splitRepo(repoFullName)
|
||||
const since = new Date(now - days * DAY_MS)
|
||||
const limit = 50
|
||||
const collected = []
|
||||
let page = 1
|
||||
|
||||
while (true) {
|
||||
const items = await giteaFetch(`/repos/${owner}/${repo}/activities/feeds?limit=${limit}&page=${page}`)
|
||||
if (!Array.isArray(items) || items.length === 0) break
|
||||
collected.push(...items)
|
||||
if (items.length < limit) break
|
||||
if (new Date(items[items.length - 1].created) < since) break
|
||||
if (page >= MAX_FEED_PAGES) {
|
||||
console.warn(
|
||||
`[pushHistory] ${repoFullName}: mehr als ${MAX_FEED_PAGES} Seiten Aktivitäten – ältere Pushes im Zeitraum werden nicht nachgetragen`
|
||||
)
|
||||
break
|
||||
}
|
||||
page += 1
|
||||
}
|
||||
|
||||
return pushesFromFeed(collected, { since })
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import { config } from '../config.js'
|
||||
import { giteaFetch, splitRepo } from './giteaClient.js'
|
||||
import { withLocks } from './keyedLock.js'
|
||||
import { listDocuments, updateDocument, Query } from './appwriteAdmin.js'
|
||||
import { invalidateProjectCache } from './projectInsights.js'
|
||||
import { previewWorkDir } from './previewDeploy.js'
|
||||
|
||||
/** Warnungen, wörtlich (siehe Plan, Abschnitt "Global Constraints", und Controller-Ruling R11). */
|
||||
export const WARN = {
|
||||
internal: 'Internes Repo, bleibt wo es ist.',
|
||||
nameTaken: (name) =>
|
||||
`In WEBklar gibt es schon ein Repo namens ${name}. Bitte in Gitea umbenennen und dann ‚Pushes nachtragen‘ klicken.`,
|
||||
giteaDown: (reason) =>
|
||||
`Gitea ist gerade nicht erreichbar (${reason}). Bitte später ‚Pushes nachtragen‘ klicken.`,
|
||||
docNotUpdated: (reason) =>
|
||||
`Das Repo liegt jetzt in WEBklar, aber die Projektdaten konnten nicht angepasst werden (${reason}). Bitte ‚Pushes nachtragen‘ klicken.`,
|
||||
hookNotRemoved: (reason) =>
|
||||
`Der alte Einzel-Webhook ans Portal konnte nicht entfernt werden (${reason}). Bitte später ‚Pushes nachtragen‘ klicken.`,
|
||||
}
|
||||
|
||||
/** Portal-Webhook-URL: ein Einzel-Webhook gilt als Portal-Webhook, wenn seine URL exakt damit beginnt. */
|
||||
function portalWebhookUrl() {
|
||||
return `${config.publicBaseUrl}/webhook/gitea`
|
||||
}
|
||||
|
||||
/**
|
||||
* Ziel-URL eines Gitea-Hooks. Echtes Gitea (Produktions-Swagger von 1.25.2, `definitions.Hook`)
|
||||
* hat dafuer KEIN Top-Level-`url`-Feld - das Ziel steht ausschliesslich unter `config.url`. Ein
|
||||
* Top-Level-`url` zu lesen wuerde in Produktion immer `''` liefern und jedes Repo mit irgendeinem
|
||||
* Hook faelschlich als intern einstufen (siehe Review C1).
|
||||
*/
|
||||
function hookUrl(hook) {
|
||||
return hook?.config?.url || ''
|
||||
}
|
||||
|
||||
/** true, wenn `owner/repo` (ohne Ruecksicht auf Gross-/Kleinschreibung) in der Ziel-Organisation liegt. */
|
||||
export function isOrgRepo(repoFullName) {
|
||||
const [owner] = splitRepo(repoFullName)
|
||||
return String(owner || '').toLowerCase() === config.gitea.org.toLowerCase()
|
||||
}
|
||||
|
||||
/** Sperr-Schluessel fuer ein Repo (fuer `withLocks`), ohne Ruecksicht auf Gross-/Kleinschreibung. */
|
||||
export function repoLockKey(repoFullName) {
|
||||
return 'repo:' + repoFullName.toLowerCase()
|
||||
}
|
||||
|
||||
/** owner/repo ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Gitea haelt es ebenso). */
|
||||
function sameRepoFullName(a, b) {
|
||||
return String(a || '').toLowerCase() === String(b || '').toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* true, wenn das Repo in `GITEA_KEEP_IN_PLACE` steht (ohne Ruecksicht auf Gross-/Kleinschreibung)
|
||||
* oder einen Webhook hat, dessen URL nicht mit der Portal-Webhook-URL beginnt (Deploy-verdrahtetes
|
||||
* internes Repo). Wirft weiter, wenn die Gitea-Abfrage der Hooks fehlschlaegt - die Einordnung
|
||||
* eines Gitea-Fehlers ist Sache der Aufrufer (siehe `moveRepoToOrg`).
|
||||
*/
|
||||
export async function isInternalRepo(repoFullName) {
|
||||
const lower = repoFullName.toLowerCase()
|
||||
if (config.gitea.keepInPlace.some((entry) => entry.toLowerCase() === lower)) return true
|
||||
|
||||
const hooks = await giteaFetch(`/repos/${repoFullName}/hooks`)
|
||||
const portalUrl = portalWebhookUrl()
|
||||
return (hooks || []).some((hook) => !hookUrl(hook).startsWith(portalUrl))
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktueller `repoFullName`: der gespeicherte Projektstand, falls (noch) aktuell, sonst der Name,
|
||||
* auf den Gitea per Weiterleitung verweist. Liefert `name` unveraendert zurueck, wenn beides nicht
|
||||
* greift (z. B. Gitea nicht erreichbar) - reine Bestpractice-Aufloesung ohne eigene Warnungen.
|
||||
*/
|
||||
export async function currentRepoFullName(name) {
|
||||
const existing = await listDocuments(config.collections.websiteProjects, [
|
||||
Query.equal('repoFullName', name),
|
||||
Query.limit(1),
|
||||
])
|
||||
if (existing[0]) return name
|
||||
|
||||
try {
|
||||
const repo = await giteaFetch(`/repos/${name}`)
|
||||
return repo.full_name
|
||||
} catch {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
/** Netzwerkfehler (kein `.status`) oder 5xx gelten als "Gitea gerade nicht erreichbar". */
|
||||
function isGiteaDownError(err) {
|
||||
return !err.status || err.status >= 500
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordnet einen waehrend Schritt 2-5 aufgetretenen Gitea-Fehler ein (siehe Controller-Ruling R10):
|
||||
* Netzwerkfehler/5xx -> `giteaDown`, alles andere (z. B. unerwartete 403/404) -> ein eigener
|
||||
* Hinweistext, ohne zu werfen. `moveRepoToOrg` darf wegen Gitea-/Appwrite-Problemen nie werfen,
|
||||
* damit eine Zuweisung (Task 6) in jedem Fall bestehen bleibt.
|
||||
*/
|
||||
function classifyGiteaError(err) {
|
||||
if (isGiteaDownError(err)) return { moved: null, warnings: [WARN.giteaDown(err.message)] }
|
||||
return { moved: null, warnings: [`Umzug nach WEBklar nicht möglich (${err.message}).`] }
|
||||
}
|
||||
|
||||
/** Schritt 6: Projektdokument auf den neuen Repo-Namen nachziehen. */
|
||||
async function updateProjectDocument(project, to) {
|
||||
const name = splitRepo(to)[1]
|
||||
return updateDocument(config.collections.websiteProjects, project.$id, {
|
||||
repoFullName: to,
|
||||
giteaRepoUrl: `${config.gitea.baseUrl}/${to}`,
|
||||
giteaRepoName: name,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Loescht die alten Einzel-Webhooks ans Portal an einem (aktuellen) Repo-Ort - die Organisation
|
||||
* hat inzwischen ihren eigenen Webhook. Ein fehlgeschlagenes Listen wird nur geloggt (kein
|
||||
* Repo-Zustand haengt daran); ein fehlgeschlagenes Loeschen liefert eine Warnung (R11), damit
|
||||
* "Pushes nachtragen" es beim naechsten Versuch erneut probieren kann - sonst bleibt der alte
|
||||
* Webhook fuer immer bestehen und jeder Push wird doppelt zugestellt (siehe Review I3).
|
||||
*/
|
||||
async function deletePortalHooks(repoFullName) {
|
||||
const portalUrl = portalWebhookUrl()
|
||||
let hooks
|
||||
try {
|
||||
hooks = await giteaFetch(`/repos/${repoFullName}/hooks`)
|
||||
} catch (err) {
|
||||
console.error(`[repoMove] Webhooks von ${repoFullName} konnten nicht gelesen werden:`, err.message)
|
||||
return []
|
||||
}
|
||||
|
||||
const warnings = []
|
||||
for (const hook of hooks || []) {
|
||||
if (!hookUrl(hook).startsWith(portalUrl)) continue
|
||||
try {
|
||||
await giteaFetch(`/repos/${repoFullName}/hooks/${hook.id}`, { method: 'DELETE' })
|
||||
} catch (err) {
|
||||
console.error(`[repoMove] Webhook ${hook.id} bei ${repoFullName} konnte nicht geloescht werden:`, err.message)
|
||||
warnings.push(WARN.hookNotRemoved(err.message))
|
||||
}
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie `deletePortalHooks`, aber fuer Schritt 1 (isOrgRepo(from)-Zweig): LIST und DELETE laufen mit
|
||||
* `redirect: 'manual'`, damit ein von Hand AUS der Organisation herausbewegtes Repo (der Name
|
||||
* `repoFullName` sieht per Owner noch wie WEBklar aus, Gitea antwortet aber mit einer
|
||||
* Weiterleitung) nicht angefasst wird - eine ganz normale (Folge-)Anfrage wuerde sonst den
|
||||
* Portal-Webhook eines inzwischen fremden Repos loeschen (final-fix Punkt 2). Eine 3xx- oder
|
||||
* 404-Antwort der LIST gilt als "hier nicht (mehr) zu finden" (`{ redirected: true }`); die
|
||||
* Aufloesung des tatsaechlichen aktuellen Namens ist Sache des Aufrufers.
|
||||
*/
|
||||
async function deletePortalHooksStrict(repoFullName) {
|
||||
const portalUrl = portalWebhookUrl()
|
||||
let hooks
|
||||
try {
|
||||
hooks = await giteaFetch(`/repos/${repoFullName}/hooks`, { redirect: 'manual' })
|
||||
} catch (err) {
|
||||
if (err.status === 404 || (err.status >= 300 && err.status < 400)) return { redirected: true }
|
||||
console.error(`[repoMove] Webhooks von ${repoFullName} konnten nicht gelesen werden:`, err.message)
|
||||
return { redirected: false, warnings: [] }
|
||||
}
|
||||
|
||||
const warnings = []
|
||||
for (const hook of hooks || []) {
|
||||
if (!hookUrl(hook).startsWith(portalUrl)) continue
|
||||
try {
|
||||
await giteaFetch(`/repos/${repoFullName}/hooks/${hook.id}`, { method: 'DELETE', redirect: 'manual' })
|
||||
} catch (err) {
|
||||
console.error(`[repoMove] Webhook ${hook.id} bei ${repoFullName} konnte nicht geloescht werden:`, err.message)
|
||||
warnings.push(WARN.hookNotRemoved(err.message))
|
||||
}
|
||||
}
|
||||
return { redirected: false, warnings }
|
||||
}
|
||||
|
||||
/**
|
||||
* Schritt 1 selbst, wenn `deletePortalHooksStrict` eine Weiterleitung meldet: den kanonischen
|
||||
* Namen normal (mit Redirect-Folge) aufloesen. Liegt er AUSSERHALB von WEBklar, wurde das Repo von
|
||||
* Hand herausverschoben - nur das Projektdokument wird nachgezogen, OHNE es automatisch
|
||||
* zurueckzuholen (final-fix Punkt 2). Liegt er weiterhin in WEBklar (z. B. nur umbenannt), wird
|
||||
* dort ganz normal aufgeraeumt.
|
||||
*/
|
||||
async function resolveRedirectedOrgRepo(project, from) {
|
||||
let canonical
|
||||
try {
|
||||
canonical = (await giteaFetch(`/repos/${from}`)).full_name
|
||||
} catch (err) {
|
||||
return classifyGiteaError(err)
|
||||
}
|
||||
|
||||
if (!isOrgRepo(canonical)) {
|
||||
try {
|
||||
await updateProjectDocument(project, canonical)
|
||||
} catch (err) {
|
||||
return { moved: null, warnings: [WARN.docNotUpdated(err.message)] }
|
||||
}
|
||||
return { moved: null, warnings: [] }
|
||||
}
|
||||
|
||||
const warnings = await deletePortalHooks(canonical)
|
||||
if (!sameRepoFullName(canonical, from)) {
|
||||
try {
|
||||
await updateProjectDocument(project, canonical)
|
||||
} catch (err) {
|
||||
warnings.push(WARN.docNotUpdated(err.message))
|
||||
}
|
||||
}
|
||||
return { moved: null, warnings }
|
||||
}
|
||||
|
||||
/**
|
||||
* Schritte 7-9, idempotent: Cache fuer alten und neuen Namen verwerfen, alte Portal-Webhooks am
|
||||
* neuen Ort loeschen, Preview-Arbeitsverzeichnis des alten Namens aufraeumen. Laeuft unabhaengig
|
||||
* davon, ob das Projektdokument selbst erfolgreich aktualisiert wurde (R11) - der Repo-Zustand in
|
||||
* Gitea ist in jedem Fall schon "in WEBklar", und genau das muss aufgeraeumt werden. `fs.rm` ist
|
||||
* abgesichert (Review I2): ein EACCES/EBUSY/ENOTEMPTY & Co. nach dem schon erfolgten, nicht mehr
|
||||
* rueckgaengig zu machenden Transfer darf `moveRepoToOrg` nicht mehr zum Werfen bringen.
|
||||
*/
|
||||
async function finishMove(from, to) {
|
||||
invalidateProjectCache(from)
|
||||
invalidateProjectCache(to)
|
||||
|
||||
const warnings = await deletePortalHooks(to)
|
||||
|
||||
try {
|
||||
await fs.rm(previewWorkDir(from), { recursive: true, force: true })
|
||||
} catch (err) {
|
||||
console.error(`[repoMove] Preview-Arbeitsverzeichnis von ${from} konnte nicht entfernt werden:`, err.message)
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* Schritte 3-6 (+ Aufraeumen): internes Repo bleibt, Namenskonflikt wird gemeldet, sonst Transfer
|
||||
* + Dokument-Update unter Sperre, danach `finishMove` - unabhaengig davon, ob `moveRepoToOrg` hier
|
||||
* ueber den normalen Weg ankommt oder ueber die Selbstheilung mit dem kanonischen, aber
|
||||
* Nicht-WEBklar-Namen als `from`.
|
||||
*/
|
||||
async function moveViaTransfer(project, from) {
|
||||
// Schritt 3: internes (Deploy-verdrahtetes) Repo bleibt, wo es ist.
|
||||
let internal
|
||||
try {
|
||||
internal = await isInternalRepo(from)
|
||||
} catch (err) {
|
||||
return classifyGiteaError(err)
|
||||
}
|
||||
if (internal) return { moved: null, warnings: [WARN.internal] }
|
||||
|
||||
// Schritt 4: Namenskonflikt in der Organisation pruefen. `candidateTo` ist der Name, den der
|
||||
// Transfer voraussichtlich ergibt - fuer den zweiten Sperr-Schluessel (siehe Controller-Ruling),
|
||||
// bestaetigt wird er erst ueber die full_name der Transfer-Antwort (`confirmedTo` unten).
|
||||
const name = splitRepo(from)[1]
|
||||
const candidateTo = `${config.gitea.org}/${name}`
|
||||
try {
|
||||
await giteaFetch(`/repos/${candidateTo}`)
|
||||
return { moved: null, warnings: [WARN.nameTaken(name)] }
|
||||
} catch (err) {
|
||||
if (err.status !== 404) return classifyGiteaError(err)
|
||||
// 404: der Name ist frei, weiter mit dem Umzug.
|
||||
}
|
||||
|
||||
// Schritt 5+6: Transfer und Dokument-Update gemeinsam gesperrt (siehe Controller-Ruling).
|
||||
const outcome = await withLocks([repoLockKey(from), repoLockKey(candidateTo)], async () => {
|
||||
let transferResult
|
||||
try {
|
||||
transferResult = await giteaFetch(`/repos/${from}/transfer`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ new_owner: config.gitea.org }),
|
||||
})
|
||||
} catch (err) {
|
||||
return { failed: 'gitea', err }
|
||||
}
|
||||
|
||||
const confirmedTo = transferResult.full_name
|
||||
// Ein-Zeiler-Absicherung (final-fix Punkt 3): Gitea kann 2xx melden, ohne das Repo tatsaechlich
|
||||
// verschoben zu haben (z. B. Teil-Fehlschlag). Erst wenn der bestaetigte Name wirklich in
|
||||
// WEBklar liegt, gilt der Transfer als geglueckt - sonst kein Dokument-Update, kein Aufraeumen.
|
||||
if (!isOrgRepo(confirmedTo)) return { failed: 'notMoved' }
|
||||
|
||||
let docErr = null
|
||||
try {
|
||||
await updateProjectDocument(project, confirmedTo)
|
||||
} catch (err) {
|
||||
docErr = err
|
||||
}
|
||||
return { to: confirmedTo, docErr }
|
||||
})
|
||||
|
||||
if (outcome.failed === 'gitea') return classifyGiteaError(outcome.err)
|
||||
if (outcome.failed === 'notMoved') {
|
||||
return { moved: null, warnings: ['Umzug nach WEBklar nicht möglich (Übertragung nicht abgeschlossen).'] }
|
||||
}
|
||||
|
||||
const confirmedTo = outcome.to
|
||||
// Das Aufraeumen (Cache, alter Webhook, Preview-Verzeichnis) haengt nicht am Dokument-Update -
|
||||
// das Repo liegt in Gitea so oder so schon in WEBklar (R11).
|
||||
const finishWarnings = await finishMove(from, confirmedTo)
|
||||
const warnings = outcome.docErr
|
||||
? [WARN.docNotUpdated(outcome.docErr.message), ...finishWarnings]
|
||||
: finishWarnings
|
||||
return { moved: { from, to: confirmedTo }, warnings }
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschiebt das Repo eines zugewiesenen Projekts nach `config.gitea.org`, wenn noetig, und haelt
|
||||
* Projektdokument, Cache und Webhooks auf dem Laufenden. Wirft nie wegen eines Gitea- oder
|
||||
* Appwrite-Problems (siehe `classifyGiteaError`/`docNotUpdated`) - Task 6 verlaesst sich darauf,
|
||||
* dass eine Zuweisung auch bei einem Ausfall bestehen bleibt.
|
||||
*/
|
||||
export async function moveRepoToOrg(project) {
|
||||
const from = project.repoFullName
|
||||
|
||||
// Schritt 1: schon in der Organisation - nur noch nach einem liegen gebliebenen Einzel-Webhook
|
||||
// schauen (R11), damit "Pushes nachtragen" auch das noch nachholen kann. Ohne Redirect-Folge
|
||||
// (final-fix Punkt 2), damit ein von Hand herausverschobenes Repo nicht angefasst wird.
|
||||
if (isOrgRepo(from)) {
|
||||
const result = await deletePortalHooksStrict(from)
|
||||
if (result.redirected) return resolveRedirectedOrgRepo(project, from)
|
||||
return { moved: null, warnings: result.warnings }
|
||||
}
|
||||
|
||||
// Schritt 2: aktuellen Stand holen (fetch folgt Giteas 301-Weiterleitung von Hand verschobener
|
||||
// Repos). Der Vergleich ist ohne Ruecksicht auf Gross-/Kleinschreibung (Review I1) - Gitea
|
||||
// liefert sonst z. B. "JUSN/Case-Test" fuer den gespeicherten Namen "jusn/case-test", ohne dass
|
||||
// sich am Repo tatsaechlich etwas geaendert haette.
|
||||
let current
|
||||
try {
|
||||
current = await giteaFetch(`/repos/${from}`)
|
||||
} catch (err) {
|
||||
return classifyGiteaError(err)
|
||||
}
|
||||
|
||||
if (!sameRepoFullName(current.full_name, from)) {
|
||||
// Das Repo wurde ausserhalb des Portals umbenannt/verschoben (z. B. von Hand). Den
|
||||
// gespeicherten Namen dafuer korrigieren - unter derselben Sperre wie ein regulaerer Umzug.
|
||||
const canonical = current.full_name
|
||||
const renameOutcome = await withLocks([repoLockKey(from), repoLockKey(canonical)], async () => {
|
||||
try {
|
||||
await updateProjectDocument(project, canonical)
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
return { ok: false, err }
|
||||
}
|
||||
})
|
||||
const renameWarnings = renameOutcome.ok ? [] : [WARN.docNotUpdated(renameOutcome.err.message)]
|
||||
|
||||
if (isOrgRepo(canonical)) {
|
||||
// Schon in WEBklar (z. B. von Hand verschoben): nur noch aufraeumen.
|
||||
const finishWarnings = await finishMove(from, canonical)
|
||||
return { moved: { from, to: canonical }, warnings: [...renameWarnings, ...finishWarnings] }
|
||||
}
|
||||
|
||||
// Woanders hin verschoben, aber nicht nach WEBklar: mit dem kanonischen Namen als neuem
|
||||
// Ausgangspunkt tatsaechlich umziehen (Schritte 3-5), statt den Umzug hier abzubrechen.
|
||||
const result = await moveViaTransfer(project, canonical)
|
||||
return { moved: result.moved, warnings: [...renameWarnings, ...result.warnings] }
|
||||
}
|
||||
|
||||
return moveViaTransfer(project, from)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* URL-/Dateiname-taugliches Kuerzel aus beliebigem Text: klein geschrieben, deutsche
|
||||
* Umlaute ausgeschrieben (ä→ae, ö→oe, ü→ue, ß→ss), Akzente entfernt, Apostrophe entfernt,
|
||||
* alles ausser a-z/0-9/- wird zu einem einzelnen '-', Rand-Bindestriche entfernt, auf
|
||||
* 100 Zeichen gekuerzt. Kein Text (z. B. null/undefined) ergibt ''.
|
||||
*/
|
||||
export function slugify(value) {
|
||||
const input = value === null || value === undefined ? '' : String(value)
|
||||
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.normalize('NFD')
|
||||
.replace(/\p{M}/gu, '')
|
||||
.replace(/['’‘`]/g, '')
|
||||
.replace(/[^a-z0-9-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 100)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { berlinDateTime } from '../server/services/berlinTime.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' }))
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
|
||||
|
||||
let fake
|
||||
let createGitPushWorksheet
|
||||
|
||||
before(async () => {
|
||||
fake = await startFakeAppwrite({
|
||||
seed: {
|
||||
workorders: [{ $id: 't1', woid: '100115', status: 'Assigned' }],
|
||||
worksheets: [{ $id: 'w0', wsid: '100077', woid: '100001', details: '' }],
|
||||
},
|
||||
})
|
||||
Object.assign(process.env, {
|
||||
APPWRITE_ENDPOINT: fake.endpoint,
|
||||
APPWRITE_API_KEY: 'test-key',
|
||||
})
|
||||
// config.js liest die Umgebung beim Import
|
||||
;({ createGitPushWorksheet } = await import('../server/services/gitWorksheet.js'))
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await fake.close()
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { test, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startFakeGitea } from './helpers/fakeGitea.js'
|
||||
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
|
||||
|
||||
let gitea
|
||||
let aw
|
||||
let config
|
||||
let listAllAccessibleRepos
|
||||
let importNewRepos
|
||||
let syncReposToAppwrite
|
||||
let isHostedProject
|
||||
let ensureRepoPreviewConfig
|
||||
|
||||
before(async () => {
|
||||
gitea = await startFakeGitea({
|
||||
repos: [
|
||||
{ full_name: 'knso/alt' },
|
||||
{ full_name: 'JUSN/neu', description: 'Döner Nation' },
|
||||
{ full_name: 'JUSN/archiv', archived: true },
|
||||
{ full_name: 'knso/privat', private: true },
|
||||
{ full_name: 'knso/synced-sub' },
|
||||
{ full_name: 'knso/lead' },
|
||||
],
|
||||
})
|
||||
|
||||
aw = await startFakeAppwrite({
|
||||
seed: {
|
||||
websiteProjects: [
|
||||
{ repoFullName: 'knso/alt', projectName: 'alt', subdomain: 'alt' },
|
||||
{ repoFullName: 'knso/synced-sub', projectName: 'synced-sub', status: 'synced', subdomain: 'synced-sub' },
|
||||
{ repoFullName: 'knso/lead', projectName: 'lead', status: 'deployed', subdomain: 'lead' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
Object.assign(process.env, {
|
||||
GITEA_BASE_URL: gitea.baseUrl,
|
||||
GITEA_API_TOKEN: 'test-token',
|
||||
APPWRITE_ENDPOINT: aw.endpoint,
|
||||
APPWRITE_API_KEY: 'test-key',
|
||||
PREVIEW_DEPLOY_ROOT: '',
|
||||
TRAEFIK_DYNAMIC_DIR: '',
|
||||
})
|
||||
// config.js liest die Umgebung beim Import
|
||||
;({ config } = await import('../server/config.js'))
|
||||
;({ listAllAccessibleRepos, importNewRepos, syncReposToAppwrite, isHostedProject, ensureRepoPreviewConfig } =
|
||||
await import('../server/services/giteaAdmin.js'))
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await gitea.close()
|
||||
await aw.close()
|
||||
})
|
||||
|
||||
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')))
|
||||
})
|
||||
test('Repo-Liste hat eine Obergrenze', async () => {
|
||||
// R8: eigener Fake-Server + kurzzeitig umgebogene config.gitea.baseUrl (statt in `gitea` oben
|
||||
// Tausende Repos zu seeden) - sonst wuerden importNewRepos/syncReposToAppwrite in den Tests
|
||||
// darueber auf einmal Tausende zusaetzliche, unbenannte Repos mitverarbeiten.
|
||||
const capRepos = []
|
||||
for (let i = 0; i < 2005; i++) capRepos.push({ full_name: `cap/repo${i}` })
|
||||
const capGitea = await startFakeGitea({ repos: capRepos })
|
||||
const originalBaseUrl = config.gitea.baseUrl
|
||||
const originalWarn = console.warn
|
||||
console.warn = () => {}
|
||||
config.gitea.baseUrl = capGitea.baseUrl
|
||||
let repos
|
||||
try {
|
||||
repos = await listAllAccessibleRepos()
|
||||
} finally {
|
||||
config.gitea.baseUrl = originalBaseUrl
|
||||
console.warn = originalWarn
|
||||
await capGitea.close()
|
||||
}
|
||||
assert.equal(repos.length, 2000)
|
||||
assert.equal(capGitea.calls.filter((c) => c.path === '/api/v1/repos/search').length, 40)
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startFakeGitea } from './helpers/fakeGitea.js'
|
||||
|
||||
let fake
|
||||
let giteaFetch
|
||||
let config
|
||||
|
||||
before(async () => {
|
||||
fake = await startFakeGitea({ repos: [{ full_name: 'JUSN/D-ner-Nation' }] })
|
||||
Object.assign(process.env, {
|
||||
GITEA_BASE_URL: fake.baseUrl,
|
||||
GITEA_API_TOKEN: 'test-token',
|
||||
})
|
||||
// config.js liest die Umgebung beim Import
|
||||
;({ giteaFetch } = await import('../server/services/giteaClient.js'))
|
||||
;({ config } = await import('../server/config.js'))
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await fake.close()
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
import { test, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHmac } from 'node:crypto'
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import express from 'express'
|
||||
import { startFakeGitea } from './helpers/fakeGitea.js'
|
||||
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
|
||||
|
||||
const WEBHOOK_TOKEN = 's3cret'
|
||||
|
||||
let gitea
|
||||
let aw
|
||||
let tmp
|
||||
let originalCwd
|
||||
let server
|
||||
let baseUrl
|
||||
|
||||
let nextFeedId = 1
|
||||
|
||||
/**
|
||||
* Baut einen Gitea-1.25-Feed-Eintrag vom Typ `commit_repo` nach (ein Commit) - derselbe Aufbau
|
||||
* wie in test/projectAssignment.test.js (Task 6): `content` traegt das JSON
|
||||
* `{Commits:[...], HeadCommit:{...}}` als String, wie im echten Aktivitaeten-Feed.
|
||||
*/
|
||||
function pushEntry({ sha, at, user, msg }) {
|
||||
const login = user || 'JUSN'
|
||||
const commit = {
|
||||
Sha1: sha,
|
||||
Message: msg || `Commit ${sha}`,
|
||||
AuthorEmail: `${login.toLowerCase()}@example.com`,
|
||||
AuthorName: login,
|
||||
CommitterEmail: `${login.toLowerCase()}@example.com`,
|
||||
CommitterName: login,
|
||||
Timestamp: at,
|
||||
}
|
||||
return {
|
||||
id: nextFeedId++,
|
||||
op_type: 'commit_repo',
|
||||
act_user: { login, full_name: '' },
|
||||
ref_name: 'refs/heads/main',
|
||||
created: at,
|
||||
content: JSON.stringify({ Commits: [commit], HeadCommit: { Sha1: sha }, CompareURL: '', Len: 1 }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wartet, bis die Fake-Gitea den POST .../transfer fuer `fullName` erhalten hat (Controller-
|
||||
* Ruling R3), statt einen festen `sleep(50)` zu nutzen: `gitea.calls` waechst, SOBALD die
|
||||
* Anfrage beim Fake-Server ankommt - strikt VOR einer etwaigen `setTransferDelay`-Verzoegerung
|
||||
* der Antwort. So laesst sich der Webhook zuverlaessig genau dann abschicken, wenn der Umzug
|
||||
* seine Repo-Sperren bereits haelt, ohne von der Terminzeit des Test-Rechners abzuhaengen.
|
||||
*/
|
||||
function waitForGiteaTransfer(fullName, timeoutMs = 1000) {
|
||||
const transferPath = `/api/v1/repos/${fullName}/transfer`
|
||||
const deadline = Date.now() + timeoutMs
|
||||
return new Promise((resolve, reject) => {
|
||||
;(function check() {
|
||||
if (gitea.calls.some((c) => c.method === 'POST' && c.path === transferPath)) return resolve()
|
||||
if (Date.now() > deadline) return reject(new Error(`Timeout beim Warten auf POST ${transferPath}`))
|
||||
setTimeout(check, 5)
|
||||
})()
|
||||
})
|
||||
}
|
||||
|
||||
async function post(urlPath, body, jwt) {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
if (jwt) headers.Authorization = `Bearer ${jwt}`
|
||||
const res = await fetch(`${baseUrl}${urlPath}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body || {}),
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
return { status: res.status, body: json }
|
||||
}
|
||||
|
||||
/** Baut einen Gitea-Push-Webhook-Payload (nur die vom Portal genutzten Felder). */
|
||||
function pushPayload({ full_name, sha, pusher, branch = 'main' }) {
|
||||
return {
|
||||
ref: `refs/heads/${branch}`,
|
||||
repository: {
|
||||
full_name,
|
||||
name: full_name.split('/').pop(),
|
||||
html_url: `${gitea.baseUrl}/${full_name}`,
|
||||
},
|
||||
commits: [
|
||||
{
|
||||
id: sha,
|
||||
message: `Commit ${sha}`,
|
||||
author: { name: pusher },
|
||||
url: `${gitea.baseUrl}/${full_name}/commit/${sha}`,
|
||||
},
|
||||
],
|
||||
pusher: { username: pusher },
|
||||
after: sha,
|
||||
}
|
||||
}
|
||||
|
||||
async function webhook(payload) {
|
||||
const bodyText = JSON.stringify(payload)
|
||||
const signature = createHmac('sha256', WEBHOOK_TOKEN).update(bodyText).digest('hex')
|
||||
const res = await fetch(`${baseUrl}/webhook/gitea`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Gitea-Signature': signature },
|
||||
body: bodyText,
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
return { status: res.status, body: json }
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
gitea = await startFakeGitea({
|
||||
repos: [{ full_name: 'JUSN/D-ner-Nation' }, { full_name: 'JUSN/langsam' }],
|
||||
feeds: {
|
||||
// Wie Task 6 (test/projectAssignment.test.js): genau ein nachtragbarer Push
|
||||
// ('26bc22ec...' von JUSN), der Rest sind Alt-System-/Alt-Branch-Rauschen.
|
||||
'JUSN/D-ner-Nation': [
|
||||
pushEntry({ sha: 's1', user: 'webklar-system', at: '2026-09-27T14:50:00Z', msg: 'System-Push' }),
|
||||
pushEntry({ sha: 'k1', user: 'knso', at: '2026-09-27T14:48:00Z', msg: 'Update preview config from ticket system' }),
|
||||
pushEntry({ sha: '26bc22ecf17e2e5a5a8794dcb4cf3892f802c51d', user: 'JUSN', at: '2026-09-27T14:46:34Z', msg: 'Bilder aktualisiert' }),
|
||||
{ ...pushEntry({ sha: 'x1', at: '2026-09-27T14:46:33Z' }), content: '' },
|
||||
{ id: nextFeedId++, op_type: 'create_repo', created: '2026-09-27T14:02:46Z', act_user: { login: 'JUSN' } },
|
||||
],
|
||||
// p5/JUSN/langsam hat keine Pushes im Feed.
|
||||
},
|
||||
})
|
||||
|
||||
aw = await startFakeAppwrite({
|
||||
seed: {
|
||||
websiteProjects: [
|
||||
{ $id: 'p1', repoFullName: 'JUSN/D-ner-Nation', projectName: 'D-ner-Nation', customerId: '', ticketId: '' },
|
||||
{ $id: 'p5', repoFullName: 'JUSN/langsam', projectName: 'langsam', customerId: '', ticketId: '' },
|
||||
],
|
||||
workorders: [{ $id: 't1', woid: '100115' }],
|
||||
customers: [{ $id: 'c1', name: 'Döner Nation' }],
|
||||
},
|
||||
})
|
||||
|
||||
originalCwd = process.cwd()
|
||||
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'gitea-routes-'))
|
||||
process.chdir(tmp)
|
||||
|
||||
Object.assign(process.env, {
|
||||
GITEA_BASE_URL: gitea.baseUrl,
|
||||
GITEA_API_TOKEN: 'test-token',
|
||||
GITEA_WEBHOOK_TOKEN: WEBHOOK_TOKEN,
|
||||
APPWRITE_ENDPOINT: aw.endpoint,
|
||||
APPWRITE_API_KEY: 'test-key',
|
||||
PREVIEW_DEPLOY_ROOT: '',
|
||||
TRAEFIK_DYNAMIC_DIR: '',
|
||||
})
|
||||
|
||||
// config.js liest die Umgebung beim Import
|
||||
const [{ default: adminGiteaRoutes }, { default: adminWebsiteProjectsRoutes }, { default: giteaWebhookRoutes }] =
|
||||
await Promise.all([
|
||||
import('../server/routes/admin/gitea.js'),
|
||||
import('../server/routes/admin/websiteProjects.js'),
|
||||
import('../server/routes/webhook/gitea.js'),
|
||||
])
|
||||
|
||||
const app = express()
|
||||
// rawBody wird fuer die HMAC-Verifikation des Gitea-Webhooks benoetigt - derselbe Aufbau wie server/index.js.
|
||||
app.use(
|
||||
express.json({
|
||||
limit: '2mb',
|
||||
verify: (req, _res, buf) => {
|
||||
req.rawBody = buf
|
||||
},
|
||||
})
|
||||
)
|
||||
app.use('/api/admin/gitea', adminGiteaRoutes)
|
||||
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
|
||||
app.use('/webhook', giteaWebhookRoutes)
|
||||
|
||||
server = await new Promise((resolve) => {
|
||||
const s = app.listen(0, '127.0.0.1', () => resolve(s))
|
||||
})
|
||||
baseUrl = `http://127.0.0.1:${server.address().port}`
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
process.chdir(originalCwd)
|
||||
await gitea.close()
|
||||
await aw.close()
|
||||
await fs.rm(tmp, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
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')))
|
||||
// Fix-Runde 1: die veraltete Payload (repo.html_url/repo.name auf den alten Namen) darf die
|
||||
// Repo-Adresse des laengst umgezogenen Dokuments nicht mit dem alten Namen ueberschreiben.
|
||||
const p1 = aw.get('websiteProjects', 'p1')
|
||||
assert.ok(p1.giteaRepoUrl.endsWith('/WEBklar/D-ner-Nation'))
|
||||
assert.equal(p1.giteaRepoName, 'D-ner-Nation')
|
||||
})
|
||||
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 waitForGiteaTransfer('JUSN/langsam')
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { test, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { startFakeGitea } from './helpers/fakeGitea.js'
|
||||
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
|
||||
import { readEnvFile, setupOrganisation } from '../scripts/gitea-organisation-setup.mjs'
|
||||
|
||||
let gitea
|
||||
let aw
|
||||
let tmp
|
||||
let originalCwd
|
||||
let envPath
|
||||
let backupDir
|
||||
let migrateRepos
|
||||
|
||||
const now = new Date('2026-09-30T10:00:00Z')
|
||||
|
||||
before(async () => {
|
||||
gitea = await startFakeGitea({
|
||||
repos: [
|
||||
{ full_name: 'knso/emmas-schatzkiste' },
|
||||
{ full_name: 'JUSN/D-ner-Nation' },
|
||||
],
|
||||
hooks: {
|
||||
'knso/emmas-schatzkiste': [{ id: 110, url: 'https://project.webklar.com/webhook/gitea', events: ['push'] }],
|
||||
},
|
||||
contents: {
|
||||
'knso/emmas-schatzkiste:.webklar-preview.json': JSON.stringify({
|
||||
enabled: true,
|
||||
type: 'static',
|
||||
branch: 'main',
|
||||
displayName: 'Emmas Schatzkiste',
|
||||
subdomain: 'emmas-schatzkiste',
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
aw = await startFakeAppwrite({
|
||||
seed: {
|
||||
websiteProjects: [
|
||||
{
|
||||
$id: 'p1',
|
||||
repoFullName: 'knso/emmas-schatzkiste',
|
||||
projectName: 'Emmas Schatzkiste',
|
||||
projectType: 'website',
|
||||
subdomain: 'emmas-schatzkiste',
|
||||
customerId: '',
|
||||
ticketId: '',
|
||||
},
|
||||
{
|
||||
$id: 'p2',
|
||||
repoFullName: 'JUSN/D-ner-Nation',
|
||||
projectName: 'D-ner-Nation',
|
||||
projectType: 'preview',
|
||||
subdomain: 'doener-nation',
|
||||
customerId: '',
|
||||
ticketId: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
originalCwd = process.cwd()
|
||||
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'gitea-scripts-'))
|
||||
process.chdir(tmp)
|
||||
envPath = path.join(tmp, '.env')
|
||||
backupDir = path.join(tmp, 'gitea-org')
|
||||
|
||||
Object.assign(process.env, {
|
||||
GITEA_BASE_URL: gitea.baseUrl,
|
||||
GITEA_API_TOKEN: 'test-token',
|
||||
APPWRITE_ENDPOINT: aw.endpoint,
|
||||
APPWRITE_API_KEY: 'test-key',
|
||||
PREVIEW_DEPLOY_ROOT: '',
|
||||
TRAEFIK_DYNAMIC_DIR: '',
|
||||
})
|
||||
// server/config.js liest die Umgebung beim Import - erst danach das Migrationsskript laden.
|
||||
;({ migrateRepos } = await import('../scripts/gitea-organisation-migrate.mjs'))
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
process.chdir(originalCwd)
|
||||
await gitea.close()
|
||||
await aw.close()
|
||||
await fs.rm(tmp, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startFakeGitea } from './helpers/fakeGitea.js'
|
||||
|
||||
let gitea
|
||||
let giteaFetch
|
||||
let moveRepoToOrg
|
||||
|
||||
// Eigene Datei, weil GITEA_TIMEOUT_MS hier absichtlich sehr klein gesetzt wird (config.js liest
|
||||
// die Umgebung beim Import - andere Testdateien duerfen davon nicht betroffen sein, jede
|
||||
// Testdatei laeuft in einem eigenen Prozess).
|
||||
before(async () => {
|
||||
gitea = await startFakeGitea({ repos: [{ full_name: 'knso/langsam' }] })
|
||||
|
||||
Object.assign(process.env, {
|
||||
GITEA_BASE_URL: gitea.baseUrl,
|
||||
GITEA_API_TOKEN: 'test-token',
|
||||
GITEA_TIMEOUT_MS: '200',
|
||||
})
|
||||
;({ giteaFetch } = await import('../server/services/giteaClient.js'))
|
||||
;({ moveRepoToOrg } = await import('../server/services/repoMove.js'))
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await gitea.close()
|
||||
})
|
||||
|
||||
test('giteaFetch bricht bei Zeitueberschreitung ohne .status ab', async () => {
|
||||
gitea.setResponseDelay(2000)
|
||||
const start = Date.now()
|
||||
await assert.rejects(
|
||||
giteaFetch('/repos/knso/langsam'),
|
||||
(err) => {
|
||||
assert.equal(err.status, undefined)
|
||||
assert.match(err.message, /Zeitlimit/)
|
||||
return true
|
||||
}
|
||||
)
|
||||
assert.ok(Date.now() - start < 2000, 'giteaFetch haette nach ~200ms abbrechen muessen')
|
||||
})
|
||||
|
||||
test('moveRepoToOrg meldet "Gitea ist gerade nicht erreichbar" statt zu haengen', async () => {
|
||||
gitea.setResponseDelay(2000)
|
||||
const start = Date.now()
|
||||
const r = await moveRepoToOrg({ $id: 'x', repoFullName: 'knso/langsam' })
|
||||
assert.ok(Date.now() - start < 2000, 'moveRepoToOrg haette nicht haengen bleiben duerfen')
|
||||
assert.equal(r.moved, null)
|
||||
assert.match(r.warnings[0], /^Gitea ist gerade nicht erreichbar \(/)
|
||||
})
|
||||
@@ -0,0 +1,254 @@
|
||||
import http from 'node:http'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Nachgebautes Appwrite fuer Tests: nur der REST-Ausschnitt, den `appwriteAdmin.js` und
|
||||
* `appwriteStaff.js` nutzen (Dokumente einer Sammlung, `/account` per JWT). Laeuft auf
|
||||
* 127.0.0.1 mit einem vom Betriebssystem vergebenen Port; `endpoint` endet auf `/v1`,
|
||||
* genau wie `config.appwrite.endpoint`.
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {Record<string, object[]>} [options.seed]
|
||||
* Anfangsbestand je Sammlung (Sammlungsname -> Dokumente). Jedes Dokument darf `$id`
|
||||
* mitbringen (sonst wird eins erzeugt); `$createdAt`/`$updatedAt` werden sonst mit dem
|
||||
* Startzeitpunkt gefuellt.
|
||||
* @returns {Promise<{
|
||||
* endpoint: string,
|
||||
* all: (collection: string) => object[],
|
||||
* get: (collection: string, id: string) => (object|undefined),
|
||||
* calls: Array<{method: string, path: string, query: Record<string, string>, headers: Record<string, string>, body: any}>,
|
||||
* failNext: (spec: {method: string, pathIncludes: string, status: number}) => void,
|
||||
* close: () => Promise<void>,
|
||||
* }>} `all`/`get` lesen den LIVE-Stand (nach create/patch/delete ueber HTTP). Die
|
||||
* Datenbank-ID im Pfad wird nicht geprueft - jede wird akzeptiert.
|
||||
* `failNext({method, pathIncludes, status})` laesst GENAU die naechste Anfrage, deren Methode
|
||||
* passt und deren Pfad `pathIncludes` enthaelt, mit diesem Status und einer generischen
|
||||
* JSON-Fehlermeldung scheitern (danach wieder normales Verhalten).
|
||||
*/
|
||||
export async function startFakeAppwrite(options = {}) {
|
||||
const calls = []
|
||||
const pendingFailures = []
|
||||
/** @type {Map<string, Map<string, object>>} Sammlungsname -> (Dokument-ID -> Dokument). */
|
||||
const store = new Map()
|
||||
|
||||
function collectionOf(name) {
|
||||
let map = store.get(name)
|
||||
if (!map) {
|
||||
map = new Map()
|
||||
store.set(name, map)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function all(col) {
|
||||
return [...collectionOf(col).values()]
|
||||
}
|
||||
|
||||
function get(col, id) {
|
||||
return collectionOf(col).get(id)
|
||||
}
|
||||
|
||||
function send(res, status, body) {
|
||||
if (body === undefined) {
|
||||
res.writeHead(status)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const text = JSON.stringify(body)
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(text),
|
||||
})
|
||||
res.end(text)
|
||||
}
|
||||
|
||||
function parseQueries(searchParams) {
|
||||
return searchParams
|
||||
.getAll('queries[]')
|
||||
.map((s) => {
|
||||
try {
|
||||
return JSON.parse(s)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Routen --- */
|
||||
|
||||
function handleAccount(match, searchParams, body, headers, res) {
|
||||
if (headers['x-appwrite-jwt'] === 'jwt-admin') {
|
||||
return send(res, 200, { $id: 'staff1', labels: ['admin'] })
|
||||
}
|
||||
send(res, 401, { message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
function handleListDocuments(match, searchParams, body, headers, res) {
|
||||
const docs = [...collectionOf(match[2]).values()]
|
||||
const queries = parseQueries(searchParams)
|
||||
|
||||
let result = docs.filter((doc) =>
|
||||
queries.every((q) => {
|
||||
if (q.method === 'equal') return q.values.includes(doc[q.attribute])
|
||||
if (q.method === 'greaterThan') return doc[q.attribute] > q.values[0]
|
||||
return true
|
||||
})
|
||||
)
|
||||
|
||||
const orderAsc = queries.find((q) => q.method === 'orderAsc')
|
||||
const orderDesc = queries.find((q) => q.method === 'orderDesc')
|
||||
const sortAttr = orderAsc?.attribute || orderDesc?.attribute
|
||||
if (sortAttr) {
|
||||
const dir = orderAsc ? 1 : -1
|
||||
result = [...result].sort((a, b) => {
|
||||
if (a[sortAttr] === b[sortAttr]) return 0
|
||||
return a[sortAttr] > b[sortAttr] ? dir : -dir
|
||||
})
|
||||
}
|
||||
|
||||
const total = result.length
|
||||
const limit = queries.find((q) => q.method === 'limit')
|
||||
if (limit) result = result.slice(0, Number(limit.values[0]))
|
||||
|
||||
send(res, 200, { total, documents: result })
|
||||
}
|
||||
|
||||
function handleCreateDocument(match, searchParams, body, headers, res) {
|
||||
const map = collectionOf(match[2])
|
||||
const documentId = body?.documentId || randomUUID()
|
||||
if (map.has(documentId)) {
|
||||
return send(res, 409, { message: 'Document with the requested ID already exists.' })
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
const doc = { $id: documentId, $createdAt: now, $updatedAt: now, ...(body?.data || {}) }
|
||||
map.set(documentId, doc)
|
||||
send(res, 201, doc)
|
||||
}
|
||||
|
||||
function handleGetDocument(match, searchParams, body, headers, res) {
|
||||
const doc = collectionOf(match[2]).get(match[3])
|
||||
if (!doc) return send(res, 404, { message: 'Document not found' })
|
||||
send(res, 200, doc)
|
||||
}
|
||||
|
||||
function handlePatchDocument(match, searchParams, body, headers, res) {
|
||||
const doc = collectionOf(match[2]).get(match[3])
|
||||
if (!doc) return send(res, 404, { message: 'Document not found' })
|
||||
Object.assign(doc, body?.data || {})
|
||||
doc.$updatedAt = new Date().toISOString()
|
||||
send(res, 200, doc)
|
||||
}
|
||||
|
||||
function handleDeleteDocument(match, searchParams, body, headers, res) {
|
||||
const map = collectionOf(match[2])
|
||||
if (!map.has(match[3])) return send(res, 404, { message: 'Document not found' })
|
||||
map.delete(match[3])
|
||||
send(res, 204)
|
||||
}
|
||||
|
||||
const ROUTES = [
|
||||
{ method: 'GET', re: /^\/v1\/account$/, handler: handleAccount },
|
||||
{
|
||||
method: 'GET',
|
||||
re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents$/,
|
||||
handler: handleListDocuments,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents$/,
|
||||
handler: handleCreateDocument,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents\/([^/]+)$/,
|
||||
handler: handleGetDocument,
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents\/([^/]+)$/,
|
||||
handler: handlePatchDocument,
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents\/([^/]+)$/,
|
||||
handler: handleDeleteDocument,
|
||||
},
|
||||
]
|
||||
|
||||
/* --------------------------------------------------------- Server --- */
|
||||
|
||||
const sockets = new Set()
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks = []
|
||||
req.on('data', (c) => chunks.push(c))
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf8')
|
||||
const url = new URL(req.url, 'http://fake-appwrite.local')
|
||||
let parsedBody = null
|
||||
if (raw) {
|
||||
try {
|
||||
parsedBody = JSON.parse(raw)
|
||||
} catch {
|
||||
parsedBody = null
|
||||
}
|
||||
}
|
||||
calls.push({
|
||||
method: req.method,
|
||||
path: url.pathname,
|
||||
query: Object.fromEntries(url.searchParams),
|
||||
headers: { ...req.headers },
|
||||
body: parsedBody,
|
||||
})
|
||||
|
||||
const failIdx = pendingFailures.findIndex(
|
||||
(f) => f.method === req.method && url.pathname.includes(f.pathIncludes)
|
||||
)
|
||||
if (failIdx !== -1) {
|
||||
const [f] = pendingFailures.splice(failIdx, 1)
|
||||
return send(res, f.status, { message: `fakeAppwrite: erzwungener Fehler (${f.status})` })
|
||||
}
|
||||
|
||||
const route = ROUTES.find((r) => r.method === req.method && r.re.test(url.pathname))
|
||||
if (!route) return send(res, 404, { message: 'Not Found' })
|
||||
const match = route.re.exec(url.pathname)
|
||||
try {
|
||||
route.handler(match, url.searchParams, parsedBody, req.headers, res)
|
||||
} catch (err) {
|
||||
send(res, 500, { message: err?.message || 'fakeAppwrite: interner Fehler' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
server.on('connection', (socket) => {
|
||||
sockets.add(socket)
|
||||
socket.on('close', () => sockets.delete(socket))
|
||||
})
|
||||
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const { port } = server.address()
|
||||
const endpoint = `http://127.0.0.1:${port}/v1`
|
||||
|
||||
/* --------------------------------------------------------- Seeding --- */
|
||||
|
||||
const seededAt = new Date().toISOString()
|
||||
for (const [col, docs] of Object.entries(options.seed || {})) {
|
||||
const map = collectionOf(col)
|
||||
for (const doc of docs || []) {
|
||||
const { $id, $createdAt, $updatedAt, ...fields } = doc
|
||||
const id = $id || randomUUID()
|
||||
map.set(id, { $id: id, $createdAt: $createdAt || seededAt, $updatedAt: $updatedAt || seededAt, ...fields })
|
||||
}
|
||||
}
|
||||
|
||||
function failNext({ method, pathIncludes, status }) {
|
||||
pendingFailures.push({ method, pathIncludes, status })
|
||||
}
|
||||
|
||||
function close() {
|
||||
for (const socket of sockets) socket.destroy()
|
||||
return new Promise((resolve) => server.close(() => resolve()))
|
||||
}
|
||||
|
||||
return { endpoint, all, get, calls, failNext, close }
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
import http from 'node:http'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Nachgebautes Gitea 1.25 fuer Tests: nur der REST-Ausschnitt unter `/api/v1`, den das
|
||||
* Kundenportal nutzt (Repos, Transfer, Hooks, Aktivitaeten-Feed, Contents, Orgs, Teams).
|
||||
* Laeuft auf 127.0.0.1 mit einem vom Betriebssystem vergebenen Port.
|
||||
*
|
||||
* Besitzer-/Repo-Namen werden ueberall ohne Ruecksicht auf Gross-/Kleinschreibung
|
||||
* nachgeschlagen, aber in der zuletzt gesetzten Schreibweise gespeichert - wie Gitea es
|
||||
* auch haelt.
|
||||
*
|
||||
* Ein umgezogenes Repo antwortet unter seinem alten Namen (auch fuer Unterpfade wie
|
||||
* `/hooks`, `/activities/feeds`, `/contents/...`, `/transfer`) mit `301` und einer RELATIVEN
|
||||
* `Location` auf denselben (Unter-)Pfad unter dem neuen Namen - so wie echtes Gitea 1.25.2
|
||||
* (live geprueft: `GET /api/v1/repos/<alt>` -> `301`, `location: /api/v1/repos/<neu>`).
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {Array<{full_name: string, archived?: boolean, private?: boolean, description?: string, default_branch?: string}>} [options.repos]
|
||||
* Anfangsbestand an Repos.
|
||||
* @param {Record<string, Array<{id: number|string, url?: string, config?: {url?: string, content_type?: string}, events?: string[], active?: boolean, type?: string}>>} [options.hooks]
|
||||
* Repo-Webhooks je `"owner/repo"`. `url` ist nur eine Eingabe-Abkuerzung beim Seeden; gespeichert
|
||||
* und ueber die API ausgeliefert wird das Hook-Objekt wie im echten Gitea 1.25 OHNE Top-Level-
|
||||
* `url` (siehe `definitions.Hook` im Produktions-Swagger) - das Ziel steht ausschliesslich unter
|
||||
* `config.url` (uebernimmt das Seed-`url`, wenn `config.url` nicht separat gesetzt wurde).
|
||||
* @param {Record<string, object[]>} [options.feeds]
|
||||
* Aktivitaeten-Feed je `"owner/repo"`, neueste zuerst (wie `GET .../activities/feeds` sie liefert).
|
||||
* @param {Record<string, string>} [options.contents]
|
||||
* Dateiinhalt (Klartext, keine Kodierung) je `"owner/repo:pfad"`, z. B. `"knso/x:.webklar-preview.json"`.
|
||||
* @param {string[]} [options.orgs]
|
||||
* Organisationen, die von Anfang an existieren (mit leerer Owners-Mitgliedschaft).
|
||||
* @param {Record<string, string>} [options.redirects]
|
||||
* Vorab gesetzte Weiterleitungen (alter `"owner/repo"` -> neuer `"owner/repo"`), z. B. fuer ein
|
||||
* bereits von Hand verschobenes Repo, ohne dass dafuer ein Transfer-Aufruf noetig war. Der Ziel-
|
||||
* Repo-Eintrag muss separat ueber `options.repos` angelegt werden.
|
||||
* @param {string} [options.user] Login, das `GET /user` meldet. Voreinstellung `'webklar-system'`.
|
||||
* @returns {Promise<{
|
||||
* baseUrl: string,
|
||||
* calls: Array<{method: string, path: string, query: Record<string, string>, headers: Record<string, string>, body: any}>,
|
||||
* repo: (fullName: string) => (object|undefined),
|
||||
* addRepo: (repo: object) => object,
|
||||
* hooksOf: (fullName: string) => object[],
|
||||
* setDown: (down: boolean) => void,
|
||||
* setTransferDelay: (ms: number) => void,
|
||||
* setResponseDelay: (ms: number) => void,
|
||||
* setTransferNoop: () => void,
|
||||
* failNext: (spec: {method: string, pathIncludes: string, status: number}) => void,
|
||||
* close: () => Promise<void>,
|
||||
* }>} `calls` waechst live, in der Reihenfolge, in der die Anfragen eintreffen - VOR einer
|
||||
* etwaigen `setTransferDelay`/`setResponseDelay`-Verzoegerung der Antwort. `repo()`/`hooksOf()`
|
||||
* lesen den aktuellen Stand direkt (ohne Weiterleitung ueber einen umgezogenen alten Namen).
|
||||
* `failNext({method, pathIncludes, status})` laesst GENAU die naechste Anfrage, deren Methode
|
||||
* passt und deren Pfad `pathIncludes` enthaelt, mit diesem Status und einer generischen
|
||||
* JSON-Fehlermeldung scheitern (danach wieder normales Verhalten) - fuer Tests, die einen
|
||||
* einzelnen Aufruf gezielt fehlschlagen lassen wollen, ohne den ganzen Server abzuschalten.
|
||||
* `setResponseDelay(ms)` verzoegert JEDE Antwort um `ms` (z.B. fuer einen Timeout-Test).
|
||||
* `setTransferNoop()` laesst genau den naechsten Transfer-Aufruf mit 201 und UNVERAENDERTEM
|
||||
* `full_name` antworten (Gitea meldet Erfolg, hat aber nichts verschoben).
|
||||
*/
|
||||
export async function startFakeGitea(options = {}) {
|
||||
const calls = []
|
||||
let isDown = false
|
||||
let transferDelayMs = 0
|
||||
let responseDelayMs = 0
|
||||
let transferNoopNext = false
|
||||
const pendingFailures = []
|
||||
|
||||
let nextRepoId = 1
|
||||
let nextHookId = 1
|
||||
let nextOrgId = 1
|
||||
let nextTeamId = 1
|
||||
|
||||
/** @type {Map<string, object>} Schluessel: `"owner/repo"` klein geschrieben. */
|
||||
const repos = new Map()
|
||||
/** @type {Map<string, string>} alter Schluessel -> neuer full_name (kanonische Schreibweise) nach Transfer. */
|
||||
const redirects = new Map()
|
||||
/** @type {Map<string, object[]>} Schluessel: `"owner/repo"` klein geschrieben. */
|
||||
const hooksByRepo = new Map()
|
||||
/** @type {Map<string, object[]>} Schluessel: `"owner/repo"` klein geschrieben. */
|
||||
const feedsByRepo = new Map()
|
||||
/** @type {Map<string, string>} Schluessel: `"owner/repo"` klein geschrieben + `:` + Pfad (Original-Gross-/Kleinschreibung). */
|
||||
const contents = new Map()
|
||||
/** @type {Map<string, {id: number, username: string, full_name: string, visibility: string, ownersTeamId: number}>} Schluessel: Login klein geschrieben. */
|
||||
const orgs = new Map()
|
||||
/** @type {Map<number, {id: number, name: string, org: string, members: string[]}>} */
|
||||
const teams = new Map()
|
||||
/** @type {Map<string, object[]>} Schluessel: Org-Login klein geschrieben. */
|
||||
const orgHooks = new Map()
|
||||
|
||||
const userLogin = options.user || 'webklar-system'
|
||||
|
||||
function keyFrom(a, b) {
|
||||
return (b === undefined ? a : `${a}/${b}`).toLowerCase()
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
const t = setTimeout(resolve, ms)
|
||||
t.unref?.()
|
||||
})
|
||||
}
|
||||
|
||||
function send(res, status, body) {
|
||||
if (body === undefined) {
|
||||
res.writeHead(status)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const text = JSON.stringify(body)
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(text),
|
||||
})
|
||||
res.end(text)
|
||||
}
|
||||
|
||||
function serializeRepo(record) {
|
||||
return { ...record, owner: { ...record.owner } }
|
||||
}
|
||||
|
||||
function addRepoInternal(def) {
|
||||
const fullName = def?.full_name || ''
|
||||
const slash = fullName.indexOf('/')
|
||||
if (slash < 1) {
|
||||
throw new Error(`fakeGitea: repo full_name muss "owner/repo" sein, bekam "${fullName}"`)
|
||||
}
|
||||
const owner = fullName.slice(0, slash)
|
||||
const name = fullName.slice(slash + 1)
|
||||
const record = {
|
||||
id: nextRepoId++,
|
||||
name,
|
||||
full_name: fullName,
|
||||
owner: { login: owner },
|
||||
html_url: `${baseUrl}/${fullName}`,
|
||||
default_branch: def.default_branch || 'main',
|
||||
archived: Boolean(def.archived),
|
||||
private: Boolean(def.private),
|
||||
description: def.description || '',
|
||||
empty: false,
|
||||
}
|
||||
repos.set(keyFrom(fullName), record)
|
||||
return record
|
||||
}
|
||||
|
||||
/**
|
||||
* Gitea-foermiges Hook-Objekt OHNE Top-Level-`url` (siehe JSDoc oben): das Ziel steht
|
||||
* ausschliesslich in `config.url`. `h.url` ist nur eine Eingabe-Abkuerzung und landet dort,
|
||||
* wenn die Eingabe kein `config.url` mitbringt.
|
||||
*/
|
||||
function normalizeHook(h) {
|
||||
const config = { content_type: 'json', ...(h.config || {}) }
|
||||
if (config.url === undefined) config.url = h.url
|
||||
return {
|
||||
id: h.id,
|
||||
type: h.type || 'gitea',
|
||||
active: h.active !== undefined ? h.active : true,
|
||||
events: h.events || ['push'],
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
function serializeHook(h) {
|
||||
return { ...h, config: { ...h.config } }
|
||||
}
|
||||
|
||||
function createOrgRecord(username, full_name, visibility) {
|
||||
const org = {
|
||||
id: nextOrgId++,
|
||||
username,
|
||||
full_name: full_name || username,
|
||||
visibility: visibility || 'public',
|
||||
ownersTeamId: nextTeamId++,
|
||||
}
|
||||
orgs.set(username.toLowerCase(), org)
|
||||
teams.set(org.ownersTeamId, { id: org.ownersTeamId, name: 'Owners', org: username, members: [] })
|
||||
return org
|
||||
}
|
||||
|
||||
function moveMapKey(map, oldKey, newKey) {
|
||||
if (map.has(oldKey)) {
|
||||
map.set(newKey, map.get(oldKey))
|
||||
map.delete(oldKey)
|
||||
}
|
||||
}
|
||||
|
||||
function moveContentKeys(oldKey, newKey) {
|
||||
const prefix = `${oldKey}:`
|
||||
for (const [k, v] of [...contents.entries()]) {
|
||||
if (k.startsWith(prefix)) {
|
||||
contents.delete(k)
|
||||
contents.set(`${newKey}${k.slice(oldKey.length)}`, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findRepoOrRedirect(owner, name) {
|
||||
const key = keyFrom(owner, name)
|
||||
if (repos.has(key)) return { key, record: repos.get(key) }
|
||||
const redirectTo = redirects.get(key)
|
||||
if (redirectTo) return { key, redirectTo }
|
||||
return null
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Routen --- */
|
||||
|
||||
function handleGetUser(match, searchParams, body, headers, res) {
|
||||
send(res, 200, { login: userLogin, is_admin: true })
|
||||
}
|
||||
|
||||
function handleSearchRepos(match, searchParams, body, headers, res) {
|
||||
const all = [...repos.values()]
|
||||
const limit = Number(searchParams.get('limit')) || 50
|
||||
const page = Number(searchParams.get('page')) || 1
|
||||
const start = (page - 1) * limit
|
||||
send(res, 200, { ok: true, data: all.slice(start, start + limit).map(serializeRepo) })
|
||||
}
|
||||
|
||||
/** 301 mit RELATIVER Location auf denselben (Unter-)Pfad unter dem neuen Namen - wie echtes Gitea. */
|
||||
function sendRepoRedirect(res, newFullName, suffix = '') {
|
||||
res.writeHead(301, { Location: `/api/v1/repos/${newFullName}${suffix}` })
|
||||
res.end()
|
||||
}
|
||||
|
||||
function handleGetRepo(match, searchParams, body, headers, res) {
|
||||
const found = findRepoOrRedirect(match[1], match[2])
|
||||
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo)
|
||||
send(res, 200, serializeRepo(found.record))
|
||||
}
|
||||
|
||||
function handlePatchRepo(match, searchParams, body, headers, res) {
|
||||
const found = findRepoOrRedirect(match[1], match[2])
|
||||
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo)
|
||||
Object.assign(found.record, body || {})
|
||||
send(res, 200, serializeRepo(found.record))
|
||||
}
|
||||
|
||||
async function handleTransfer(match, searchParams, body, headers, res) {
|
||||
if (transferDelayMs > 0) await sleep(transferDelayMs)
|
||||
|
||||
const found = findRepoOrRedirect(match[1], match[2])
|
||||
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, '/transfer')
|
||||
const record = found.record
|
||||
|
||||
// Simuliert eine Gitea-Antwort, die Erfolg meldet, aber tatsaechlich nichts verschoben hat
|
||||
// (Bug-Szenario fuer Controller-Ruling R10 Ein-Zeiler-Absicherung): 201 mit UNVERAENDERTEM
|
||||
// full_name, kein Owner-/Key-Wechsel, keine Weiterleitung.
|
||||
if (transferNoopNext) {
|
||||
transferNoopNext = false
|
||||
return send(res, 201, serializeRepo(record))
|
||||
}
|
||||
|
||||
const key = found.key
|
||||
const newOwner = body?.new_owner
|
||||
if (!newOwner) return send(res, 422, { message: 'new_owner erforderlich' })
|
||||
|
||||
const newFullName = `${newOwner}/${record.name}`
|
||||
const newKey = keyFrom(newOwner, record.name)
|
||||
if (repos.has(newKey)) {
|
||||
return send(res, 422, {
|
||||
message: `repository already exists [uname: ${newOwner}, name: ${record.name}]`,
|
||||
})
|
||||
}
|
||||
|
||||
repos.delete(key)
|
||||
record.owner = { login: newOwner }
|
||||
record.full_name = newFullName
|
||||
repos.set(newKey, record)
|
||||
redirects.set(key, newFullName)
|
||||
moveMapKey(hooksByRepo, key, newKey)
|
||||
moveMapKey(feedsByRepo, key, newKey)
|
||||
moveContentKeys(key, newKey)
|
||||
|
||||
send(res, 202, serializeRepo(record))
|
||||
}
|
||||
|
||||
function handleGetRepoHooks(match, searchParams, body, headers, res) {
|
||||
const found = findRepoOrRedirect(match[1], match[2])
|
||||
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, '/hooks')
|
||||
send(res, 200, (hooksByRepo.get(found.key) || []).map(serializeHook))
|
||||
}
|
||||
|
||||
function handleDeleteRepoHook(match, searchParams, body, headers, res) {
|
||||
const found = findRepoOrRedirect(match[1], match[2])
|
||||
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, `/hooks/${match[3]}`)
|
||||
const list = hooksByRepo.get(found.key)
|
||||
if (!list) return send(res, 404, { message: 'Not Found' })
|
||||
const idx = list.findIndex((h) => String(h.id) === match[3])
|
||||
if (idx === -1) return send(res, 404, { message: 'Not Found' })
|
||||
list.splice(idx, 1)
|
||||
send(res, 204)
|
||||
}
|
||||
|
||||
function handleFeeds(match, searchParams, body, headers, res) {
|
||||
const found = findRepoOrRedirect(match[1], match[2])
|
||||
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, '/activities/feeds')
|
||||
const all = feedsByRepo.get(found.key) || []
|
||||
const limit = Number(searchParams.get('limit')) || 1000
|
||||
const page = Number(searchParams.get('page')) || 1
|
||||
const start = (page - 1) * limit
|
||||
send(res, 200, all.slice(start, start + limit))
|
||||
}
|
||||
|
||||
function handleGetContents(match, searchParams, body, headers, res) {
|
||||
const found = findRepoOrRedirect(match[1], match[2])
|
||||
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, `/contents/${match[3]}`)
|
||||
const record = found.record
|
||||
const filePath = decodeURIComponent(match[3])
|
||||
const text = contents.get(`${found.key}:${filePath}`)
|
||||
if (text === undefined) return send(res, 404, { message: 'Not Found' })
|
||||
const sha = createHash('sha1').update(text).digest('hex')
|
||||
send(res, 200, {
|
||||
name: filePath.split('/').pop(),
|
||||
path: filePath,
|
||||
sha,
|
||||
encoding: 'base64',
|
||||
content: Buffer.from(text, 'utf8').toString('base64'),
|
||||
html_url: `${baseUrl}/${record.full_name}/src/branch/${record.default_branch}/${filePath}`,
|
||||
})
|
||||
}
|
||||
|
||||
function handleWriteContents(match, searchParams, body, headers, res) {
|
||||
const found = findRepoOrRedirect(match[1], match[2])
|
||||
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, `/contents/${match[3]}`)
|
||||
const record = found.record
|
||||
const filePath = decodeURIComponent(match[3])
|
||||
const contentKey = `${found.key}:${filePath}`
|
||||
const existed = contents.has(contentKey)
|
||||
const decoded = Buffer.from(body?.content || '', 'base64').toString('utf8')
|
||||
contents.set(contentKey, decoded)
|
||||
const sha = createHash('sha1').update(decoded).digest('hex')
|
||||
send(res, existed ? 200 : 201, {
|
||||
content: {
|
||||
name: filePath.split('/').pop(),
|
||||
path: filePath,
|
||||
sha,
|
||||
encoding: 'base64',
|
||||
content: body?.content || '',
|
||||
},
|
||||
commit: { sha, html_url: `${baseUrl}/${record.full_name}/commit/${sha}` },
|
||||
})
|
||||
}
|
||||
|
||||
function handleGetOrg(match, searchParams, body, headers, res) {
|
||||
const org = orgs.get(match[1].toLowerCase())
|
||||
if (!org) return send(res, 404, { message: 'Not Found' })
|
||||
send(res, 200, { id: org.id, username: org.username, full_name: org.full_name, visibility: org.visibility })
|
||||
}
|
||||
|
||||
function handleCreateOrg(match, searchParams, body, headers, res) {
|
||||
const username = body?.username
|
||||
if (!username) return send(res, 422, { message: 'username erforderlich' })
|
||||
if (orgs.has(username.toLowerCase())) {
|
||||
return send(res, 422, { message: `user already exists [name: ${username}]` })
|
||||
}
|
||||
const org = createOrgRecord(username, body?.full_name, body?.visibility)
|
||||
send(res, 201, { id: org.id, username: org.username, full_name: org.full_name, visibility: org.visibility })
|
||||
}
|
||||
|
||||
function handleOrgTeams(match, searchParams, body, headers, res) {
|
||||
const org = orgs.get(match[1].toLowerCase())
|
||||
if (!org) return send(res, 404, { message: 'Not Found' })
|
||||
const team = teams.get(org.ownersTeamId)
|
||||
send(res, 200, [{ id: team.id, name: team.name }])
|
||||
}
|
||||
|
||||
function handleTeamMembers(match, searchParams, body, headers, res) {
|
||||
const team = teams.get(Number(match[1]))
|
||||
if (!team) return send(res, 404, { message: 'Not Found' })
|
||||
send(res, 200, team.members.map((login) => ({ login })))
|
||||
}
|
||||
|
||||
function handlePutTeamMember(match, searchParams, body, headers, res) {
|
||||
const team = teams.get(Number(match[1]))
|
||||
if (!team) return send(res, 404, { message: 'Not Found' })
|
||||
const user = match[2]
|
||||
if (!team.members.some((m) => m.toLowerCase() === user.toLowerCase())) {
|
||||
team.members.push(user)
|
||||
}
|
||||
send(res, 204)
|
||||
}
|
||||
|
||||
function handleGetOrgHooks(match, searchParams, body, headers, res) {
|
||||
const org = orgs.get(match[1].toLowerCase())
|
||||
if (!org) return send(res, 404, { message: 'Not Found' })
|
||||
send(res, 200, orgHooks.get(org.username.toLowerCase()) || [])
|
||||
}
|
||||
|
||||
function handlePostOrgHooks(match, searchParams, body, headers, res) {
|
||||
const org = orgs.get(match[1].toLowerCase())
|
||||
if (!org) return send(res, 404, { message: 'Not Found' })
|
||||
const key = org.username.toLowerCase()
|
||||
const list = orgHooks.get(key) || []
|
||||
const hook = { id: nextHookId++, ...body }
|
||||
list.push(hook)
|
||||
orgHooks.set(key, list)
|
||||
send(res, 201, hook)
|
||||
}
|
||||
|
||||
const ROUTES = [
|
||||
{ method: 'GET', re: /^\/api\/v1\/user$/, handler: handleGetUser },
|
||||
{ method: 'GET', re: /^\/api\/v1\/repos\/search$/, handler: handleSearchRepos },
|
||||
{ method: 'POST', re: /^\/api\/v1\/orgs$/, handler: handleCreateOrg },
|
||||
{ method: 'GET', re: /^\/api\/v1\/orgs\/([^/]+)\/teams$/, handler: handleOrgTeams },
|
||||
{ method: 'GET', re: /^\/api\/v1\/orgs\/([^/]+)\/hooks$/, handler: handleGetOrgHooks },
|
||||
{ method: 'POST', re: /^\/api\/v1\/orgs\/([^/]+)\/hooks$/, handler: handlePostOrgHooks },
|
||||
{ method: 'GET', re: /^\/api\/v1\/orgs\/([^/]+)$/, handler: handleGetOrg },
|
||||
{ method: 'GET', re: /^\/api\/v1\/teams\/([^/]+)\/members$/, handler: handleTeamMembers },
|
||||
{ method: 'PUT', re: /^\/api\/v1\/teams\/([^/]+)\/members\/([^/]+)$/, handler: handlePutTeamMember },
|
||||
{ method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/transfer$/, handler: handleTransfer },
|
||||
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks$/, handler: handleGetRepoHooks },
|
||||
{ method: 'DELETE', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks\/([^/]+)$/, handler: handleDeleteRepoHook },
|
||||
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/activities\/feeds$/, handler: handleFeeds },
|
||||
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleGetContents },
|
||||
{ method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents },
|
||||
{ method: 'PUT', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents },
|
||||
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)$/, handler: handleGetRepo },
|
||||
{ method: 'PATCH', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)$/, handler: handlePatchRepo },
|
||||
]
|
||||
|
||||
/* --------------------------------------------------------- Server --- */
|
||||
|
||||
const sockets = new Set()
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks = []
|
||||
req.on('data', (c) => chunks.push(c))
|
||||
req.on('end', async () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf8')
|
||||
const url = new URL(req.url, 'http://fake-gitea.local')
|
||||
let parsedBody = null
|
||||
if (raw) {
|
||||
try {
|
||||
parsedBody = JSON.parse(raw)
|
||||
} catch {
|
||||
parsedBody = null
|
||||
}
|
||||
}
|
||||
calls.push({
|
||||
method: req.method,
|
||||
path: url.pathname,
|
||||
query: Object.fromEntries(url.searchParams),
|
||||
headers: { ...req.headers },
|
||||
body: parsedBody,
|
||||
})
|
||||
|
||||
if (responseDelayMs > 0) await sleep(responseDelayMs)
|
||||
|
||||
const failIdx = pendingFailures.findIndex(
|
||||
(f) => f.method === req.method && url.pathname.includes(f.pathIncludes)
|
||||
)
|
||||
if (failIdx !== -1) {
|
||||
const [f] = pendingFailures.splice(failIdx, 1)
|
||||
return send(res, f.status, { message: `fakeGitea: erzwungener Fehler (${f.status})` })
|
||||
}
|
||||
|
||||
const route = ROUTES.find((r) => r.method === req.method && r.re.test(url.pathname))
|
||||
if (!route) return send(res, 404, { message: 'Not Found' })
|
||||
const match = route.re.exec(url.pathname)
|
||||
try {
|
||||
await route.handler(match, url.searchParams, parsedBody, req.headers, res)
|
||||
} catch (err) {
|
||||
send(res, 500, { message: err?.message || 'fakeGitea: interner Fehler' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
server.on('connection', (socket) => {
|
||||
sockets.add(socket)
|
||||
socket.on('close', () => sockets.delete(socket))
|
||||
if (isDown) socket.destroy()
|
||||
})
|
||||
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const { port } = server.address()
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
|
||||
/* --------------------------------------------------------- Seeding --- */
|
||||
|
||||
for (const def of options.repos || []) addRepoInternal(def)
|
||||
for (const [fullName, list] of Object.entries(options.hooks || {})) {
|
||||
hooksByRepo.set(keyFrom(fullName), (list || []).map(normalizeHook))
|
||||
}
|
||||
for (const [fullName, feed] of Object.entries(options.feeds || {})) {
|
||||
feedsByRepo.set(keyFrom(fullName), feed || [])
|
||||
}
|
||||
for (const [seedKey, text] of Object.entries(options.contents || {})) {
|
||||
const idx = seedKey.indexOf(':')
|
||||
const fullName = idx === -1 ? seedKey : seedKey.slice(0, idx)
|
||||
const filePath = idx === -1 ? '' : seedKey.slice(idx + 1)
|
||||
contents.set(`${keyFrom(fullName)}:${filePath}`, text)
|
||||
}
|
||||
for (const name of options.orgs || []) createOrgRecord(name)
|
||||
for (const [from, to] of Object.entries(options.redirects || {})) {
|
||||
redirects.set(keyFrom(from), to)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ oeffentlich --- */
|
||||
|
||||
function repo(fullName) {
|
||||
const record = repos.get(keyFrom(fullName))
|
||||
return record ? serializeRepo(record) : undefined
|
||||
}
|
||||
|
||||
function addRepo(def) {
|
||||
return serializeRepo(addRepoInternal(def))
|
||||
}
|
||||
|
||||
function hooksOf(fullName) {
|
||||
return (hooksByRepo.get(keyFrom(fullName)) || []).map(serializeHook)
|
||||
}
|
||||
|
||||
function setDown(down) {
|
||||
isDown = Boolean(down)
|
||||
if (isDown) {
|
||||
for (const socket of sockets) socket.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
function setTransferDelay(ms) {
|
||||
transferDelayMs = Number(ms) || 0
|
||||
}
|
||||
|
||||
/** Verzoegert JEDE Antwort um `ms` (z.B. um einen `GITEA_TIMEOUT_MS`-Abbruch zu provozieren). */
|
||||
function setResponseDelay(ms) {
|
||||
responseDelayMs = Number(ms) || 0
|
||||
}
|
||||
|
||||
/** Laesst den naechsten Transfer-Aufruf 201 mit UNVERAENDERTEM full_name melden (kein Umzug). */
|
||||
function setTransferNoop() {
|
||||
transferNoopNext = true
|
||||
}
|
||||
|
||||
function failNext({ method, pathIncludes, status }) {
|
||||
pendingFailures.push({ method, pathIncludes, status })
|
||||
}
|
||||
|
||||
function close() {
|
||||
for (const socket of sockets) socket.destroy()
|
||||
return new Promise((resolve) => server.close(() => resolve()))
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
calls,
|
||||
repo,
|
||||
addRepo,
|
||||
hooksOf,
|
||||
setDown,
|
||||
setTransferDelay,
|
||||
setResponseDelay,
|
||||
setTransferNoop,
|
||||
failNext,
|
||||
close,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { withLocks } from '../server/services/keyedLock.js'
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
let log
|
||||
beforeEach(() => {
|
||||
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)
|
||||
})
|
||||
test('Timeout eines Wartenden hebt die Sperre nicht auf', async () => {
|
||||
const call3 = (async () => {
|
||||
await sleep(100)
|
||||
await withLocks(['a'], job('3', 10))
|
||||
})()
|
||||
await Promise.all([
|
||||
withLocks(['a'], job('1', 300)),
|
||||
withLocks(['a'], job('2', 10), { waitMs: 50 }),
|
||||
call3,
|
||||
])
|
||||
assert.ok(log.indexOf('3:start') > log.indexOf('1:end'), `log: ${log.join(',')}`)
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
import { test, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { startFakeGitea } from './helpers/fakeGitea.js'
|
||||
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
|
||||
|
||||
let gitea
|
||||
let aw
|
||||
let tmp
|
||||
let originalCwd
|
||||
let assignProject
|
||||
let reconcileProject
|
||||
|
||||
const now = new Date('2026-09-27T16:00:00Z')
|
||||
|
||||
let nextFeedId = 1
|
||||
|
||||
/**
|
||||
* Baut einen Gitea-1.25-Feed-Eintrag vom Typ `commit_repo` nach (ein Commit), siehe
|
||||
* `test/pushHistory.test.js` fuer denselben Aufbau: `content` traegt das JSON
|
||||
* `{Commits:[...], HeadCommit:{...}}` als String, wie im echten Aktivitaeten-Feed.
|
||||
*/
|
||||
function pushEntry({ sha, at, user, msg }) {
|
||||
const login = user || 'JUSN'
|
||||
const commit = {
|
||||
Sha1: sha,
|
||||
Message: msg || `Commit ${sha}`,
|
||||
AuthorEmail: `${login.toLowerCase()}@example.com`,
|
||||
AuthorName: login,
|
||||
CommitterEmail: `${login.toLowerCase()}@example.com`,
|
||||
CommitterName: login,
|
||||
Timestamp: at,
|
||||
}
|
||||
return {
|
||||
id: nextFeedId++,
|
||||
op_type: 'commit_repo',
|
||||
act_user: { login, full_name: '' },
|
||||
ref_name: 'refs/heads/main',
|
||||
created: at,
|
||||
content: JSON.stringify({ Commits: [commit], HeadCommit: { Sha1: sha }, CompareURL: '', Len: 1 }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wartet, bis `aw.calls` mindestens `count` Eintraege hat - deterministisch, kein fester Timeout.
|
||||
* `calls.push` passiert server-seitig, SOBALD eine Anfrage beim Fake-Appwrite ankommt - also
|
||||
* strikt BEVOR die Antwort beim Aufrufer eintrifft und dieser (falls ueberhaupt) die naechste
|
||||
* Anfrage lostritt. Damit laesst sich `failNext` gezielt auf einen bestimmten von mehreren gleich
|
||||
* aussehenden Aufrufen ansetzen (z. B. das zweite GET desselben Dokuments), ohne zu raten.
|
||||
*/
|
||||
function waitForAppwriteCalls(count) {
|
||||
return new Promise((resolve) => {
|
||||
;(function check() {
|
||||
if (aw.calls.length >= count) return resolve()
|
||||
setImmediate(check)
|
||||
})()
|
||||
})
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
gitea = await startFakeGitea({
|
||||
repos: [
|
||||
{ full_name: 'JUSN/D-ner-Nation' },
|
||||
{ full_name: 'JUSN/leer' },
|
||||
{ full_name: 'JUSN/nur-kunde' },
|
||||
{ full_name: 'JUSN/doppelt' },
|
||||
{ full_name: 'WEBklar/ticket-fail' },
|
||||
{ full_name: 'WEBklar/worksheet-fail' },
|
||||
{ full_name: 'JUSN/reread-fail' },
|
||||
{ full_name: 'WEBklar/inlock-fail' },
|
||||
],
|
||||
feeds: {
|
||||
// Newest first, wie der echte Gitea-Feed.
|
||||
'WEBklar/ticket-fail': [pushEntry({ sha: 'tf1', user: 'JUSN', at: '2026-09-27T10:00:00Z', msg: 'Ticket-Fail-Push' })],
|
||||
'WEBklar/worksheet-fail': [pushEntry({ sha: 'wf1', user: 'JUSN', at: '2026-09-27T10:00:00Z', msg: 'Worksheet-Fail-Push' })],
|
||||
'JUSN/D-ner-Nation': [
|
||||
pushEntry({ sha: 's1', user: 'webklar-system', at: '2026-09-27T14:50:00Z', msg: 'System-Push' }),
|
||||
pushEntry({ sha: 'k1', user: 'knso', at: '2026-09-27T14:48:00Z', msg: 'Update preview config from ticket system' }),
|
||||
pushEntry({ sha: '26bc22ecf17e2e5a5a8794dcb4cf3892f802c51d', user: 'JUSN', at: '2026-09-27T14:46:34Z', msg: 'Bilder aktualisiert' }),
|
||||
{ ...pushEntry({ sha: 'x1', at: '2026-09-27T14:46:33Z' }), content: '' },
|
||||
{ id: nextFeedId++, op_type: 'create_repo', created: '2026-09-27T14:02:46Z', act_user: { login: 'JUSN' } },
|
||||
],
|
||||
'JUSN/nur-kunde': [pushEntry({ sha: 'n1', user: 'JUSN', at: '2026-09-27T12:00:00Z', msg: 'Kundenseite live' })],
|
||||
'JUSN/doppelt': [pushEntry({ sha: 'd1', user: 'JUSN', at: '2026-09-27T11:00:00Z', msg: 'Doppelt-Push' })],
|
||||
'JUSN/leer': [],
|
||||
},
|
||||
})
|
||||
|
||||
aw = await startFakeAppwrite({
|
||||
seed: {
|
||||
websiteProjects: [
|
||||
{ $id: 'p1', repoFullName: 'JUSN/D-ner-Nation', projectName: 'D-ner-Nation', customerId: '', ticketId: '' },
|
||||
{ $id: 'p2', repoFullName: 'JUSN/leer', projectName: 'leer', customerId: '', ticketId: '' },
|
||||
{ $id: 'p3', repoFullName: 'JUSN/nur-kunde', projectName: 'nur-kunde', customerId: '', ticketId: '' },
|
||||
{ $id: 'p4', repoFullName: 'JUSN/doppelt', projectName: 'doppelt', customerId: '', ticketId: '' },
|
||||
{ $id: 'p5', repoFullName: 'WEBklar/ticket-fail', projectName: 'ticket-fail', customerId: '', ticketId: '' },
|
||||
{ $id: 'p6', repoFullName: 'WEBklar/worksheet-fail', projectName: 'worksheet-fail', customerId: '', ticketId: '' },
|
||||
{ $id: 'p7', repoFullName: 'JUSN/reread-fail', projectName: 'reread-fail', customerId: '', ticketId: '' },
|
||||
{ $id: 'p8', repoFullName: 'WEBklar/inlock-fail', projectName: 'inlock-fail', customerId: '', ticketId: '' },
|
||||
],
|
||||
workorders: [{ $id: 't1', woid: '100115' }],
|
||||
customers: [{ $id: 'c1', name: 'Döner Nation' }],
|
||||
},
|
||||
})
|
||||
|
||||
originalCwd = process.cwd()
|
||||
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'project-assignment-'))
|
||||
process.chdir(tmp)
|
||||
|
||||
Object.assign(process.env, {
|
||||
GITEA_BASE_URL: gitea.baseUrl,
|
||||
GITEA_API_TOKEN: 'test-token',
|
||||
APPWRITE_ENDPOINT: aw.endpoint,
|
||||
APPWRITE_API_KEY: 'test-key',
|
||||
PREVIEW_DEPLOY_ROOT: '',
|
||||
TRAEFIK_DYNAMIC_DIR: '',
|
||||
})
|
||||
// config.js liest die Umgebung beim Import
|
||||
;({ assignProject, reconcileProject } = await import('../server/services/projectAssignment.js'))
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
process.chdir(originalCwd)
|
||||
await gitea.close()
|
||||
await aw.close()
|
||||
await fs.rm(tmp, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
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 () => {
|
||||
// ticketAdmin loggt die automatische Ticket-Anlage (console.log) - hier abfangen, damit die
|
||||
// Testausgabe sauber bleibt (Ruling, Fix-Runde 1).
|
||||
const originalLog = console.log
|
||||
console.log = () => {}
|
||||
let r
|
||||
try {
|
||||
r = await assignProject('p3', { customerId: 'c1' }, { now })
|
||||
} finally {
|
||||
console.log = originalLog
|
||||
}
|
||||
const stored = aw.get('websiteProjects', 'p3')
|
||||
const t = aw.all('workorders').find((w) => w.$id === stored.ticketId)
|
||||
assert.equal(t.type, 'Webpage'); assert.equal(r.backfilled, 1)
|
||||
// I2: die Antwort muss die gerade erst automatisch angelegte ticketId zeigen, nicht den Stand
|
||||
// von vor dem Anlegen.
|
||||
assert.equal(r.project.ticketId, stored.ticketId)
|
||||
})
|
||||
test('Kunde erneut zuweisen ohne Ticket: kein zweites Ticket, kein doppeltes Arbeitsblatt', async () => {
|
||||
// p3 hat aus dem vorigen Test bereits ein automatisch angelegtes Ticket - ein verzoegerter
|
||||
// zweiter Klick (gleicher Kunde, keine ticketId) darf es nicht durch ein leeres ueberschreiben
|
||||
// (Review I3). "Verzoegert" reicht hier als einfaches Abwarten des ersten Aufrufs.
|
||||
const before = { workorders: aw.all('workorders').length, worksheets: aw.all('worksheets').length }
|
||||
const ticketIdBefore = aw.get('websiteProjects', 'p3').ticketId
|
||||
const r = await assignProject('p3', { customerId: 'c1' }, { now })
|
||||
assert.equal(aw.all('workorders').length, before.workorders)
|
||||
assert.equal(aw.all('worksheets').length, before.worksheets)
|
||||
assert.equal(aw.get('websiteProjects', 'p3').ticketId, ticketIdBefore)
|
||||
assert.equal(r.project.ticketId, ticketIdBefore)
|
||||
})
|
||||
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 () => {
|
||||
// Diese Zuweisung loest bewusst einen Gitea-Ausfall aus (`deletePortalHooks` in `repoMove.js`
|
||||
// loggt einen fehlgeschlagenen Hook-Abruf mit console.error) - Konsole hier abfangen, damit
|
||||
// die Testausgabe sauber bleibt.
|
||||
const originalError = console.error
|
||||
const originalWarn = console.warn
|
||||
console.error = () => {}
|
||||
console.warn = () => {}
|
||||
gitea.setDown(true)
|
||||
let r
|
||||
try {
|
||||
r = await assignProject('p2', { customerId: 'c1', ticketId: 't1' }, { now })
|
||||
} finally {
|
||||
gitea.setDown(false)
|
||||
console.error = originalError
|
||||
console.warn = originalWarn
|
||||
}
|
||||
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)
|
||||
})
|
||||
test('Appwrite-Ausfall beim Ticket-Anlegen: Zuweisung bleibt, Warnung statt Abbruch', async () => {
|
||||
aw.failNext({ method: 'POST', pathIncludes: 'workorders', status: 500 })
|
||||
const r = await assignProject('p5', { customerId: 'c1' }, { now })
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(aw.get('websiteProjects', 'p5').customerId, 'c1')
|
||||
assert.match(r.warnings[0], /^Pushes konnten nicht nachgetragen werden \(/)
|
||||
})
|
||||
test('Appwrite-Ausfall beim Arbeitsblatt-Anlegen: Zuweisung bleibt, Warnung statt Abbruch', async () => {
|
||||
aw.failNext({ method: 'POST', pathIncludes: 'worksheets', status: 500 })
|
||||
const r = await assignProject('p6', { customerId: 'c1', ticketId: 't1' }, { now })
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(aw.get('websiteProjects', 'p6').ticketId, 't1')
|
||||
assert.match(r.warnings[0], /^Pushes konnten nicht nachgetragen werden \(/)
|
||||
})
|
||||
test('Nachlesen nach Umzug schlaegt fehl: Antwort zeigt trotzdem den neuen Namen', async () => {
|
||||
// p7 zieht hier tatsaechlich um (JUSN -> WEBklar); das GET danach (Controller-Ruling R13/R14)
|
||||
// soll scheitern. Gezielt getimt: warten, bis das GET VOR dem Umzug und das PATCH AUS dem
|
||||
// Umzug (`updateProjectDocument` in repoMove.js) durch sind, dann erst failNext scharf machen -
|
||||
// so trifft es sicher das GET NACH dem Umzug und keines der beiden vorherigen.
|
||||
const target = aw.calls.length + 2
|
||||
const p = reconcileProject('p7', { now })
|
||||
await waitForAppwriteCalls(target)
|
||||
aw.failNext({ method: 'GET', pathIncludes: 'p7', status: 500 })
|
||||
const r = await p
|
||||
assert.deepEqual(r.moved, { from: 'JUSN/reread-fail', to: 'WEBklar/reread-fail' })
|
||||
assert.deepEqual(r.warnings, [])
|
||||
assert.equal(r.project.repoFullName, 'WEBklar/reread-fail')
|
||||
assert.equal(r.project.giteaRepoUrl, `${gitea.baseUrl}/WEBklar/reread-fail`)
|
||||
assert.equal(r.project.giteaRepoName, 'reread-fail')
|
||||
})
|
||||
test('Appwrite-Fehler beim Nachlesen unter der Sperre in assignProject: Zuweisung bleibt, Warnung statt Abbruch', async () => {
|
||||
// Gezielt getimt wie oben: warten, bis die Existenzpruefung und das Speichern der Zuweisung
|
||||
// (beide in `assignProject`) durch sind, dann erst failNext scharf machen - so trifft es sicher
|
||||
// das GET, das der Reconcile-Kern (`reconcileProjectLocked`) direkt danach selbst macht.
|
||||
const target = aw.calls.length + 2
|
||||
const p = assignProject('p8', { customerId: 'c1' }, { now })
|
||||
await waitForAppwriteCalls(target)
|
||||
aw.failNext({ method: 'GET', pathIncludes: 'p8', status: 500 })
|
||||
const r = await p
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.moved, null)
|
||||
assert.equal(r.backfilled, 0)
|
||||
assert.equal(aw.get('websiteProjects', 'p8').customerId, 'c1')
|
||||
assert.match(r.warnings[0], /^Zuweisung gespeichert, aber Umzug und Nachtragen sind fehlgeschlagen \(/)
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import { test, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startFakeGitea } from './helpers/fakeGitea.js'
|
||||
|
||||
let fake
|
||||
let pushesFromFeed
|
||||
let listRecentPushes
|
||||
|
||||
const now = new Date('2026-09-27T12:00:00Z')
|
||||
|
||||
let nextFeedId = 1
|
||||
|
||||
/**
|
||||
* Baut einen Gitea-1.25-Feed-Eintrag vom Typ `commit_repo` nach: `{ op_type, act_user,
|
||||
* ref_name, created, content }`, wobei `content` das JSON `{Commits:[...], HeadCommit:{...}}`
|
||||
* als String enthaelt (siehe Task-Brief).
|
||||
*
|
||||
* Optionen: `sha` (ein Commit) oder `commits` (mehrere Shas, in Feed-Reihenfolge), `head`
|
||||
* (HeadCommit-Sha; `null` fuer "kein HeadCommit"; Voreinstellung: erster Commit), `at`
|
||||
* (created/Timestamp, ISO-String), `user` (act_user.login), `fullName` (act_user.full_name),
|
||||
* `msg` (Commit-Message, sonst generisch).
|
||||
*/
|
||||
function pushEntry({ sha, commits, head, at, user, fullName, msg } = {}) {
|
||||
const shas = commits || [sha ?? 'c0']
|
||||
const login = user || 'JUSN'
|
||||
const created = at || '2026-09-27T00:00:00Z'
|
||||
|
||||
const builtCommits = shas.map((s) => ({
|
||||
Sha1: s,
|
||||
Message: msg || `Commit ${s}`,
|
||||
AuthorEmail: `${login.toLowerCase()}@example.com`,
|
||||
AuthorName: login,
|
||||
CommitterEmail: `${login.toLowerCase()}@example.com`,
|
||||
CommitterName: login,
|
||||
Timestamp: created,
|
||||
}))
|
||||
const headSha = head === undefined ? shas[0] : head
|
||||
|
||||
return {
|
||||
id: nextFeedId++,
|
||||
op_type: 'commit_repo',
|
||||
act_user: { login, full_name: fullName || '' },
|
||||
ref_name: 'refs/heads/main',
|
||||
created,
|
||||
content: JSON.stringify({
|
||||
Commits: builtCommits,
|
||||
HeadCommit: headSha ? { Sha1: headSha } : null,
|
||||
CompareURL: '',
|
||||
Len: builtCommits.length,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Push-Feed-Eintrag `days` Tage vor `base`, ein Commit mit Sha `sha`. */
|
||||
function daysAgo(base, days, sha) {
|
||||
return pushEntry({ sha, at: new Date(base.getTime() - days * 864e5).toISOString() })
|
||||
}
|
||||
|
||||
/** 120 Eintraege, 3 pro Tag ueber 40 Tage, neueste zuerst (fuer die Blaetter-Grenze). */
|
||||
function manyPushes(base) {
|
||||
const entries = []
|
||||
for (let d = 0; d < 40; d++) {
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const at = new Date(base.getTime() - d * 864e5 - (1 + 2 * k) * 36e5).toISOString()
|
||||
entries.push(pushEntry({ sha: `d${d}k${k}`, at }))
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Mehr als 40*50 Eintraege, alle nur Minuten auseinander und damit weit innerhalb jedes
|
||||
* 30-Tage-Fensters - fuer den Test der Blaetter-Obergrenze (die Seite wird nie leer/kurz,
|
||||
* und der "aeltester Eintrag vor since"-Stopp greift nie).
|
||||
*/
|
||||
function packedPushes(base, count) {
|
||||
const entries = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
entries.push(pushEntry({ sha: `p${i}`, at: new Date(base.getTime() - i * 60000).toISOString() }))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
fake = await startFakeGitea({
|
||||
repos: [{ full_name: 'knso/viel' }, { full_name: 'knso/endlos' }],
|
||||
feeds: {
|
||||
'knso/viel': manyPushes(now),
|
||||
'knso/endlos': packedPushes(now, 2005),
|
||||
},
|
||||
})
|
||||
Object.assign(process.env, {
|
||||
GITEA_BASE_URL: fake.baseUrl,
|
||||
GITEA_API_TOKEN: 'test-token',
|
||||
})
|
||||
// config.js liest die Umgebung beim Import
|
||||
;({ pushesFromFeed, listRecentPushes } = await import('../server/services/pushHistory.js'))
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await fake.close()
|
||||
})
|
||||
|
||||
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('HeadCommit hat Vorrang vor dem ersten Commit', () => {
|
||||
const [p] = pushesFromFeed([pushEntry({ commits: ['c1', 'c2'], head: 'c2' })], { since: new Date(0) })
|
||||
assert.equal(p.commitSha, 'c2')
|
||||
})
|
||||
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)
|
||||
})
|
||||
test('Blättern hat eine Obergrenze', async () => {
|
||||
// Feed liefert auf jeder Seite 50 volle, aktuelle Einträge - ohne Obergrenze würde
|
||||
// listRecentPushes endlos weiterblättern.
|
||||
const originalWarn = console.warn
|
||||
console.warn = () => {}
|
||||
let r
|
||||
try {
|
||||
r = await listRecentPushes('knso/endlos', { now })
|
||||
} finally {
|
||||
console.warn = originalWarn
|
||||
}
|
||||
assert.equal(r.length, 2000)
|
||||
assert.equal(fake.calls.filter((c) => c.path.includes('/knso/endlos/activities/feeds')).length, 40)
|
||||
})
|
||||
@@ -0,0 +1,238 @@
|
||||
import { test, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { startFakeGitea } from './helpers/fakeGitea.js'
|
||||
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
|
||||
|
||||
let gitea
|
||||
let aw
|
||||
let tmp
|
||||
let originalCwd
|
||||
let previewWorkDir
|
||||
let moveRepoToOrg
|
||||
let isOrgRepo
|
||||
let isInternalRepo
|
||||
let repoLockKey
|
||||
let currentRepoFullName
|
||||
|
||||
// Schluessel: urspruenglich gesetzter repoFullName -> Appwrite-$id. Wird beim Seeden befuellt
|
||||
// (siehe Controller-Ruling R2) - eine Live-Suche ueber repoFullName wuerde nach einem Umzug
|
||||
// nichts mehr finden, weil sich das Feld dann geaendert hat.
|
||||
const idByRepo = new Map()
|
||||
|
||||
/** Liefert das gespeicherte Dokument des Repos, ueber den zur Seed-Zeit gebauten Id-Index. */
|
||||
function doc(name) {
|
||||
return aw.get('websiteProjects', idByRepo.get(name))
|
||||
}
|
||||
|
||||
const PORTAL_HOOK_URL = 'https://project.webklar.com/webhook/gitea'
|
||||
|
||||
before(async () => {
|
||||
gitea = await startFakeGitea({
|
||||
repos: [
|
||||
{ full_name: 'JUSN/D-ner-Nation' },
|
||||
{ full_name: 'knso/emmas-schatzkiste' },
|
||||
{ full_name: 'ANDJ/Steuerungstechnik' },
|
||||
{ full_name: 'knso/hytail-ark' },
|
||||
{ full_name: 'WEBklar/schon-da' },
|
||||
{ full_name: 'JUSN/schon-da' },
|
||||
{ full_name: 'WEBklar/von-hand' },
|
||||
{ full_name: 'ANDJ/unberuehrt' },
|
||||
{ full_name: 'JUSN/doc-fail-test' },
|
||||
{ full_name: 'JUSN/verboten' },
|
||||
{ full_name: 'JUSN/Case-Test' },
|
||||
{ full_name: 'knso/rausgezogen' },
|
||||
],
|
||||
// C1: Hooks kommen (wie im echten Gitea) OHNE Top-Level-`url` an - `url` hier ist nur die
|
||||
// Seed-Abkuerzung, gespeichert/ausgeliefert wird das Ziel ausschliesslich unter `config.url`.
|
||||
hooks: {
|
||||
'knso/emmas-schatzkiste': [{ id: 110, url: PORTAL_HOOK_URL }],
|
||||
'knso/hytail-ark': [{ id: 7, url: 'https://webklar.com/webhook/hytail-ark/deploy' }],
|
||||
'WEBklar/von-hand': [{ id: 210, url: PORTAL_HOOK_URL }],
|
||||
'WEBklar/schon-da': [{ id: 310, url: PORTAL_HOOK_URL }],
|
||||
'JUSN/doc-fail-test': [{ id: 410, url: PORTAL_HOOK_URL }],
|
||||
'knso/rausgezogen': [{ id: 510, url: PORTAL_HOOK_URL }],
|
||||
},
|
||||
// knso/von-hand wurde von Hand bereits nach WEBklar/von-hand verschoben (kein Transfer-Aufruf noetig).
|
||||
// WEBklar/rausgezogen wurde von Hand AUS der Organisation heraus nach knso/rausgezogen verschoben
|
||||
// (final-fix Punkt 2: Schritt-1-Aufraeumen darf dieser Weiterleitung nicht folgen).
|
||||
redirects: {
|
||||
'knso/von-hand': 'WEBklar/von-hand',
|
||||
'WEBklar/rausgezogen': 'knso/rausgezogen',
|
||||
},
|
||||
})
|
||||
|
||||
const repoNames = [
|
||||
'JUSN/D-ner-Nation',
|
||||
'knso/emmas-schatzkiste',
|
||||
'ANDJ/Steuerungstechnik',
|
||||
'knso/hytail-ark',
|
||||
'WEBklar/schon-da',
|
||||
'JUSN/schon-da',
|
||||
'knso/von-hand', // Appwrite kennt noch den alten Namen (siehe Brief)
|
||||
'ANDJ/unberuehrt',
|
||||
'JUSN/doc-fail-test',
|
||||
'JUSN/verboten',
|
||||
'jusn/case-test', // Appwrite kennt eine andere Schreibweise als Gitea (dort: JUSN/Case-Test)
|
||||
'WEBklar/rausgezogen', // Appwrite kennt noch den WEBklar-Namen, Gitea hat es herausverschoben
|
||||
]
|
||||
const websiteProjects = repoNames.map((repoFullName) => {
|
||||
const $id = randomUUID()
|
||||
idByRepo.set(repoFullName, $id)
|
||||
return { $id, repoFullName, projectName: repoFullName.split('/').pop() }
|
||||
})
|
||||
|
||||
aw = await startFakeAppwrite({ seed: { websiteProjects } })
|
||||
|
||||
originalCwd = process.cwd()
|
||||
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-move-'))
|
||||
process.chdir(tmp)
|
||||
|
||||
Object.assign(process.env, {
|
||||
GITEA_BASE_URL: gitea.baseUrl,
|
||||
GITEA_API_TOKEN: 'test-token',
|
||||
APPWRITE_ENDPOINT: aw.endpoint,
|
||||
APPWRITE_API_KEY: 'test-key',
|
||||
PREVIEW_DEPLOY_ROOT: '',
|
||||
TRAEFIK_DYNAMIC_DIR: '',
|
||||
})
|
||||
// config.js liest die Umgebung beim Import
|
||||
;({ previewWorkDir } = await import('../server/services/previewDeploy.js'))
|
||||
;({ moveRepoToOrg, isOrgRepo, isInternalRepo, repoLockKey, currentRepoFullName } = await import(
|
||||
'../server/services/repoMove.js'
|
||||
))
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
process.chdir(originalCwd)
|
||||
await gitea.close()
|
||||
await aw.close()
|
||||
await fs.rm(tmp, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const r = await moveRepoToOrg(doc('knso/emmas-schatzkiste'))
|
||||
assert.deepEqual(r.moved, { from: 'knso/emmas-schatzkiste', to: 'WEBklar/emmas-schatzkiste' })
|
||||
assert.deepEqual(gitea.hooksOf('WEBklar/emmas-schatzkiste'), [])
|
||||
assert.equal(
|
||||
gitea.calls.some(
|
||||
(c) => c.method === 'DELETE' && c.path === '/api/v1/repos/WEBklar/emmas-schatzkiste/hooks/110'
|
||||
),
|
||||
true
|
||||
)
|
||||
})
|
||||
test('docNotUpdated: Repo zieht trotzdem um, Portal-Webhook wird trotzdem geloescht', async () => {
|
||||
aw.failNext({ method: 'PATCH', pathIncludes: doc('JUSN/doc-fail-test').$id, status: 500 })
|
||||
const r = await moveRepoToOrg(doc('JUSN/doc-fail-test'))
|
||||
assert.deepEqual(r.moved, { from: 'JUSN/doc-fail-test', to: 'WEBklar/doc-fail-test' })
|
||||
assert.match(r.warnings[0], /^Das Repo liegt jetzt in WEBklar/)
|
||||
assert.deepEqual(gitea.hooksOf('WEBklar/doc-fail-test'), [])
|
||||
})
|
||||
test('schon in WEBklar: nichts passiert', async () => {
|
||||
const r = await moveRepoToOrg(doc('WEBklar/schon-da'))
|
||||
assert.deepEqual(r, { moved: null, warnings: [] })
|
||||
// R11: "Pushes nachtragen" raeumt auch bei einem schon in WEBklar liegenden Repo einen liegen
|
||||
// gebliebenen Einzel-Webhook auf.
|
||||
assert.deepEqual(gitea.hooksOf('WEBklar/schon-da'), [])
|
||||
})
|
||||
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('R10: 403 beim Transfer', async () => {
|
||||
gitea.failNext({ method: 'POST', pathIncludes: '/transfer', status: 403 })
|
||||
const r = await moveRepoToOrg(doc('JUSN/verboten'))
|
||||
assert.equal(r.moved, null)
|
||||
assert.match(r.warnings[0], /^Umzug nach WEBklar nicht möglich \(.+\)\.$/)
|
||||
})
|
||||
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)
|
||||
const d = aw.get('websiteProjects', doc('knso/von-hand').$id)
|
||||
assert.equal(d.repoFullName, 'WEBklar/von-hand')
|
||||
assert.equal(d.giteaRepoUrl, `${gitea.baseUrl}/WEBklar/von-hand`)
|
||||
assert.equal(d.giteaRepoName, 'von-hand')
|
||||
// R11/I3: der beim Hand-Umzug liegen gebliebene Einzel-Webhook wird jetzt mit aufgeraeumt.
|
||||
assert.deepEqual(gitea.hooksOf('WEBklar/von-hand'), [])
|
||||
})
|
||||
test('nur Groß-/Kleinschreibung unterschiedlich: echter Umzug', async () => {
|
||||
// I1: gespeichert ist "jusn/case-test", Gitea antwortet auf denselben Namen mit "JUSN/Case-Test"
|
||||
// (reine Schreibweise, kein tatsaechlicher Umzug) - das darf den Selbstheilungs-Zweig NICHT
|
||||
// ausloesen, sondern muss ganz normal weiter zum Transfer fuehren.
|
||||
const r = await moveRepoToOrg(doc('jusn/case-test'))
|
||||
assert.deepEqual(r, { moved: { from: 'jusn/case-test', to: 'WEBklar/Case-Test' }, warnings: [] })
|
||||
assert.equal(
|
||||
gitea.calls.some((c) => c.method === 'POST' && c.path === '/api/v1/repos/jusn/case-test/transfer'),
|
||||
true
|
||||
)
|
||||
assert.equal(aw.get('websiteProjects', doc('jusn/case-test').$id).repoFullName, 'WEBklar/Case-Test')
|
||||
})
|
||||
test('Erkennung ohne Groß-/Kleinschreibung', async () => {
|
||||
assert.equal(isOrgRepo('webklar/x'), true)
|
||||
assert.equal(await isInternalRepo('andj/steuerungstechnik'), true)
|
||||
})
|
||||
test('repoLockKey', () => {
|
||||
assert.equal(repoLockKey('JUSN/D-ner-Nation'), 'repo:jusn/d-ner-nation')
|
||||
})
|
||||
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\.$/)
|
||||
})
|
||||
test('Schritt 1 folgt keiner Weiterleitung: von Hand aus WEBklar herausverschobenes Repo wird nicht angefasst', async () => {
|
||||
// final-fix Punkt 2: WEBklar/rausgezogen (isOrgRepo(from) === true) leitet inzwischen auf
|
||||
// knso/rausgezogen um - das Aufraeumen darf dem NICHT folgen (sonst wuerde der Portal-Webhook
|
||||
// eines fremden Repos geloescht). Stattdessen wird der kanonische Name aufgeloest und, weil er
|
||||
// ausserhalb von WEBklar liegt, nur das Projektdokument nachgezogen.
|
||||
const r = await moveRepoToOrg(doc('WEBklar/rausgezogen'))
|
||||
assert.deepEqual(r, { moved: null, warnings: [] })
|
||||
assert.equal(
|
||||
gitea.calls.some((c) => c.method === 'DELETE' && /rausgezogen/i.test(c.path)),
|
||||
false
|
||||
)
|
||||
assert.deepEqual(gitea.hooksOf('knso/rausgezogen'), [
|
||||
{ id: 510, type: 'gitea', active: true, events: ['push'], config: { content_type: 'json', url: PORTAL_HOOK_URL } },
|
||||
])
|
||||
const d = aw.get('websiteProjects', doc('WEBklar/rausgezogen').$id)
|
||||
assert.equal(d.repoFullName, 'knso/rausgezogen')
|
||||
})
|
||||
test('R10-Ein-Zeiler: Transfer meldet Erfolg, aber unveraenderten full_name -> kein Aufraeumen', async () => {
|
||||
// final-fix Punkt 3: der Transfer selbst antwortet 201, aber full_name ist danach nicht in
|
||||
// WEBklar (simuliert einen Gitea-Bug/Teil-Fehlschlag). Erst NACH dieser Pruefung darf
|
||||
// aufgeraeumt werden - hier eben nicht.
|
||||
gitea.setTransferNoop()
|
||||
const r = await moveRepoToOrg(doc('ANDJ/unberuehrt'))
|
||||
assert.equal(r.moved, null)
|
||||
assert.deepEqual(r.warnings, ['Umzug nach WEBklar nicht möglich (Übertragung nicht abgeschlossen).'])
|
||||
assert.equal(
|
||||
gitea.calls.some((c) => c.method === 'DELETE' && /unberuehrt/i.test(c.path)),
|
||||
false
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { slugify } from '../server/services/slug.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), '')
|
||||
})
|
||||
Reference in new issue
Block a user