ADR-0012 made docs/issues/ canonical for the management scope only and left gitops, ThreadNet-Web and threadnet-call on GitLab "until the component adopts". That split produced exactly what it invited: two numbering worlds where management#20 and gitops#20 are different issues, drift nobody had to answer for (gitops#61 carried no milestone since 2026-08-11), and component backlogs that host sessions without lab access cannot read at all. The 46 open component issues are now files 0056-0101. The file id is the group-wide identifier; provenance lives in the frontmatter (new field `projekt` plus gitlab_iid) and in the filename, so "gitops#61" still finds 0091. Bodies are copied verbatim; comments and history stay on GitLab, as with the 2026-08-11 management import. Both scripts learned the second dimension: spiegel_issues.py routes each file to its origin project, reopens issues that are open in the repo but closed on the board, and writes the new iid back after creating one; gruppenpruefung.py checks drift across all four trackers instead of management alone. What the mirror cannot decide stays a finding, not a silent state. Two things needed a hand, both recorded in the files: gitops#61 had no milestone (M1 - it is a live account-takeover path) and carried two area labels where the schema holds one. The Gitea migration footers in the imported bodies point at decommissioned trackers; their links are removed, the provenance sentence stays. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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())
|