#!/usr/bin/env tsx /** * Spiegelt docs/issues/NNN-*.md in den Gitea-Tracker. Die Dateien sind die Quelle, * der Tracker die Anzeige. * * Das Token kommt aus GITEA_TOKEN oder, falls das fehlt, aus ~/.git-credentials * fuer denselben Host. Es wird nie ausgegeben. * * pnpm issues:sync spiegelt * pnpm issues:sync --pruefen meldet nur Abweichungen, schreibt nichts */ import { readFileSync, readdirSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; const VERZEICHNIS = "docs/issues"; const nurPruefen = process.argv.includes("--pruefen"); function remoteLesen(): { host: string; besitzer: string; repo: string } { const konfiguration = readFileSync(".git/config", "utf8"); const treffer = konfiguration.match( /url\s*=\s*https?:\/\/(?[^/]+)\/(?[^/]+)\/(?[^/\s]+?)(?:\.git)?\s*$/m, ); if (!treffer?.groups) throw new Error("Konnte den Remote aus .git/config nicht lesen."); const { host, besitzer, repo } = treffer.groups; return { host: host!, besitzer: besitzer!, repo: repo! }; } function tokenLesen(host: string): string { const ausUmgebung = process.env["GITEA_TOKEN"]; if (ausUmgebung) return ausUmgebung; try { const zeilen = readFileSync(join(homedir(), ".git-credentials"), "utf8"); const treffer = zeilen.match(new RegExp(`https://[^:]+:([^@]+)@${host.replace(".", "\\.")}`)); if (treffer?.[1]) return treffer[1]; } catch { // Datei fehlt – dann greift die Meldung unten. } throw new Error( `Kein Token. Setze GITEA_TOKEN oder lege einen Zugang fuer ${host} in ~/.git-credentials ab.`, ); } const { host, besitzer, repo } = remoteLesen(); const API = `https://${host}/api/v1/repos/${besitzer}/${repo}`; const TOKEN = tokenLesen(host); async function ruf(methode: string, pfad: string, rumpf?: unknown): Promise { for (let versuch = 1; versuch <= 5; versuch += 1) { try { const antwort = await fetch(API + pfad, { method: methode, headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json" }, ...(rumpf === undefined ? {} : { body: JSON.stringify(rumpf) }), signal: AbortSignal.timeout(30_000), }); if (!antwort.ok) { throw new Error(`HTTP ${antwort.status} bei ${methode} ${pfad}: ${await antwort.text()}`); } return (await antwort.json().catch(() => null)) as T; } catch (fehler) { if (versuch === 5 || String(fehler).includes("HTTP 4")) throw fehler; await new Promise((f) => setTimeout(f, versuch * 1000)); } } throw new Error("unerreichbar"); } function feld(text: string, name: string): string { return text.match(new RegExp(`\\*\\*${name}:\\*\\*\\s*(.+?)(?=\\s*\\*\\*|$)`, "m"))?.[1]?.trim() ?? ""; } type Label = { id: number; name: string }; type Meilenstein = { id: number; title: string }; type Issue = { number: number; title: string }; const labels = new Map((await ruf("GET", "/labels?limit=100")).map((l) => [l.name, l.id] as const)); const meilensteine = new Map( (await ruf("GET", "/milestones?state=all")).map((m) => [m.title, m.id] as const), ); const vorhanden = new Map( (await ruf("GET", "/issues?state=all&limit=100")).map((i) => [i.title, i.number] as const), ); let geaendert = 0; for (const name of readdirSync(VERZEICHNIS) .filter((n) => /^\d{3}-.+\.md$/.test(n)) .sort()) { const text = readFileSync(join(VERZEICHNIS, name), "utf8"); const kopf = text.match(/^#\s*#(\d+)\s+(.+)$/m); if (!kopf) throw new Error(`${name}: keine Kopfzeile der Form "# #007 Titel".`); const titel = kopf[2]!.trim(); const labelNamen = feld(text, "Labels") .split(",") .map((n) => n.trim()) .filter(Boolean); const unbekannt = labelNamen.filter((n) => !labels.has(n)); if (unbekannt.length > 0) { console.warn(` ${name}: unbekannte Labels: ${unbekannt.join(", ")}`); } const nutzlast: Record = { title: titel, body: `${text.slice(kopf.index! + kopf[0].length).replace(/^\n+/, "")}\n\n---\n\n` + `Quelle: \`${VERZEICHNIS}/${name}\` — dort gepflegt, hierher gespiegelt.`, labels: labelNamen.filter((n) => labels.has(n)).map((n) => labels.get(n)!), }; const meilenstein = meilensteine.get(feld(text, "Meilenstein")); if (meilenstein !== undefined) nutzlast["milestone"] = meilenstein; const nummer = vorhanden.get(titel); if (nurPruefen) { if (nummer === undefined) console.log(` fehlt im Tracker: ${titel}`); continue; } if (nummer === undefined) { const neu = await ruf("POST", "/issues", nutzlast); const warnung = String(neu.number) === String(Number(name.slice(0, 3))) ? "" : " ⚠ Nummer weicht ab"; console.log(` angelegt #${neu.number} ${titel}${warnung}`); } else { await ruf("PATCH", `/issues/${nummer}`, nutzlast); console.log(` aktualisiert #${nummer} ${titel}`); } geaendert += 1; } console.log(nurPruefen ? "Prüfung beendet." : `Fertig, ${geaendert} Issues gespiegelt.`);