feat(issues): adopt the component trackers — one backlog, one numbering (ADR-0019)

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>
This commit is contained in:
Thore Cimbal
2026-08-18 12:00:00 +00:00
co-authored by Claude Opus 5
parent 5ec4aa702e
commit a1def8666e
52 changed files with 1789 additions and 44 deletions
+139
View File
@@ -0,0 +1,139 @@
#!/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())