#!/usr/bin/env python3 """Sync the SOTS1 RE campaign board (campaign/board.md) to Forgejo issues + labels. Forgejo 10.x has no Projects API, so issues are the source of truth and status labels are the kanban columns. Idempotent: matches issues by exact title. FORGEJO_TOKEN=... scripts/forgejo_campaign.py bootstrap # ensure labels, create/sync issues FORGEJO_TOKEN=... scripts/forgejo_campaign.py status # print issues grouped by status """ import json, os, re, sys, urllib.request, urllib.parse API = os.environ.get("FORGEJO_API", "http://chonkers:2112/api/v1") REPO = os.environ.get("FORGEJO_REPO", "alex/sots-re") TOKEN = os.environ.get("FORGEJO_TOKEN") or sys.exit("set FORGEJO_TOKEN") BOARD = os.path.join(os.path.dirname(__file__), "..", "campaign", "board.md") LABELS = { # name: (color, description) "status/backlog": ("e4e669", "queued target"), "status/in-progress": ("fbca04", "being mapped"), "status/mapped": ("0e8a16", "analyst done; awaiting verification"), "status/verified": ("1d76db", "re-verifier stamped with evidence"), "status/blocked": ("b60205", "blocked"), "type/object": ("5319e7", "object model / class"), "type/control-flow": ("d93f0b", "entry / loops / dispatch"), "type/subsystem": ("0052cc", "subsystem"), "type/meta": ("bfdadc", "campaign meta / inventories"), "type/verify": ("c2e0c6", "verification task"), "conf/low": ("f9d0c4", "low confidence"), "conf/med": ("fef2c0", "medium confidence"), "conf/high": ("c5def5", "high confidence"), } def req(method, path, data=None, params=None): url = f"{API}{path}" + (("?" + urllib.parse.urlencode(params)) if params else "") body = json.dumps(data).encode() if data is not None else None r = urllib.request.Request(url, data=body, method=method, headers={"Authorization": f"token {TOKEN}", "Content-Type": "application/json"}) with urllib.request.urlopen(r) as resp: t = resp.read() return json.loads(t) if t else None def all_pages(path, **params): out, page = [], 1 while True: chunk = req("GET", path, params=dict(params, limit=100, page=page)) or [] out += chunk if len(chunk) < 100: return out page += 1 def ensure_labels(): have = {l["name"]: l for l in all_pages(f"/repos/{REPO}/labels")} for name, (color, desc) in LABELS.items(): if name not in have: have[name] = req("POST", f"/repos/{REPO}/labels", {"name": name, "color": color, "description": desc}) print(" + label", name) return {n: l["id"] for n, l in have.items()} def parse_board(): rows = [] for line in open(BOARD, encoding="utf-8"): if not line.startswith("|") or line.startswith("| Target") or line.startswith("|---"): continue c = [x.strip() for x in line.strip().strip("|").split("|")] if len(c) < 7: continue target, typ, status, conf, cov, upd, notes = c[:7] rows.append(dict(title=target.replace("`", "").strip(), type=typ, status=status, conf=conf, cov=cov, upd=upd, notes=notes)) return rows def want_labels(row, ids): names = [f"status/{row['status']}", f"type/{row['type']}"] if row["conf"] in ("low", "med", "high"): names.append(f"conf/{row['conf']}") return [ids[n] for n in names if n in ids] def body_for(row): b = [f"**Type:** {row['type']} · **Status:** {row['status']} · **Confidence:** {row['conf']} · **Coverage:** {row['cov']} · **Updated:** {row['upd']}", "", row["notes"] or "", "", "_Tracked from `campaign/board.md`; findings under `findings/`._"] return "\n".join(b) def bootstrap(): ids = ensure_labels() existing = {i["title"]: i for i in all_pages(f"/repos/{REPO}/issues", state="all", type="issues")} for row in parse_board(): labels = want_labels(row, ids) if row["title"] in existing: n = existing[row["title"]]["number"] req("PUT", f"/repos/{REPO}/issues/{n}/labels", {"labels": labels}) req("PATCH", f"/repos/{REPO}/issues/{n}", {"body": body_for(row), "state": "closed" if row["status"] == "verified" else "open"}) print(f" ~ #{n} {row['title']} -> {row['status']}") else: i = req("POST", f"/repos/{REPO}/issues", {"title": row["title"], "body": body_for(row), "labels": labels}) print(f" + #{i['number']} {row['title']} [{row['status']}]") def status(): groups = {} for i in all_pages(f"/repos/{REPO}/issues", state="all", type="issues"): st = next((l["name"].split("/",1)[1] for l in i["labels"] if l["name"].startswith("status/")), "?") groups.setdefault(st, []).append(f"#{i['number']} {i['title']}") for st in ("backlog","in-progress","mapped","verified","blocked","?"): if st in groups: print(f"[{st}] ({len(groups[st])})"); [print(" ", x) for x in groups[st]] cmd = sys.argv[1] if len(sys.argv) > 1 else "status" {"bootstrap": bootstrap, "status": status}[cmd]()