|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Polls springfall2008/batpred for new GitHub issues and triages each one |
| 3 | +with Claude Code, using a dedicated local clone kept in sync with main. |
| 4 | +
|
| 5 | +Setup (one-time, on whichever always-on machine will run this): |
| 6 | +
|
| 7 | +1. Update Claude Code first: `claude update`. The --max-budget-usd cap |
| 8 | + below only enforces spend on v2.1.217+; older versions accept the |
| 9 | + flag but won't actually cap anything. |
| 10 | +
|
| 11 | +2. Clone a copy of the repo dedicated to this daemon - do NOT point it |
| 12 | + at a machine's normal working copy. Each run does a hard reset to |
| 13 | + origin/main before invoking Claude, which would blow away any |
| 14 | + in-progress work in a real dev checkout. |
| 15 | +
|
| 16 | + mkdir -p ~/predbat-triage-bot |
| 17 | + cd ~/predbat-triage-bot |
| 18 | + git clone https://github.com/springfall2008/batpred |
| 19 | +
|
| 20 | +3. Set up the test venv once, so the skill can run targeted tests: |
| 21 | +
|
| 22 | + cd ~/predbat-triage-bot/batpred/coverage |
| 23 | + source setup.csh |
| 24 | +
|
| 25 | +4. Seed the state file with the CURRENT highest open issue number, so |
| 26 | + the first run doesn't triage the entire existing backlog: |
| 27 | +
|
| 28 | + gh issue list --repo springfall2008/batpred --state open \ |
| 29 | + --json number --limit 200 \ |
| 30 | + | python3 -c "import json,sys; print(max(i['number'] for i in json.load(sys.stdin)))" |
| 31 | +
|
| 32 | + echo '{"last_processed": <number from above>}' > ~/predbat-triage-bot/state.json |
| 33 | +
|
| 34 | +5. Run it. It polls every 5 minutes and keeps running until stopped |
| 35 | + (Ctrl-C, or run it under nohup/tmux/a LaunchAgent to survive a |
| 36 | + closed terminal): |
| 37 | +
|
| 38 | + python3 tools/triage_daemon.py |
| 39 | +
|
| 40 | +What it does per new issue: syncs the dedicated clone to origin/main, |
| 41 | +then runs `claude -p "/issue-triage <number>"` (the skill at |
| 42 | +.claude/skills/issue-triage/SKILL.md) with a permission mode that |
| 43 | +denies anything not explicitly listed below - git/gh read commands, |
| 44 | +running one targeted test, editing files only inside this dedicated |
| 45 | +clone, and fetching issue attachments. All MCP tools are explicitly |
| 46 | +denied (--disallowedTools "mcp__*"), and --max-turns/--max-budget-usd |
| 47 | +cap a single invocation from running away. |
| 48 | +""" |
| 49 | + |
| 50 | +import json |
| 51 | +import subprocess |
| 52 | +import time |
| 53 | +from pathlib import Path |
| 54 | + |
| 55 | +REPO = "springfall2008/batpred" |
| 56 | +BASE_DIR = Path.home() / "predbat-triage-bot" |
| 57 | +CLONE_DIR = BASE_DIR / "batpred" |
| 58 | +STATE_FILE = BASE_DIR / "state.json" |
| 59 | +POLL_SECONDS = 300 |
| 60 | + |
| 61 | +EDIT_SCOPE = f"//{CLONE_DIR.relative_to('/')}/**" |
| 62 | +ALLOWED_TOOLS = ",".join( |
| 63 | + [ |
| 64 | + "Bash(gh *)", |
| 65 | + "Bash(git log*)", |
| 66 | + "Bash(git diff*)", |
| 67 | + "Bash(git show*)", |
| 68 | + "Bash(git blame*)", |
| 69 | + "Bash(git grep*)", |
| 70 | + "Bash(cd *)", |
| 71 | + "Bash(./run_all*)", |
| 72 | + f"Edit({EDIT_SCOPE})", |
| 73 | + "WebFetch", |
| 74 | + "Read", |
| 75 | + "Grep", |
| 76 | + "Glob", |
| 77 | + ] |
| 78 | +) |
| 79 | + |
| 80 | + |
| 81 | +def load_state(): |
| 82 | + try: |
| 83 | + if STATE_FILE.exists(): |
| 84 | + return json.loads(STATE_FILE.read_text()) |
| 85 | + except json.JSONDecodeError: |
| 86 | + return {"last_processed": 0} |
| 87 | + return {"last_processed": 0} |
| 88 | + |
| 89 | + |
| 90 | +def save_state(state): |
| 91 | + BASE_DIR.mkdir(parents=True, exist_ok=True) |
| 92 | + tmp_path = STATE_FILE.with_suffix(STATE_FILE.suffix + ".tmp") |
| 93 | + tmp_path.write_text(json.dumps(state)) |
| 94 | + tmp_path.replace(STATE_FILE) |
| 95 | + |
| 96 | + |
| 97 | +def fetch_new_issues(since_number): |
| 98 | + result = subprocess.run( |
| 99 | + ["gh", "issue", "list", "--repo", REPO, "--state", "open", "--json", "number,createdAt", "--limit", "100"], |
| 100 | + capture_output=True, |
| 101 | + text=True, |
| 102 | + check=True, |
| 103 | + ) |
| 104 | + issues = json.loads(result.stdout) |
| 105 | + new = [i for i in issues if i["number"] > since_number] |
| 106 | + return sorted(new, key=lambda i: i["number"]) |
| 107 | + |
| 108 | + |
| 109 | +def sync_repo(): |
| 110 | + subprocess.run(["git", "-C", str(CLONE_DIR), "fetch", "origin", "main"], check=True) |
| 111 | + subprocess.run(["git", "-C", str(CLONE_DIR), "reset", "--hard", "origin/main"], check=True) |
| 112 | + |
| 113 | + |
| 114 | +def triage(issue_number): |
| 115 | + cmd = [ |
| 116 | + "claude", |
| 117 | + "-p", |
| 118 | + f"/issue-triage {issue_number}", |
| 119 | + "--permission-mode", |
| 120 | + "dontAsk", |
| 121 | + "--allowedTools", |
| 122 | + ALLOWED_TOOLS, |
| 123 | + "--disallowedTools", |
| 124 | + "mcp__*", |
| 125 | + "--max-turns", |
| 126 | + "40", |
| 127 | + # Client-side token-usage estimate, not a real spend cap under subscription |
| 128 | + # auth (see agent-sdk/cost-tracking) - just a circuit-breaker against a |
| 129 | + # runaway invocation, sized generously since one issue can need several |
| 130 | + # file reads plus a test run. |
| 131 | + "--max-budget-usd", |
| 132 | + "10.00", |
| 133 | + ] |
| 134 | + print(f"[triage] issue #{issue_number}: starting", flush=True) |
| 135 | + result = subprocess.run(cmd, cwd=str(CLONE_DIR)) |
| 136 | + if result.returncode != 0: |
| 137 | + raise subprocess.CalledProcessError(result.returncode, cmd) |
| 138 | + print(f"[triage] issue #{issue_number}: exited {result.returncode}", flush=True) |
| 139 | + |
| 140 | + |
| 141 | +def main(): |
| 142 | + if not CLONE_DIR.exists(): |
| 143 | + raise SystemExit(f"Expected a git clone at {CLONE_DIR} - see setup steps before running this daemon.") |
| 144 | + |
| 145 | + state = load_state() |
| 146 | + while True: |
| 147 | + try: |
| 148 | + for issue in fetch_new_issues(state["last_processed"]): |
| 149 | + sync_repo() |
| 150 | + triage(issue["number"]) |
| 151 | + state["last_processed"] = issue["number"] |
| 152 | + save_state(state) |
| 153 | + except subprocess.CalledProcessError as exc: |
| 154 | + print(f"[triage] error: {exc}", flush=True) |
| 155 | + time.sleep(POLL_SECONDS) |
| 156 | + |
| 157 | + |
| 158 | +if __name__ == "__main__": |
| 159 | + main() |
0 commit comments