In den Git-Zugangsdaten steht das Konto-Passwort, kein API-Token. Gitea nimmt es nur per Basic-Auth an; das Skript erkennt das jetzt selbst. Alle 30 Issues mit Labels und Meilensteinen uebertragen. Neu: #031 Rotation des Deployment-Keys und #032 Nacharbeiten fuer 1.0.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
/**
|
||
* Ü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<string, string> = {
|
||
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<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,
|
||
/** Fertiger Wert fuer den Authorization-Header (`token …` oder `Basic …`). */
|
||
private readonly autorisierung: 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: 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<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),
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
});
|