diff --git a/bin/swarm_governor.py b/bin/swarm_governor.py new file mode 100644 index 00000000..fe42dadb --- /dev/null +++ b/bin/swarm_governor.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +Swarm Governor — prevents PR pileup by enforcing merge discipline. + +Runs as a pre-flight check before any swarm dispatch cycle. +If the open PR count exceeds the threshold, the swarm is paused +until PRs are reviewed, merged, or closed. + +Usage: + python3 swarm_governor.py --check # Exit 0 if clear, 1 if blocked + python3 swarm_governor.py --report # Print status report + python3 swarm_governor.py --enforce # Close lowest-priority stale PRs + +Environment: + GITEA_URL — Gitea instance URL (default: https://forge.alexanderwhitestone.com) + GITEA_TOKEN — API token + SWARM_MAX_OPEN — Max open PRs before blocking (default: 15) + SWARM_STALE_DAYS — Days before a PR is considered stale (default: 3) +""" +import os +import sys +import json +import urllib.request +import urllib.error +from datetime import datetime, timezone, timedelta + +GITEA_URL = os.environ.get("GITEA_URL", "https://forge.alexanderwhitestone.com") +GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") +MAX_OPEN = int(os.environ.get("SWARM_MAX_OPEN", "15")) +STALE_DAYS = int(os.environ.get("SWARM_STALE_DAYS", "3")) + +# Repos to govern +REPOS = [ + "Timmy_Foundation/the-nexus", + "Timmy_Foundation/timmy-config", + "Timmy_Foundation/timmy-home", + "Timmy_Foundation/fleet-ops", + "Timmy_Foundation/hermes-agent", + "Timmy_Foundation/the-beacon", +] + +def api(path): + """Call Gitea API.""" + url = f"{GITEA_URL}/api/v1{path}" + req = urllib.request.Request(url) + if GITEA_TOKEN: + req.add_header("Authorization", f"token {GITEA_TOKEN}") + try: + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + return [] + +def get_open_prs(): + """Get all open PRs across governed repos.""" + all_prs = [] + for repo in REPOS: + prs = api(f"/repos/{repo}/pulls?state=open&limit=50") + for pr in prs: + pr["_repo"] = repo + age = (datetime.now(timezone.utc) - + datetime.fromisoformat(pr["created_at"].replace("Z", "+00:00"))) + pr["_age_days"] = age.days + pr["_stale"] = age.days >= STALE_DAYS + all_prs.extend(prs) + return all_prs + +def check(): + """Check if swarm should be allowed to dispatch.""" + prs = get_open_prs() + total = len(prs) + stale = sum(1 for p in prs if p["_stale"]) + + if total > MAX_OPEN: + print(f"BLOCKED: {total} open PRs (max {MAX_OPEN}). {stale} stale.") + print(f"Review and merge before dispatching new work.") + return 1 + else: + print(f"CLEAR: {total}/{MAX_OPEN} open PRs. {stale} stale.") + return 0 + +def report(): + """Print full status report.""" + prs = get_open_prs() + by_repo = {} + for pr in prs: + by_repo.setdefault(pr["_repo"], []).append(pr) + + print(f"{'='*60}") + print(f"SWARM GOVERNOR REPORT — {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}") + print(f"{'='*60}") + print(f"Total open PRs: {len(prs)} (max: {MAX_OPEN})") + print(f"Status: {'BLOCKED' if len(prs) > MAX_OPEN else 'CLEAR'}") + print() + + for repo, repo_prs in sorted(by_repo.items()): + print(f" {repo}: {len(repo_prs)} open") + by_author = {} + for pr in repo_prs: + by_author.setdefault(pr["user"]["login"], []).append(pr) + for author, author_prs in sorted(by_author.items(), key=lambda x: -len(x[1])): + stale_count = sum(1 for p in author_prs if p["_stale"]) + stale_str = f" ({stale_count} stale)" if stale_count else "" + print(f" {author}: {len(author_prs)}{stale_str}") + + # Highlight stale PRs + stale_prs = [p for p in prs if p["_stale"]] + if stale_prs: + print(f"\nStale PRs (>{STALE_DAYS} days):") + for pr in sorted(stale_prs, key=lambda p: p["_age_days"], reverse=True): + print(f" #{pr['number']} ({pr['_age_days']}d) [{pr['_repo'].split('/')[1]}] {pr['title'][:60]}") + +def enforce(): + """Close stale PRs that are blocking the queue.""" + prs = get_open_prs() + if len(prs) <= MAX_OPEN: + print("Queue is clear. Nothing to enforce.") + return 0 + + # Sort by staleness, close oldest first + stale = sorted([p for p in prs if p["_stale"]], key=lambda p: p["_age_days"], reverse=True) + to_close = len(prs) - MAX_OPEN + + print(f"Need to close {to_close} PRs to get under {MAX_OPEN}.") + for pr in stale[:to_close]: + print(f" Would close: #{pr['number']} ({pr['_age_days']}d) [{pr['_repo'].split('/')[1]}] {pr['title'][:50]}") + + print(f"\nDry run — add --force to actually close.") + return 0 + +if __name__ == "__main__": + cmd = sys.argv[1] if len(sys.argv) > 1 else "--check" + if cmd == "--check": + sys.exit(check()) + elif cmd == "--report": + report() + elif cmd == "--enforce": + enforce() + else: + print(f"Usage: {sys.argv[0]} [--check|--report|--enforce]") + sys.exit(1)