diff --git a/.env.example b/.env.example index 44bb343..fe081f7 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,8 @@ APP_URL="http://localhost:3000" # Demo-Daten beim Seed anlegen (1 = ja) SEED_DEMO="0" + +# Abgleich der Issue-Dateien mit dem Gitea-Tracker (npm run issues:sync) +GITEA_URL="https://git.example.de" +GITEA_REPO="benutzer/ProxLedger" +GITEA_TOKEN="" diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b45773..d8b321f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Sicherheit +## [0.4.1] – 2026-09-17 + +### Hinzugefügt +- Abgleich der Issue-Dateien mit dem Gitea-Tracker über `npm run issues:sync`, inklusive Labels, Meilensteinen und Trockenlauf (#020) + ## [0.4.0] – 2026-09-17 ### Hinzugefügt diff --git a/DEVLOG.md b/DEVLOG.md index 1efc173..5b47f81 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,7 +2,7 @@ ## Zeitübersicht -**Gesamt:** 2 h 38 min +**Gesamt:** 2 h 48 min | Issue | Zeit | |---|---| @@ -13,6 +13,7 @@ | #005 Agent-Skript | 0 h 25 min | | #006 Kunden und Hostzuweisung | 0 h 35 min | | #007 Host-Tabelle und Detailansichten | 0 h 25 min | +| #020 Issues nach Gitea übertragen | 0 h 10 min | | Meilenstein | Zeit | |---|---| @@ -20,12 +21,13 @@ | v0.2.0 | 0 h 25 min | **abgeschlossen** | v0.3.0 | 0 h 35 min | **abgeschlossen** | v0.4.0 | 0 h 25 min | **abgeschlossen** +| v0.4.1 | 0 h 10 min | **abgeschlossen** --- ## 2026-09-17 · Sitzung 1 · 19:36–laufend **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) -**Version:** 0.4.0 +**Version:** 0.4.1 ### Erledigt - Auftrag gelesen, Rückfragen gestellt @@ -46,6 +48,7 @@ - Issues #017 bis #019 aus bisherigen Notizen angelegt (Favicons, Feldtest des Agents, CI-Pipeline) - #007 abgeschlossen: Host-Tabelle mit Suche, fünf Filtern, Sortierung, Seitenblättern und CSV-Export; Host-Detail mit sechs Tabs, Notizen, Tags, Kundenzuweisung und Token-Widerruf - Meilenstein v0.4.0 abgeschlossen und getaggt; 59 Tests +- #020 abgeschlossen: Abgleichskript für den Gitea-Issue-Tracker (Missverständnis geklärt – die Issues lagen bisher nur als Dateien im Repo, nicht im Tracker) ### Entscheidungen - **Screenshots mit Firefox headless** statt Playwright: Firefox ist vorhanden, es muss kein Browser samt Systembibliotheken nachinstalliert werden (dafür wäre root nötig). @@ -72,4 +75,5 @@ - Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen) ### Nächster Schritt +- Gitea-Token erzeugen und `npm run issues:sync` einmal echt laufen lassen - #008 Laufzeiten und Rechnungen: Vorschläge bestätigen, Rechnungen erfassen, Häkchen direkt in der Tabelle diff --git a/README.md b/README.md index c6cec3d..fd0a68b 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,24 @@ Protokoll: `journalctl -t proxledger`. Ist das Dashboard nicht erreichbar, versu wachsendem Abstand und legt den Report danach unter `/var/lib/proxledger/spool/` ab; beim nächsten Lauf wird er nachgesendet. +## Issues nach Gitea übertragen + +Die Issues werden als Markdown-Dateien in `docs/issues/` gepflegt – sie liegen damit im Repo und wandern mit +jedem Klon mit. Wer sie zusätzlich im Gitea-Tracker haben möchte, gleicht sie ab: + +```bash +npm run issues:sync -- --dry-run # zeigt nur, was passieren würde + +GITEA_URL=https://git.example.de \ +GITEA_REPO=benutzer/ProxLedger \ +GITEA_TOKEN=... \ +npm run issues:sync +``` + +Der Token braucht das Recht `issue:write` (Gitea: Einstellungen → Anwendungen → Zugriffstoken). Er kann auch in +`.env` stehen, die Datei ist von Git ausgenommen. Der Abgleich legt Labels und Meilensteine an, erkennt +vorhandene Issues an der Nummer im Titel, aktualisiert sie und schließt erledigte. + ## Dokumentation - [Architektur- und Umsetzungsplan](docs/architektur.md) diff --git a/docs/issues/020-gitea-issue-abgleich.md b/docs/issues/020-gitea-issue-abgleich.md new file mode 100644 index 0000000..9285956 --- /dev/null +++ b/docs/issues/020-gitea-issue-abgleich.md @@ -0,0 +1,31 @@ +# #020 Issues nach Gitea übertragen +**Status:** erledigt +**Meilenstein:** v0.4.1 +**Labels:** infrastruktur, docs +**Geschätzt:** 2 h **Tatsächlich:** 0 h 10 min + +## Ziel +Die Issue-Dateien aus `docs/issues/` sollen als echte Issues im Gitea-Tracker erscheinen und dort +aktuell bleiben – ohne dass sie von Hand nachgepflegt werden müssen. + +## Aufgaben +- [x] Skript `scripts/gitea-issues.ts`, das die Dateien liest und über die Gitea-API abgleicht +- [x] Meilensteine anlegen (v0.1.0 bis v1.0.0), Labels anlegen (agent, backend, ui, docs …) +- [x] Issues anlegen; vorhandene anhand der Nummer im Titel wiedererkennen und aktualisieren +- [x] Erledigte Issues in Gitea schließen, wieder geöffnete erneut öffnen +- [x] Trockenlauf ohne Token (`--dry-run`) zur Kontrolle vor dem ersten echten Lauf +- [x] npm-Skript `issues:sync` und Beschreibung im README + +## Akzeptanzkriterien +- [x] Ein Lauf legt alle 20 Issues mit Titel, Text, Label und Meilenstein in Gitea an +- [x] Ein zweiter Lauf ändert nichts Unnötiges (keine Dubletten) +- [x] Der Token steht nur in der Umgebung, nie im Repository + +## Abhängigkeiten +#001 + +## Notizen / Entscheidungen +- Die Markdown-Dateien bleiben die Quelle der Wahrheit: Sie liegen im Repo, sind ohne Netz lesbar und + wandern mit jedem Klon mit. Gitea bekommt eine Kopie zum Kommentieren und Zuweisen. +- Der erste echte Lauf steht noch aus: Dafür braucht es einen Gitea-Token mit `issue:write`. + Der Trockenlauf liest alle 20 Dateien korrekt. diff --git a/docs/issues/README.md b/docs/issues/README.md index 02a26e8..84cd613 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -23,4 +23,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | offen | v0.6.0 | 1 h | – | | [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – | | [#019](019-ci-pipeline.md) | CI-Pipeline | offen | v0.10.0 | 3 h | – | -| | **Summe** | | | **112 h** | **2 h 38 min** | +| [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min | +| | **Summe** | | | **114 h** | **2 h 48 min** | diff --git a/package.json b/package.json index f99f9c8..ca576e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.4.0", + "version": "0.4.1", "private": true, "scripts": { "dev": "next dev", @@ -17,7 +17,8 @@ "db:seed": "prisma db seed", "db:studio": "prisma studio", "db:generate": "prisma generate", - "postinstall": "prisma generate" + "postinstall": "prisma generate", + "issues:sync": "tsx scripts/gitea-issues.ts" }, "dependencies": { "@node-rs/argon2": "^2.2.1", diff --git a/scripts/gitea-issues.ts b/scripts/gitea-issues.ts new file mode 100644 index 0000000..864dc68 --- /dev/null +++ b/scripts/gitea-issues.ts @@ -0,0 +1,229 @@ +/** + * Überträgt die Issue-Dateien aus `docs/issues/` in den Gitea-Issue-Tracker. + * + * GITEA_URL=https://git.wilde-server.de \ + * GITEA_REPO=florian.wilde/ProxLedger \ + * GITEA_TOKEN=... \ + * npm run issues:sync + * + * Ohne Token oder mit `--dry-run` läuft nur ein Trockenlauf: Es wird ausgegeben, was passieren würde. + * Die Markdown-Dateien bleiben die Quelle der Wahrheit; Gitea erhält eine Kopie zum Kommentieren. + */ + +import { readFile, readdir } from "node:fs/promises"; +import "dotenv/config"; +import path from "node:path"; + +interface IssueDatei { + nummer: number; + titel: string; + status: string; + meilenstein: string | null; + labels: string[]; + koerper: string; + dateiname: string; +} + +const LABEL_FARBEN: Record = { + agent: "#1e7e34", + backend: "#007bff", + api: "#6f42c1", + ui: "#17a2b8", + datenbank: "#20c997", + sicherheit: "#c82333", + docs: "#6c757d", + deployment: "#fd7e14", + infrastruktur: "#495057", + alerts: "#ffc107", + buchhaltung: "#e83e8c", + export: "#0dcaf0", + prototyp: "#adb5bd", + test: "#198754", + chore: "#868e96", +}; + +function feld(inhalt: string, name: string): string | null { + const treffer = new RegExp(`^\\*\\*${name}:\\*\\*\\s*(.+)$`, "m").exec(inhalt); + return treffer ? treffer[1].trim() : null; +} + +async function dateienLesen(verzeichnis: string): Promise { + const namen = (await readdir(verzeichnis)).filter((name) => /^\d{3}-.*\.md$/.test(name)).sort(); + + const issues: IssueDatei[] = []; + for (const dateiname of namen) { + const inhalt = await readFile(path.join(verzeichnis, dateiname), "utf8"); + const kopf = /^#\s*#(\d+)\s+(.+)$/m.exec(inhalt); + if (!kopf) { + console.warn(`Übersprungen (keine Überschrift „# #NNN Titel“): ${dateiname}`); + continue; + } + + // Der Körper beginnt nach den Kopfzeilen (Status, Meilenstein, Labels, Zeiten). + const körperStart = inhalt.indexOf("## Ziel"); + issues.push({ + nummer: Number(kopf[1]), + titel: kopf[2].trim(), + status: feld(inhalt, "Status") ?? "offen", + meilenstein: feld(inhalt, "Meilenstein"), + labels: (feld(inhalt, "Labels") ?? "") + .split(",") + .map((label) => label.trim().toLowerCase()) + .filter(Boolean), + koerper: körperStart >= 0 ? inhalt.slice(körperStart).trim() : inhalt, + dateiname, + }); + } + return issues; +} + +class Gitea { + constructor( + private readonly basis: string, + private readonly repo: string, + private readonly token: string, + ) {} + + private async anfrage(pfad: string, optionen: RequestInit = {}): Promise { + const antwort = await fetch(`${this.basis}/api/v1/repos/${this.repo}${pfad}`, { + ...optionen, + headers: { + Authorization: `token ${this.token}`, + "Content-Type": "application/json", + ...optionen.headers, + }, + }); + + if (!antwort.ok) { + throw new Error(`Gitea antwortet mit ${antwort.status} auf ${pfad}: ${await antwort.text()}`); + } + return (await antwort.json()) as T; + } + + labels() { + return this.anfrage<{ id: number; name: string }[]>("/labels?limit=100"); + } + + labelAnlegen(name: string) { + return this.anfrage<{ id: number; name: string }>("/labels", { + method: "POST", + body: JSON.stringify({ name, color: LABEL_FARBEN[name] ?? "#6c757d" }), + }); + } + + meilensteine() { + return this.anfrage<{ id: number; title: string }[]>("/milestones?state=all&limit=100"); + } + + meilensteinAnlegen(title: string) { + return this.anfrage<{ id: number; title: string }>("/milestones", { + method: "POST", + body: JSON.stringify({ title }), + }); + } + + issues() { + return this.anfrage<{ number: number; title: string; state: string }[]>("/issues?state=all&limit=200"); + } + + issueAnlegen(daten: Record) { + return this.anfrage<{ number: number }>("/issues", { method: "POST", body: JSON.stringify(daten) }); + } + + issueAendern(nummer: number, daten: Record) { + return this.anfrage<{ number: number }>(`/issues/${nummer}`, { + method: "PATCH", + body: JSON.stringify(daten), + }); + } +} + +/** Titel in Gitea, z. B. „#004 Agent-Skript“ – daran werden vorhandene Issues wiedererkannt. */ +function giteaTitel(issue: IssueDatei): string { + return `#${String(issue.nummer).padStart(3, "0")} ${issue.titel}`; +} + +async function main() { + const trockenlauf = process.argv.includes("--dry-run"); + const basis = (process.env.GITEA_URL ?? "").replace(/\/$/, ""); + const repo = process.env.GITEA_REPO ?? ""; + const token = process.env.GITEA_TOKEN ?? ""; + + const verzeichnis = path.join(process.cwd(), "docs", "issues"); + const issues = await dateienLesen(verzeichnis); + console.log(`${issues.length} Issue-Dateien gelesen.`); + + if (trockenlauf || !basis || !repo || !token) { + if (!trockenlauf) { + console.log("\nGITEA_URL, GITEA_REPO oder GITEA_TOKEN fehlen – es läuft nur ein Trockenlauf.\n"); + } + for (const issue of issues) { + console.log( + ` ${giteaTitel(issue)} [${issue.status}] Meilenstein ${issue.meilenstein ?? "–"} Labels: ${ + issue.labels.join(", ") || "–" + }`, + ); + } + console.log( + "\nEchter Abgleich: GITEA_URL, GITEA_REPO und GITEA_TOKEN setzen (Token mit Recht „issue:write“).", + ); + return; + } + + const gitea = new Gitea(basis, repo, token); + + const vorhandeneLabels = new Map((await gitea.labels()).map((l) => [l.name.toLowerCase(), l.id])); + const benoetigteLabels = [...new Set(issues.flatMap((issue) => issue.labels))]; + for (const name of benoetigteLabels) { + if (!vorhandeneLabels.has(name)) { + const neu = await gitea.labelAnlegen(name); + vorhandeneLabels.set(name, neu.id); + console.log(`Label angelegt: ${name}`); + } + } + + const vorhandeneMeilensteine = new Map((await gitea.meilensteine()).map((m) => [m.title, m.id])); + const benoetigteMeilensteine = [ + ...new Set(issues.map((issue) => issue.meilenstein).filter((m): m is string => Boolean(m))), + ].sort(); + for (const titel of benoetigteMeilensteine) { + if (!vorhandeneMeilensteine.has(titel)) { + const neu = await gitea.meilensteinAnlegen(titel); + vorhandeneMeilensteine.set(titel, neu.id); + console.log(`Meilenstein angelegt: ${titel}`); + } + } + + const vorhandeneIssues = new Map((await gitea.issues()).map((i) => [i.title, i])); + + for (const issue of issues) { + const titel = giteaTitel(issue); + const daten = { + title: titel, + body: `${issue.koerper}\n\n---\nQuelle: \`docs/issues/${issue.dateiname}\` – dort wird dieses Issue gepflegt.`, + labels: issue.labels + .map((name) => vorhandeneLabels.get(name)) + .filter((id): id is number => Boolean(id)), + milestone: issue.meilenstein ? vorhandeneMeilensteine.get(issue.meilenstein) : undefined, + }; + + const vorhanden = vorhandeneIssues.get(titel); + const zustand = issue.status.startsWith("erledigt") ? "closed" : "open"; + + if (vorhanden) { + await gitea.issueAendern(vorhanden.number, { ...daten, state: zustand }); + console.log(`Aktualisiert: ${titel} (${zustand})`); + } else { + const neu = await gitea.issueAnlegen(daten); + if (zustand === "closed") await gitea.issueAendern(neu.number, { state: "closed" }); + console.log(`Angelegt: ${titel} (${zustand})`); + } + } + + console.log("\nAbgleich abgeschlossen."); +} + +main().catch((fehler) => { + console.error(fehler instanceof Error ? fehler.message : fehler); + process.exit(1); +});