#!/usr/bin/env python3 """ TP36 (reframed) โ€” Archiv-Backfill: fehlende Archiv-TPs als Gitea-Issues anlegen. ๐Ÿ”ด RICHTUNG (seit 2026-10-03): **Gitea ist die SSoT fuer Index/Status.** Dieses Werkzeug liest die Datei NUR, um zu pruefen, ob jedes archivierte TP ein Issue hat. Es schreibt **ausschliesslich** NEUE Issues fuer noch nicht existierende Archiv-TPs โ€” existierende Issues werden NIE angefasst (kein Label-, kein State-Patch). Warum das wichtig ist: die Vorgaenger-Version glich auch bestehende Issues ab und leitete den Zielzustand aus dem Datei-Marker ab (fest `โœ…`). Damit konnte sie ein bewusst gesetztes Label ueberschreiben โ€” so wurde #24 (TP34, `status:dropped`) auf `status:done` und #32 (TP43) auf ein falsches `thema:` gezogen. Die Richtung Datei โ†’ Gitea fuer *Status* ist seit dem TP36-Cutover (2026-09-07) falsch; sie bleibt hier nur fuer das *Anlegen* erhalten. Abgrenzung: - Aktive TPs: werden DIREKT als Gitea-Issue gepflegt (nicht hier). - Status/Thema/State bestehender Issues: aendert nur der Mensch im Board. - Per-TP-Prosa: bleibt in docs/teilprojekte.md / teilprojekte-archiv.md. Token: ~/.hermes/.env (GITEA_ACCESS_TOKEN, GITEA_HOST) โ€” wird NIE ausgegeben. Aufruf: python3 bin/gitea-tp-sync.py # Dry-Run (default) โ€” zeigt, was fehlt python3 bin/gitea-tp-sync.py --commit # legt fehlende Archiv-Issues an """ import json import os import re import sys import urllib.request import urllib.error from pathlib import Path REPO = Path(__file__).resolve().parent.parent ACTIVE = REPO / "docs" / "teilprojekte.md" ARCHIV = REPO / "docs" / "teilprojekte-archiv.md" HERMES = Path.home() / ".hermes" / ".env" OWNER_REPO = "admin/bf-infra" COMMIT = "--commit" in sys.argv LABEL_COLORS = { "status:todo": "#ededed", "status:wip": "#fbca04", "status:paused": "#c5def5", "status:idea": "#d4c5f9", "status:concept": "#bfdadc", "status:done": "#0e8a16", "status:dropped": "#e11d21", "thema:firewall-dns": "#b60205", "thema:wlan": "#1d76db", "thema:cloudflare-proxy": "#f9a825", "thema:nautobot-doku": "#5319e7", "thema:infra": "#0e8a16", "thema:ipv6-matter": "#006b75", "thema:identity": "#e99695", "thema:backlog": "#cccccc", } # TP-Nummer -> Themen-Label (aus dem Statusbaum-Gruppierung; nur aktive TPs brauchen ein Board-Thema) THEME = { "5.1": "firewall-dns", "23": "firewall-dns", "25": "wlan", "28": "wlan", "13": "wlan", "17.1": "wlan", "17.3": "wlan", "16": "cloudflare-proxy", "16.2": "cloudflare-proxy", "16.3": "cloudflare-proxy", "31": "cloudflare-proxy", "41": "cloudflare-proxy", "14.4": "cloudflare-proxy", "42": "cloudflare-proxy", "43": "cloudflare-proxy", "44": "cloudflare-proxy", "32": "nautobot-doku", "34": "nautobot-doku", "35": "nautobot-doku", "36": "nautobot-doku", "37": "nautobot-doku", "30": "infra", "40": "infra", "20.1": "infra", "18": "infra", "24": "infra", "38": "infra", "11": "infra", "12": "ipv6-matter", "12.1": "ipv6-matter", "8": "identity", "29": "backlog", "33": "backlog", } def hermes_env(): tok = host = None for line in HERMES.read_text().splitlines(): m = re.match(r"\s*(GITEA_ACCESS_TOKEN|GITEA_HOST)\s*=\s*(.+?)\s*$", line) if not m: continue if m.group(1) == "GITEA_ACCESS_TOKEN": tok = m.group(2).strip().strip('"').strip("'") else: host = m.group(2).strip().strip('"').strip("'") if not tok: sys.exit("GITEA_ACCESS_TOKEN nicht in %s gefunden." % HERMES) host = (host or "https://gitea.emsenhuber.net").rstrip("/") if not host.startswith("http"): host = "https://" + host return tok, host TOKEN, HOST = hermes_env() API = HOST + "/api/v1" def api(method, path, body=None): url = API + path data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(url, data=data, method=method, headers={ "Authorization": "token " + TOKEN, "Accept": "application/json", "Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=30) as r: return r.status, json.load(r) except urllib.error.HTTPError as e: return e.code, (json.load(e) if e.headers.get("content-type", "").startswith("application/json") else {}) # --------------------------------------------------------------------------- parse def parse_archiv(exclude_nums): """Archiv-Header -> archivierte TPs, die ein Issue bekommen SOLLEN. Header-Form seit 2026-10-03: `### TP25 ยท Gitea #17 โ€” WLAN-Stabilisierung โ€” โ€ฆ` Der Status-Anspruch (frueher `โ–ธ โœ… FERTIG โ€ฆ`) ist entfernt; die Datei traegt keinen Status mehr. Der Gitea-Zeiger `ยท Gitea #N` ist die Bruecke, falls er schon da ist. exclude_nums = TP-Nummern, die in Gitea OFFEN sind -> die brauchen kein neues Issue. """ out = [] hdr = re.compile(r"^#{2,4}\s*(TP[\d.\-A-Za-z]+)\b\s*(?:ยท\s*Gitea\s*#(\d+)\s*)?โ€”\s*(.+?)\s*$") seen = set() for line in ARCHIV.read_text().splitlines(): m = hdr.match(line) if not m: continue tp, issue_no, title = m.groups() num = tp[2:] if num in exclude_nums or num in seen: continue seen.add(num) # Caveat hinter dem zweiten ` โ€” ` abschneiden; nur der Kurztitel gehoert ins Issue. short = re.split(r"\s+โ€”\s+", title)[0] short = re.sub(r"\*\*|`", "", short).strip()[:80] out.append({"tp": tp, "num": num, "issue": issue_no, "title": short, "src": "archiv"}) return out # --------------------------------------------------------------------------- gitea sync def ensure_labels(existing): by_name = {l["name"]: l for l in existing} for name, color in LABEL_COLORS.items(): if name in by_name: continue if COMMIT: api("POST", f"/repos/{OWNER_REPO}/labels", {"name": name, "color": color.lstrip("#")}) print(f" [{'+' if COMMIT else 'DRY'}] label {name}") st, cur = api("GET", f"/repos/{OWNER_REPO}/labels?limit=100") return {l["name"]: l["id"] for l in (cur if isinstance(cur, list) else [])} def all_issues(): out, page = [], 1 while True: st, d = api("GET", f"/repos/{OWNER_REPO}/issues?state=all&type=issues&limit=50&page={page}") if not isinstance(d, list) or not d: break out += d page += 1 return out def main(): print(f"Gitea {HOST} repo {OWNER_REPO} mode={'COMMIT' if COMMIT else 'DRY-RUN'}") st, existing_labels = api("GET", f"/repos/{OWNER_REPO}/labels?limit=100") label_id = ensure_labels(existing_labels if isinstance(existing_labels, list) else []) issues = all_issues() by_tp = {} open_nums = set() for iss in issues: # Titel ist "TPxx โ€” โ€ฆ"; volle ID inkl. Buchstaben/Bindestrich (TP14.2a, TP-R) bis zum " โ€” " m = re.match(r"^(TP[\w.\-]*?)\s+โ€”", iss["title"]) if m: by_tp[m.group(1)] = iss if iss.get("state") == "open": open_nums.add(m.group(1)[2:]) # Aktive TPs sind seit dem TP36-Cutover SSoT direkt in Gitea -> nur das Archiv abgleichen, # und offene (reopente) TPs davon ausschliessen, damit sie nicht faelschlich geschlossen werden. items = parse_archiv(open_nums) print(f"Geparst: {len(items)} archivierte TP (aktive TPs = SSoT in Gitea: " f"{len(open_nums)} offen, nicht angetastet)\n") created = skipped = 0 for it in items: # Archiv-TPs sind abgeschlossen -> neu angelegte Issues gehen direkt auf closed. status_label, state = "status:done", "closed" labels = [status_label] th = THEME.get(it["num"]) if th: labels.append(f"thema:{th}") title = f"{it['tp']} โ€” {it['title']}" body = (f"Steuerungs-SSoT: Index/Status hier in Gitea; **Prosa/Detail** im Repo unter " f"`docs/teilprojekte-archiv.md` (Abschnitt `{it['tp']}`).\n\n" f"- Status: โœ… ({status_label})\n" f"- Angelegt aus dem Archiv-Backfill (`bin/gitea-tp-sync.py`).\n") want_ids = [label_id[l] for l in labels if l in label_id] cur = by_tp.get(it["tp"]) if cur is not None: # ๐Ÿ”ด Existierendes Issue: NICHT anfassen. Weder Label noch State noch Body. # Status/Thema aendert nur der Mensch im Board (Gitea = SSoT seit TP36-Cutover). skipped += 1 continue # Fehlt das Issue, obwohl der Header schon einen Zeiger traegt? -> Zeiger ist stale. hint = f" โš  Datei-Header nennt bereits Gitea #{it['issue']} โ€” Issue fehlt!" if it.get("issue") else "" print(f" [{'+' if COMMIT else 'DRY'}] NEW {title} {labels} state={state}{hint}") if COMMIT: sc, res = api("POST", f"/repos/{OWNER_REPO}/issues", {"title": title, "body": body, "labels": want_ids}) if isinstance(res, dict) and res.get("number"): api("PATCH", f"/repos/{OWNER_REPO}/issues/{res['number']}", {"state": "closed"}) print(f" -> #{res['number']} angelegt (closed). Zeiger in " f"docs/teilprojekte-archiv.md nachtragen: `ยท Gitea #{res['number']}`") else: print(f" !! Anlegen fehlgeschlagen: {res}") created += 1 print(f"\n{'Angelegt' if COMMIT else 'Wuerde anlegen'}: {created} ยท " f"unveraendert gelassen (SSoT = Gitea): {skipped}") if not COMMIT: print("Dry-Run โ€” nichts geschrieben. Mit --commit ausfuehren.") if __name__ == "__main__": main()