Files
rune/scripts/forge_board.py
T
Nick 8f2ec34bc4
local-review.yml (push) Invalid workflow file
Auto-version and Release / release (push) Successful in 9s
CI Pipeline / lint (push) Failing after 29s
CI Pipeline / go-surface (push) Failing after 18s
CI Pipeline / rust-surface (push) Successful in 4m20s
CI Pipeline / canary (push) Failing after 1m7s
CI Pipeline / best-practices (push) Successful in 7s
CI Pipeline / documentation (push) Successful in 7s
CI Pipeline / gitea-offline (push) Successful in 18s
CI Pipeline / type-check (push) Failing after 35s
CI Pipeline / unit-tests (push) Failing after 8m26s
deploy / deploy (push) Failing after 15m11s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Successful in 8s
fix(lint): annotate the board fetcher's intentional silence — BLE001 baseline (fault-watch fix, completes c73d249)
2026-10-08 14:48:58 +01:00

165 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""forge_board.py — #204: the forge as the single pipeline state machine.
Reads the forge's OWN state (open issues, labels, CI status on each head)
and derives the next action per issue. No agent memory involved: any agent
(or a crashed-and-replaced session) runs this and knows what happens next.
Derivation rules (deterministic, zero tokens):
1. issue carries `blocked` -> BLOCKED (say on what, from the body)
2. head has failing CI -> FIX FIRST (fault-watch contract)
3. head has pending CI -> WAITING (subject + ETA shape)
4. issue carries `awaiting-review`-> REVIEW (reviewer gates decide)
5. no linked work (no contract) -> OPEN CONTRACT (scaffolding is absolute)
6. otherwise -> IMPLEMENT per the body's acceptance
Exit codes: 0 = board read; 1 = forge unreachable; 2 = no open issues
(the factory's only other legal stop).
"""
from __future__ import annotations
import argparse
import json
import sys
from typing import Any, Callable, Dict, List, Optional
Fetch = Callable[[str], Optional[Dict[str, Any]]]
BLOCKED_LABELS = {"blocked", "waiting-on-user", "on-hold"}
REVIEW_LABELS = {"awaiting-review", "in-review", "review-required"}
CI_PENDING = {"pending", "running", "unknown"}
CI_FAILED = {"failure", "cancelled"}
def derive_next_action(issue: Dict[str, Any], ci_state: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Pure derivation: forge state in, next action out. No network, no memory."""
labels = {l.get("name", "") for l in issue.get("labels", [])}
num = issue.get("number")
title = issue.get("title", "")
if labels & BLOCKED_LABELS:
return {
"issue": num,
"title": title,
"action": "BLOCKED",
"why": "carries a blocking label; unblock before the queue touches it",
}
if ci_state:
verdict = ci_state.get("verdict")
if verdict in CI_FAILED:
return {
"issue": num,
"title": title,
"action": "FIX FIRST",
"why": f"CI on head {ci_state.get('sha', '?')[:8]} is {verdict}; the fault-watch contract fixes the submit before the queue resumes",
}
if verdict in CI_PENDING:
return {
"issue": num,
"title": title,
"action": "WAITING",
"why": f"CI on head {ci_state.get('sha', '?')[:8]} is {verdict}; factory multiplexes the next batch meanwhile",
}
if labels & REVIEW_LABELS:
return {
"issue": num,
"title": title,
"action": "REVIEW",
"why": "awaiting reviewer gates (spec-compliance + code-quality)",
}
body = issue.get("body") or ""
if "BUILDER-" in body:
return {
"issue": num,
"title": title,
"action": "CONTINUE CONTRACT",
"why": "a construction contract is already linked in the body",
}
return {
"issue": num,
"title": title,
"action": "OPEN CONTRACT",
"why": "no linked work in the body; scaffolding is absolute — contract first",
}
def read_board(
fetch: Fetch, owner: str = "conductor", repo: str = "rune", base: str = "http://127.0.0.1:3000/api/v1"
) -> Dict[str, Any]:
"""Pull open issues + CI state from the forge and derive the board."""
raw = fetch(f"{base}/repos/{owner}/{repo}/issues?state=open&limit=50")
if raw is None:
raise RuntimeError("forge unreachable")
issues = raw if isinstance(raw, list) else raw.get("data", [])
board: List[Dict[str, Any]] = []
for issue in issues:
sha = _head_sha_from_labels(issue) or _head_sha_from_body(issue)
ci_state = None
if sha:
status = fetch(f"{base}/repos/{owner}/{repo}/commits/{sha}/status")
if status:
ci_state = {"sha": sha, "verdict": status.get("state", "unknown")}
board.append(derive_next_action(issue, ci_state))
order = {"FIX FIRST": 0, "CONTINUE CONTRACT": 1, "OPEN CONTRACT": 2, "REVIEW": 3, "WAITING": 4, "BLOCKED": 5}
board.sort(key=lambda r: (order.get(r["action"], 9), r["issue"] or 0))
return {"repo": f"{owner}/{repo}", "open": len(board), "board": board}
def _head_sha_from_labels(issue: Dict[str, Any]) -> Optional[str]:
for l in issue.get("labels", []):
name = l.get("name", "")
if name.startswith("head:"):
return name.split("head:", 1)[1].strip()
return None
def _head_sha_from_body(issue: Dict[str, Any]) -> Optional[str]:
import re
m = re.search(r"\b([0-9a-f]{40})\b", issue.get("body") or "")
return m.group(1) if m else None
def _default_fetch(url: str) -> Optional[Any]:
try:
import urllib.request
sys.path.insert(0, "/home/nick/.config/opencode/shared")
from gitea_auth import load_gitea_token
req = urllib.request.Request(url, headers={"Authorization": f"token {load_gitea_token()}"})
with urllib.request.urlopen(req, timeout=10) as r:
return json.load(r)
except Exception: # noqa: BLE001 — fetcher probe: unreachability is reported upstream as a hard error (read_board raises)
return None
def main() -> int:
ap = argparse.ArgumentParser(description="#204 forge board: the next action, from forge state alone")
ap.add_argument("--json", action="store_true", help="machine-readable output")
args = ap.parse_args()
try:
board = read_board(_default_fetch)
except RuntimeError as e:
print(f"forge_board: {e}", file=sys.stderr)
return 1
if args.json:
print(json.dumps(board, indent=2))
else:
print(f"== FORGE BOARD {board['repo']} — {board['open']} open ==")
for row in board["board"]:
print(f" #{row['issue']} [{row['action']}] {row['title'][:60]} — {row['why']}")
if board["open"] == 0:
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())