/** * Ü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, /** Fertiger Wert fuer den Authorization-Header (`token …` oder `Basic …`). */ private readonly autorisierung: string, ) {} private async anfrage(pfad: string, optionen: RequestInit = {}): Promise { const antwort = await fetch(`${this.basis}/api/v1/repos/${this.repo}${pfad}`, { ...optionen, headers: { Authorization: this.autorisierung, "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 die Zugangsdaten aus Git (Schlüsselbund oder `~/.git-credentials`), damit sie nicht * zusätzlich in der .env stehen müssen. Hinterlegt ist dort ein API-Token oder – wie bei * sshkeyvault – das Konto-Passwort; Gitea nimmt beides an, das Passwort aber nur per Basic-Auth * (#030). */ function zugangAusGitCredentials(basis: string): { benutzer: string; geheimnis: 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); const benutzer = /^username=(.*)$/m.exec(antwort); if (passwort?.[1]) return { benutzer: benutzer?.[1] ?? "", geheimnis: 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 zugang = process.env.GITEA_TOKEN ? null : basis ? zugangAusGitCredentials(basis) : null; const token = process.env.GITEA_TOKEN || zugang?.geheimnis || ""; const tokenQuelle = process.env.GITEA_TOKEN ? "Umgebungsvariable" : "Git-Zugangsdaten"; // Ein Gitea-Token sind 40 Hex-Zeichen; alles andere ist ein Passwort und geht per Basic-Auth. const autorisierung = process.env.GITEA_TOKEN || /^[0-9a-f]{40}$/.test(token) || !zugang?.benutzer ? `token ${token}` : `Basic ${Buffer.from(`${zugang.benutzer}:${token}`).toString("base64")}`; 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, autorisierung); 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); });