#!/usr/bin/env python3 """adoptiere.py — Komponenten-Issues in docs/issues/ übernehmen (ADR-0019). Liest die offenen Issues der adoptierten GitLab-Projekte und legt für jedes eine kanonische Datei an: fortlaufende Nummer hinter der höchsten vorhandenen, Herkunft als `projekt` + `gitlab_iid` im Frontmatter, die GitLab-Beschreibung wortgleich als Rumpf. Kommentare und Verlauf bleiben auf GitLab (wie beim Management-Import 2026-08-11, ADR-0012). Deterministisch und idempotent: existiert bereits eine Datei mit demselben (projekt, gitlab_iid), wird das Issue übersprungen. Default ist Dry-Run; --execute schreibt die Dateien. Stdlib-only. Usage: python3 verfahren/issue-adoption/adoptiere.py [repo-root] [--execute] (Token: ~/.config/gitlab-lab/token oder $GITLAB_TOKEN) """ from __future__ import annotations import json import os import re import sys import urllib.parse import urllib.request from pathlib import Path API = "https://git.lab/api/v4" # Reihenfolge = Nummernvergabe: erst gitops, dann die Clients. PROJEKTE = [ ("gitops", "axion1337.chat/axion1337.chat-gitops"), ("threadnet-web", "axion1337.chat/ThreadNet-Web"), ("threadnet-call", "axion1337.chat/threadnet-call"), ] STATUS_AUS_LABEL = {"status:next": "next", "status:doing": "in-progress", "status:wartet": "waiting"} AREAS = ("security", "infrastructure", "database", "element") def token_lesen() -> str: if os.environ.get("GITLAB_TOKEN"): return os.environ["GITLAB_TOKEN"] return (Path.home() / ".config/gitlab-lab/token").read_text( encoding="utf-8").strip() def alle(pfad: str, token: str): daten, seite = [], 1 while True: trenner = "&" if "?" in pfad else "?" req = urllib.request.Request( f"{API}/{pfad}{trenner}per_page=100&page={seite}", headers={"PRIVATE-TOKEN": token}) with urllib.request.urlopen(req) as antwort: batch = json.load(antwort) daten += batch if len(batch) < 100: return daten seite += 1 def slug(text: str, maxlen: int = 45) -> str: text = text.lower() for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")): text = text.replace(a, b) text = re.sub(r"[^a-z0-9]+", "-", text).strip("-") return text[:maxlen].rstrip("-") def vorhandene(root: Path) -> tuple[int, set[tuple[str, str]]]: hoechste, belegt = 0, set() for f in (root / "docs/issues").glob("[0-9]*.md"): meta = {} for z in f.read_text(encoding="utf-8").splitlines()[1:]: if z == "---": break m = re.match(r"^(\w+):\s*(.*)$", z) if m: meta[m.group(1)] = m.group(2).strip().strip('"') hoechste = max(hoechste, int(meta.get("id", 0))) if meta.get("gitlab_iid"): belegt.add((meta.get("projekt") or "management", meta["gitlab_iid"])) return hoechste, belegt def main() -> int: argv = [a for a in sys.argv[1:] if a != "--execute"] scharf = "--execute" in sys.argv root = Path(argv[0]) if argv else Path.cwd() token = token_lesen() nr, belegt = vorhandene(root) modus = "EXECUTE" if scharf else "DRY-RUN" for kurz, pfad in PROJEKTE: issues = alle(f"projects/{urllib.parse.quote(pfad, safe='')}" f"/issues?state=opened", token) for i in sorted(issues, key=lambda x: x["iid"]): if (kurz, str(i["iid"])) in belegt: print(f"[{modus}] {kurz}#{i['iid']} bereits adoptiert — übersprungen") continue nr += 1 ms = (i.get("milestone") or {}).get("title", "") ms = ms[:2] if re.match(r"^M[1-5]\b", ms) else "" prio = next((l.split(":")[1] for l in i["labels"] if l.startswith("priority:")), "") status = next((STATUS_AUS_LABEL[l] for l in i["labels"] if l in STATUS_AUS_LABEL), "open") area = next((a for a in AREAS if f"area:{a}" in i["labels"]), None) name = f"{nr:04d}-{kurz}-{i['iid']}-{slug(i['title'])}.md" zeilen = ["---", "type: issue", f'id: "{nr:04d}"', f"status: {status}", f"created: {i['created_at'][:10]}", f"milestone: {ms or 'FEHLT'}", f"priority: {prio}"] if i.get("due_date"): zeilen.append(f"due: {i['due_date']}") if area: zeilen.append(f"area: {area}") if status == "waiting": zeilen.append("wartegrund: WARTEGRUND-NACHTRAGEN") zeilen += [f"projekt: {kurz}", f'gitlab_iid: "{i["iid"]}"', "related: []", "---", f"# {i['title'].strip()}", "", f"> Adoptiert aus [{kurz}#{i['iid']}]" f"(https://git.lab/{pfad}/-/issues/{i['iid']}) " f"(2026-08-18, ADR-0019). Kommentare und Verlauf " f"bleiben dort; kanonisch ist ab jetzt diese Datei.", ""] rumpf = (i.get("description") or "").strip() if rumpf: zeilen += [rumpf, ""] print(f"[{modus}] {kurz}#{i['iid']} -> {name}" + (" (MEILENSTEIN FEHLT)" if not ms else "") + (" (wartegrund nachtragen)" if status == "waiting" else "")) if scharf: (root / "docs/issues" / name).write_text( "\n".join(zeilen), encoding="utf-8") return 0 if __name__ == "__main__": sys.exit(main())