campaign: forgejo issues sync script; issues are the published board

This commit is contained in:
alex 2026-09-07 15:04:57 -04:00
parent 3bdc83fe29
commit 35269d0083
2 changed files with 106 additions and 0 deletions

View file

@ -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.

103
scripts/forgejo_campaign.py Executable file
View file

@ -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]()