From 35269d0083dd3c1e748dbb2f29eecc0025bbd1d6 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 7 Sep 2026 15:04:57 -0400 Subject: [PATCH] campaign: forgejo issues sync script; issues are the published board --- README.md | 3 ++ scripts/forgejo_campaign.py | 103 ++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100755 scripts/forgejo_campaign.py diff --git a/README.md b/README.md index 8a93b49..91cd471 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ A 4-agent crew (defined in `~/.claude/agents/re-*.md`) runs the exploration: **re-scribe** (files the note, links it, commits). - `campaign/board.md` — live status board (start here). +- **Live tracking = Forgejo issues** on `alex/sots-re` (labels `status/*` are the kanban + columns; `type/*`, `conf/*`). `campaign/board.md` is the editable mirror — publish with + `scripts/forgejo_campaign.py bootstrap` (needs `FORGEJO_TOKEN`). - `campaign/backlog.md` — prioritized target queue. - `campaign/open-questions.md` — unresolved threads. - `findings/_template.md` — the record format every finding follows. diff --git a/scripts/forgejo_campaign.py b/scripts/forgejo_campaign.py new file mode 100755 index 0000000..6add358 --- /dev/null +++ b/scripts/forgejo_campaign.py @@ -0,0 +1,103 @@ +#!/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]()