feat(tooling): Issue-Dateien mit dem Gitea-Tracker abgleichen (#020)
Skript liest docs/issues/, legt Labels und Meilensteine an, erkennt vorhandene Issues an der Nummer im Titel und schliesst erledigte. Trockenlauf ohne Token. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string> = {
|
||||
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<IssueDatei[]> {
|
||||
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<T>(pfad: string, optionen: RequestInit = {}): Promise<T> {
|
||||
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<string, unknown>) {
|
||||
return this.anfrage<{ number: number }>("/issues", { method: "POST", body: JSON.stringify(daten) });
|
||||
}
|
||||
|
||||
issueAendern(nummer: number, daten: Record<string, unknown>) {
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user