140 lines
5.5 KiB
Python
140 lines
5.5 KiB
Python
#!/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())
|