Files
Thore CimbalandClaude Opus 5 a1def8666e 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>
2026-08-18 12:00:00 +00:00

186 lines
7.5 KiB
Python

#!/usr/bin/env python3
"""spiegel_issues.py — Repo → GitLab, ein deterministischer Spiegel.
docs/issues/ ist kanonisch (ADR-0012, seit ADR-0019 auch für die
adoptierten Komponenten-Tracker); dieses Skript bespielt die
GitLab-Ansicht, damit Board, Meilensteine und Labels weiterarbeiten.
Das Zielprojekt bestimmt das Frontmatter-Feld `projekt` (fehlt es:
management). Gespiegelt werden **nur** Titel, Zustand, Meilenstein,
Priorität, Fälligkeit und Status-Label — nie Beschreibungen (der
Migrations-Fußtext und die Kommentare auf GitLab bleiben
unangetastet), und nie in Gegenrichtung. Eine offene Datei, deren
GitLab-Issue geschlossen wurde, wird wieder geöffnet.
Default ist **Dry-Run**: druckt jeden geplanten API-Aufruf und ändert
nichts. `--ausfuehren` schreibt wirklich — das stößt nur sorb an.
GitLab-seitig offene Issues ohne kanonische Datei werden gemeldet,
nie automatisch geschlossen.
Usage: python3 scripts/spiegel_issues.py [repo-root] [--ausfuehren]
(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"
GRUPPE = "axion1337.chat"
PROJEKTE = { # Frontmatter `projekt` -> GitLab-Projektpfad (ADR-0019)
"management": "axion1337.chat/management",
"gitops": "axion1337.chat/axion1337.chat-gitops",
"threadnet-web": "axion1337.chat/ThreadNet-Web",
"threadnet-call": "axion1337.chat/threadnet-call",
}
STATUS_LABEL = {"open": None, "next": "status:next",
"in-progress": "status:doing", "waiting": "status:wartet"}
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 api(pfad: str, token: str, methode: str = "GET", daten: dict | None = None):
body = json.dumps(daten).encode() if daten is not None else None
req = urllib.request.Request(
f"{API}/{pfad}", data=body, method=methode,
headers={"PRIVATE-TOKEN": token, "Content-Type": "application/json"})
with urllib.request.urlopen(req) as antwort:
return json.load(antwort)
def alle(pfad: str, token: str):
daten, seite = [], 1
while True:
trenner = "&" if "?" in pfad else "?"
batch = api(f"{pfad}{trenner}per_page=100&page={seite}", token)
daten += batch
if len(batch) < 100:
return daten
seite += 1
def frontmatter_und_titel(p: Path):
zeilen = p.read_text(encoding="utf-8").splitlines()
meta, titel, i = {}, "", 1
while i < len(zeilen) and zeilen[i] != "---":
m = re.match(r"^(\w+):\s*(.*)$", zeilen[i])
if m:
meta[m.group(1)] = m.group(2).strip().strip('"')
i += 1
for z in zeilen[i:]:
if z.startswith("# "):
titel = z[2:].strip()
break
return meta, titel
def main() -> int:
argv = [a for a in sys.argv[1:] if a != "--ausfuehren"]
scharf = "--ausfuehren" in sys.argv
root = Path(argv[0]) if argv else Path.cwd()
token = token_lesen()
meilensteine = {re.match(r"^(M\d)\b", m["title"]).group(1): m["id"]
for m in alle(f"groups/{GRUPPE}/milestones", token)
if re.match(r"^M\d\b", m["title"])}
pfade = {kurz: urllib.parse.quote(pfad, safe="")
for kurz, pfad in PROJEKTE.items()}
gitlab = {kurz: {i["iid"]: i for i in
alle(f"projects/{pfade[kurz]}/issues?state=all", token)}
for kurz in PROJEKTE}
plan: list[tuple[str, str, dict, str]] = []
gespiegelt: set[tuple[str, int]] = set()
for f in sorted((root / "docs/issues").glob("[0-9]*.md")):
meta, titel = frontmatter_und_titel(f)
proj = meta.get("projekt") or "management"
offen = meta.get("status") not in ("done", "rejected")
labels = [f"priority:{meta['priority']}"]
if STATUS_LABEL.get(meta.get("status")):
labels.append(STATUS_LABEL[meta["status"]])
for feld in ("host", "area"):
if meta.get(feld):
labels.append(f"{feld}:{meta[feld]}")
soll = {"title": titel, "labels": sorted(labels),
"milestone_id": meilensteine.get(meta.get("milestone")),
"due_date": meta.get("due") or None}
iid = int(meta["gitlab_iid"]) if meta.get("gitlab_iid") else None
if iid is None:
if offen:
plan.append(("ANLEGEN", f.name,
{k: v for k, v in soll.items() if v is not None},
proj))
continue
gespiegelt.add((proj, iid))
ist = gitlab[proj].get(iid)
if ist is None:
if offen:
plan.append(("MELDEN", f.name,
{"grund": f"{proj}#{iid} auf GitLab nicht "
f"gefunden, Datei aber {meta.get('status')}"},
proj))
continue
ist_offen = ist["state"] == "opened"
if not offen and not ist_offen:
continue # beidseitig zu — Historie nicht anfassen
delta = {}
if ist["title"].strip() != titel:
delta["title"] = titel
ist_labels = sorted(l for l in ist["labels"]
if l.split(":")[0] in
("priority", "status", "host", "area"))
if ist_labels != soll["labels"]:
delta["labels"] = soll["labels"]
if (ist.get("milestone") or {}).get("id") != soll["milestone_id"]:
delta["milestone_id"] = soll["milestone_id"]
if (ist.get("due_date") or None) != soll["due_date"]:
delta["due_date"] = soll["due_date"]
if offen and not ist_offen:
delta["state_event"] = "reopen"
elif not offen and ist_offen:
delta = {"state_event": "close"}
if delta:
plan.append(("ÄNDERN", f"{f.name} (#{iid})", delta, proj))
for kurz in PROJEKTE:
for iid in sorted(iid for iid, i in gitlab[kurz].items()
if i["state"] == "opened"
and (kurz, iid) not in gespiegelt):
plan.append(("MELDEN", f"{kurz}#{iid}",
{"grund": "offen auf GitLab ohne kanonische Datei — "
"wird NICHT automatisch geschlossen"}, kurz))
modus = "AUSFÜHREN" if scharf else "DRY-RUN"
for aktion, wer, daten, proj in plan:
print(f"[{modus}] {aktion} {wer}: {json.dumps(daten, ensure_ascii=False)}")
if scharf and aktion == "ANLEGEN":
neu = api(f"projects/{pfade[proj]}/issues", token, "POST", daten)
# Spiegel-Adresse zurückschreiben, sonst legt der nächste
# Lauf ein Duplikat an.
datei = root / "docs/issues" / wer
text = datei.read_text(encoding="utf-8")
datei.write_text(text.replace(
"\n---\n", f'\ngitlab_iid: "{neu["iid"]}"\n---\n', 1),
encoding="utf-8")
print(f" -> {proj}#{neu['iid']} angelegt, gitlab_iid in {wer}")
elif scharf and aktion == "ÄNDERN":
iid = int(wer.rsplit("#", 1)[1].rstrip(")"))
api(f"projects/{pfade[proj]}/issues/{iid}", token, "PUT", daten)
print(f"spiegel_issues [{modus}]: {len(plan)} Aktionen "
f"({sum(1 for a, *_ in plan if a == 'MELDEN')} Meldungen)")
return 0
if __name__ == "__main__":
sys.exit(main())