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:
co-authored by
Claude Opus 5
parent
5ec4aa702e
commit
a1def8666e
+24
-12
@@ -14,8 +14,10 @@ zu überspringen, und jede Prüfung bildet einen real passierten Fall ab
|
||||
3. Meilenstein- und Prioritätspflicht über ALLE offenen
|
||||
Gruppen-Issues. (realer Fall: gitops#61, entstanden 2026-08-11,
|
||||
ohne Meilenstein)
|
||||
4. Issue-Drift management ↔ docs/issues/ — Titel, Zustand,
|
||||
Meilenstein, Priorität, Status-Label. (F-001/F-017-Klasse)
|
||||
4. Issue-Drift GitLab ↔ docs/issues/ — Titel, Zustand, Meilenstein,
|
||||
Priorität, Status-Label; seit ADR-0019 über management UND die
|
||||
adoptierten Komponenten-Tracker (Feld `projekt`).
|
||||
(F-001/F-017-Klasse)
|
||||
5. Git-Hygiene seit 2026-08-07 — Autor- und Committer-Zeit 12:00:00
|
||||
UTC, kanonische Identität; ausgenommen sind maschinelle Absender
|
||||
(MASCHINEN, per Adresse — ADR-0009). (F-002/F-003)
|
||||
@@ -188,29 +190,39 @@ def main() -> int:
|
||||
if len(prios) != 1:
|
||||
befunde.append(f"{ref}: {len(prios)} priority-Labels statt 1")
|
||||
|
||||
# 4) Issue-Drift management ↔ docs/issues/
|
||||
gl = {i["iid"]: i for i in
|
||||
api(f"projects/{urllib.parse.quote(GRUPPE + '/management', safe='')}"
|
||||
f"/issues?state=opened", token)}
|
||||
# 4) Issue-Drift GitLab ↔ docs/issues/ (management + adoptierte
|
||||
# Komponenten-Tracker, ADR-0019)
|
||||
spiegel_projekte = {
|
||||
"management": "axion1337.chat/management",
|
||||
"gitops": "axion1337.chat/axion1337.chat-gitops",
|
||||
"threadnet-web": "axion1337.chat/ThreadNet-Web",
|
||||
"threadnet-call": "axion1337.chat/threadnet-call",
|
||||
}
|
||||
gl = {}
|
||||
for kurz, pfad in spiegel_projekte.items():
|
||||
for i in api(f"projects/{urllib.parse.quote(pfad, safe='')}"
|
||||
f"/issues?state=opened", token):
|
||||
gl[(kurz, i["iid"])] = i
|
||||
dateien = {}
|
||||
for f in sorted((root / "docs/issues").glob("[0-9]*.md")):
|
||||
meta = frontmatter(f)
|
||||
proj = meta.get("projekt") or "management"
|
||||
if meta.get("gitlab_iid"):
|
||||
dateien[int(meta["gitlab_iid"])] = (f, meta)
|
||||
dateien[(proj, int(meta["gitlab_iid"]))] = (f, meta)
|
||||
elif meta.get("status") not in ("done", "rejected"):
|
||||
hinweise.append(f"{f.name}: noch nicht gespiegelt "
|
||||
f"(gitlab_iid fehlt — erwartet bis zum "
|
||||
f"ersten Spiegel-Lauf)")
|
||||
for iid in sorted(set(gl) - set(dateien)):
|
||||
befunde.append(f"management#{iid} ist offen auf GitLab, hat aber "
|
||||
for proj, iid in sorted(set(gl) - set(dateien)):
|
||||
befunde.append(f"{proj}#{iid} ist offen auf GitLab, hat aber "
|
||||
f"keine kanonische Datei (zweites Backlog!)")
|
||||
for iid, (f, meta) in sorted(dateien.items()):
|
||||
if iid not in gl:
|
||||
for (proj, iid), (f, meta) in sorted(dateien.items()):
|
||||
if (proj, iid) not in gl:
|
||||
if meta.get("status") not in ("done", "rejected"):
|
||||
befunde.append(f"{f.name}: offen im Repo, aber auf GitLab "
|
||||
f"geschlossen/fehlend — nachziehen")
|
||||
continue
|
||||
i = gl[iid]
|
||||
i = gl[(proj, iid)]
|
||||
if titel(f) != i["title"].strip():
|
||||
befunde.append(f"{f.name}: Titel weicht von GitLab ab")
|
||||
ms = (i.get("milestone") or {}).get("title", "")
|
||||
|
||||
+57
-29
@@ -1,12 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""spiegel_issues.py — Repo → GitLab, ein deterministischer Spiegel.
|
||||
|
||||
docs/issues/ ist kanonisch (ADR-0012); dieses Skript bespielt die
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
@@ -27,8 +30,13 @@ import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
API = "https://git.lab/api/v4"
|
||||
PROJEKT = urllib.parse.quote("axion1337.chat/management", safe="")
|
||||
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"}
|
||||
|
||||
@@ -84,13 +92,17 @@ def main() -> int:
|
||||
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"])}
|
||||
gitlab = {i["iid"]: i for i in
|
||||
alle(f"projects/{PROJEKT}/issues?state=opened", token)}
|
||||
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]] = []
|
||||
gespiegelt: set[int] = set()
|
||||
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")):
|
||||
@@ -100,24 +112,27 @@ def main() -> int:
|
||||
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,
|
||||
"state_event": None if offen else "close"}
|
||||
"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 k != "state_event" and v is not None}))
|
||||
{k: v for k, v in soll.items() if v is not None},
|
||||
proj))
|
||||
continue
|
||||
gespiegelt.add(iid)
|
||||
ist = gitlab.get(iid)
|
||||
gespiegelt.add((proj, iid))
|
||||
ist = gitlab[proj].get(iid)
|
||||
if ist is None:
|
||||
if offen:
|
||||
plan.append(("MELDEN", f.name,
|
||||
{"grund": f"#{iid} auf GitLab nicht offen, "
|
||||
f"Datei aber {meta.get('status')}"}))
|
||||
{"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
|
||||
@@ -130,26 +145,39 @@ def main() -> int:
|
||||
delta["milestone_id"] = soll["milestone_id"]
|
||||
if (ist.get("due_date") or None) != soll["due_date"]:
|
||||
delta["due_date"] = soll["due_date"]
|
||||
if not offen:
|
||||
delta["state_event"] = "close"
|
||||
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))
|
||||
plan.append(("ÄNDERN", f"{f.name} (#{iid})", delta, proj))
|
||||
|
||||
for iid in sorted(set(gitlab) - gespiegelt):
|
||||
plan.append(("MELDEN", f"management#{iid}",
|
||||
{"grund": "offen auf GitLab ohne kanonische Datei — "
|
||||
"wird NICHT automatisch geschlossen"}))
|
||||
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 in plan:
|
||||
for aktion, wer, daten, proj in plan:
|
||||
print(f"[{modus}] {aktion} {wer}: {json.dumps(daten, ensure_ascii=False)}")
|
||||
if scharf and aktion == "ANLEGEN":
|
||||
api(f"projects/{PROJEKT}/issues", token, "POST", daten)
|
||||
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/{PROJEKT}/issues/{iid}", token, "PUT", daten)
|
||||
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)")
|
||||
f"({sum(1 for a, *_ in plan if a == 'MELDEN')} Meldungen)")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user