From 8fadb84923f30235fb67f5b7d9e5ffa14a24c430 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 13:06:28 +0200 Subject: [PATCH] chore(gitea): Abgleich der Issue-Dateien nach Gitea vorbereiten (#030) Skript aus ProxLedger uebernommen: legt Labels, Meilensteine und Issues an bzw. aktualisiert sie, Token aus GITEA_TOKEN oder den Git-Zugangsdaten. Ohne Zugang laeuft nur ein Trockenlauf - der Gitea- Zugang fehlt noch. Co-Authored-By: Claude Opus 5.5 --- scripts/gitea-issues.ts | 274 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 scripts/gitea-issues.ts diff --git a/scripts/gitea-issues.ts b/scripts/gitea-issues.ts new file mode 100644 index 0000000..f2b1de8 --- /dev/null +++ b/scripts/gitea-issues.ts @@ -0,0 +1,274 @@ +/** + * Überträgt die Issue-Dateien aus `docs/issues/` in den Gitea-Issue-Tracker. + * + * GITEA_URL=https://git.wilde-server.de \ + * GITEA_REPO=florian.wilde/sshkv \ + * 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 { execFileSync } from "node:child_process"; +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; +} + +/** Farben der Labels, angelehnt an die Design-Tokens (#030). */ +const LABEL_FARBEN: Record = { + backend: "#6aa8ff", + ui: "#f0b43c", + datenbank: "#3cc9a7", + sicherheit: "#f2714b", + test: "#3cc9a7", + docs: "#8b96a3", + infrastruktur: "#2a323b", + chore: "#8b96a3", +}; + +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), + }); + } +} + +/** + * Ermittelt Serveradresse und Repo aus dem Git-Remote, damit beides nicht doppelt gepflegt werden muss. + * Beispiel: https://git.example.de/benutzer/sshkv.git → { basis, repo } + */ +function ausRemote(): { basis: string; repo: string } | null { + try { + const url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" }).trim(); + const treffer = /^https?:\/\/(?:[^@/]*@)?([^/]+)\/(.+?)(?:\.git)?$/.exec(url); + if (!treffer) return null; + return { basis: `https://${treffer[1]}`, repo: treffer[2] ?? "" }; + } catch { + return null; + } +} + +/** + * Holt den Token aus den Git-Zugangsdaten (Schlüsselbund oder `~/.git-credentials`). + * So genügt ein einziger Token für Push und API – er muss nicht zusätzlich in der .env stehen. + */ +function tokenAusGitCredentials(basis: string): string | null { + const host = new URL(basis).host; + + // Erst die eingerichteten Helfer (z. B. Schlüsselbund), dann zusätzlich die Datei + // ~/.git-credentials – je nach Rechner ist mal das eine, mal das andere gefüllt. + for (const zusatz of [[], ["-c", "credential.helper=store"]]) { + try { + const antwort = execFileSync("git", [...zusatz, "credential", "fill"], { + input: `protocol=https\nhost=${host}\n\n`, + encoding: "utf8", + // Keine Rückfrage im Terminal: Wenn nichts hinterlegt ist, soll der Aufruf scheitern. + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + stdio: ["pipe", "pipe", "ignore"], + }); + const passwort = /^password=(.*)$/m.exec(antwort); + if (passwort?.[1]) return passwort[1]; + } catch { + // Nächste Möglichkeit versuchen. + } + } + + return null; +} + +/** 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 remote = ausRemote(); + const basis = (process.env.GITEA_URL ?? remote?.basis ?? "").replace(/\/$/, ""); + const repo = process.env.GITEA_REPO ?? remote?.repo ?? ""; + const token = process.env.GITEA_TOKEN || (basis ? (tokenAusGitCredentials(basis) ?? "") : ""); + const tokenQuelle = process.env.GITEA_TOKEN ? "Umgebungsvariable" : "Git-Zugangsdaten"; + + 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( + "\nAdresse, Repo oder Token nicht ermittelbar – es läuft nur ein Trockenlauf.\n" + + "Entweder GITEA_TOKEN setzen oder einmal `git push` ausführen, damit der Token hinterlegt wird.\n", + ); + } + for (const issue of issues) { + console.log( + ` ${giteaTitel(issue)} [${issue.status}] Meilenstein ${issue.meilenstein ?? "–"} Labels: ${ + issue.labels.join(", ") || "–" + }`, + ); + } + console.log( + `\nZiel wäre: ${basis || "?"}/${repo || "?"} – Token ${token ? "vorhanden" : "fehlt"}.` + + "\nDer Token wird aus den Git-Zugangsdaten gelesen; er braucht zusätzlich das Recht „issue:write“.", + ); + return; + } + + console.log(`Ziel: ${basis}/${repo} (Token aus: ${tokenQuelle})`); + 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); +});