|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Fetch and triage a CircuitPython GitHub issue/PR via pi RPC mode.""" |
| 3 | + |
| 4 | +from __future__ import annotations |
| 5 | + |
| 6 | +import argparse |
| 7 | +import json |
| 8 | +import queue |
| 9 | +import subprocess |
| 10 | +import sys |
| 11 | +import threading |
| 12 | +import uuid |
| 13 | +from datetime import datetime |
| 14 | +from pathlib import Path |
| 15 | +from typing import Any |
| 16 | + |
| 17 | + |
| 18 | +DEFAULT_REPO = "adafruit/circuitpython" |
| 19 | + |
| 20 | + |
| 21 | +class PiRpcClient: |
| 22 | + def __init__( |
| 23 | + self, repo_root: Path, provider: str | None, model: str | None, thinking: str | None |
| 24 | + ): |
| 25 | + cmd = ["pi", "--mode", "rpc", "--no-extensions", "--no-skills", "--no-prompt-templates"] |
| 26 | + if provider: |
| 27 | + cmd.extend(["--provider", provider]) |
| 28 | + if model: |
| 29 | + cmd.extend(["--model", model]) |
| 30 | + if thinking: |
| 31 | + cmd.extend(["--thinking", thinking]) |
| 32 | + |
| 33 | + self.proc = subprocess.Popen( |
| 34 | + cmd, |
| 35 | + cwd=repo_root, |
| 36 | + stdin=subprocess.PIPE, |
| 37 | + stdout=subprocess.PIPE, |
| 38 | + stderr=subprocess.PIPE, |
| 39 | + text=True, |
| 40 | + bufsize=1, |
| 41 | + ) |
| 42 | + if not self.proc.stdin or not self.proc.stdout: |
| 43 | + raise RuntimeError("Failed to start pi RPC process") |
| 44 | + |
| 45 | + self._queue: queue.Queue[dict[str, Any]] = queue.Queue() |
| 46 | + self._reader = threading.Thread(target=self._read_stdout, daemon=True) |
| 47 | + self._reader.start() |
| 48 | + |
| 49 | + def _read_stdout(self) -> None: |
| 50 | + assert self.proc.stdout is not None |
| 51 | + for line in self.proc.stdout: |
| 52 | + line = line.strip() |
| 53 | + if not line: |
| 54 | + continue |
| 55 | + try: |
| 56 | + self._queue.put(json.loads(line)) |
| 57 | + except json.JSONDecodeError: |
| 58 | + # Ignore non-JSON lines. |
| 59 | + continue |
| 60 | + |
| 61 | + def send(self, payload: dict[str, Any]) -> str: |
| 62 | + req_id = str(uuid.uuid4()) |
| 63 | + payload = dict(payload) |
| 64 | + payload["id"] = req_id |
| 65 | + assert self.proc.stdin is not None |
| 66 | + self.proc.stdin.write(json.dumps(payload) + "\n") |
| 67 | + self.proc.stdin.flush() |
| 68 | + return req_id |
| 69 | + |
| 70 | + def close(self) -> None: |
| 71 | + if self.proc.poll() is None: |
| 72 | + self.proc.terminate() |
| 73 | + try: |
| 74 | + self.proc.wait(timeout=5) |
| 75 | + except subprocess.TimeoutExpired: |
| 76 | + self.proc.kill() |
| 77 | + |
| 78 | + |
| 79 | +def run_gh_api(path: str) -> Any: |
| 80 | + result = subprocess.run( |
| 81 | + ["gh", "api", path], |
| 82 | + text=True, |
| 83 | + capture_output=True, |
| 84 | + check=False, |
| 85 | + ) |
| 86 | + if result.returncode != 0: |
| 87 | + raise RuntimeError(f"gh api failed ({path}): {result.stderr.strip()}") |
| 88 | + return json.loads(result.stdout) |
| 89 | + |
| 90 | + |
| 91 | +def shorten(text: str | None, limit: int = 4000) -> str: |
| 92 | + if not text: |
| 93 | + return "" |
| 94 | + text = text.strip() |
| 95 | + if len(text) <= limit: |
| 96 | + return text |
| 97 | + return text[:limit] + "\n...[truncated]..." |
| 98 | + |
| 99 | + |
| 100 | +def fetch_issue_context(repo: str, number: int, max_comments: int) -> str: |
| 101 | + issue = run_gh_api(f"repos/{repo}/issues/{number}") |
| 102 | + comments = run_gh_api(f"repos/{repo}/issues/{number}/comments") |
| 103 | + comments = comments[-max_comments:] if max_comments > 0 else [] |
| 104 | + |
| 105 | + pr_data = None |
| 106 | + pr_files = None |
| 107 | + if issue.get("pull_request"): |
| 108 | + pr_data = run_gh_api(f"repos/{repo}/pulls/{number}") |
| 109 | + pr_files = run_gh_api(f"repos/{repo}/pulls/{number}/files") |
| 110 | + |
| 111 | + lines: list[str] = [] |
| 112 | + labels = [l["name"] for l in issue.get("labels", [])] |
| 113 | + lines.extend( |
| 114 | + [ |
| 115 | + f"Repository: {repo}", |
| 116 | + f"Number: #{issue['number']}", |
| 117 | + f"Type: {'PR' if pr_data else 'Issue'}", |
| 118 | + f"Title: {issue.get('title', '')}", |
| 119 | + f"State: {issue.get('state', '')}", |
| 120 | + f"URL: {issue.get('html_url', '')}", |
| 121 | + f"Author: {issue.get('user', {}).get('login', '')}", |
| 122 | + f"Created: {issue.get('created_at', '')}", |
| 123 | + f"Updated: {issue.get('updated_at', '')}", |
| 124 | + f"Labels: {', '.join(labels) if labels else '(none)'}", |
| 125 | + "", |
| 126 | + "Issue Body:", |
| 127 | + shorten(issue.get("body"), 12000) or "(empty)", |
| 128 | + "", |
| 129 | + f"Comments (latest {len(comments)}):", |
| 130 | + ] |
| 131 | + ) |
| 132 | + |
| 133 | + for idx, comment in enumerate(comments, 1): |
| 134 | + lines.extend( |
| 135 | + [ |
| 136 | + f"--- Comment {idx} by {comment.get('user', {}).get('login', '')} at {comment.get('created_at', '')} ---", |
| 137 | + shorten(comment.get("body"), 3000) or "(empty)", |
| 138 | + ] |
| 139 | + ) |
| 140 | + |
| 141 | + if pr_data: |
| 142 | + lines.extend( |
| 143 | + [ |
| 144 | + "", |
| 145 | + "PR Details:", |
| 146 | + f"Draft: {pr_data.get('draft')}", |
| 147 | + f"Merged: {pr_data.get('merged')}", |
| 148 | + f"Mergeable state: {pr_data.get('mergeable_state')}", |
| 149 | + f"Base: {pr_data.get('base', {}).get('ref')}", |
| 150 | + f"Head: {pr_data.get('head', {}).get('ref')} ({pr_data.get('head', {}).get('repo', {}).get('full_name')})", |
| 151 | + f"Changed files: {pr_data.get('changed_files')}", |
| 152 | + f"Additions/Deletions: {pr_data.get('additions')}/{pr_data.get('deletions')}", |
| 153 | + "", |
| 154 | + "PR File List:", |
| 155 | + ] |
| 156 | + ) |
| 157 | + for f in pr_files or []: |
| 158 | + lines.append( |
| 159 | + f"- {f.get('filename')} (+{f.get('additions')}/-{f.get('deletions')}, {f.get('status')})" |
| 160 | + ) |
| 161 | + |
| 162 | + return "\n".join(lines) |
| 163 | + |
| 164 | + |
| 165 | +def build_prompt(issue_context: str, issue_number: int, apply_fix: bool) -> str: |
| 166 | + maybe_fix = ( |
| 167 | + "If the required code change is minimal and low-risk, create a new branch from main, implement it, and commit. " |
| 168 | + "If not minimal, explain why no code changes were made." |
| 169 | + if apply_fix |
| 170 | + else "Do not modify files or branches; analysis only." |
| 171 | + ) |
| 172 | + |
| 173 | + return f""" |
| 174 | +You are triaging CircuitPython GitHub issue/PR #{issue_number} in a local CircuitPython checkout. |
| 175 | +
|
| 176 | +Use repository files and git history as needed. Please provide: |
| 177 | +1) One-line summary. |
| 178 | +2) Exact hardware needed to test. |
| 179 | +3) A candidate code.py test script. |
| 180 | +4) What native_sim (Zephyr) tests would be needed. |
| 181 | +5) Whether this appears already fixed in git history (show evidence: commits/files). |
| 182 | +6) Additional labels to add. |
| 183 | +7) If not fixed, a concrete plan to fix. |
| 184 | +8) {maybe_fix} |
| 185 | +
|
| 186 | +When checking if fixed already, focus git history on likely relevant paths/files and cite commit hashes. |
| 187 | +
|
| 188 | +Return results in sections numbered 1-8. |
| 189 | +
|
| 190 | +GitHub context: |
| 191 | +{issue_context} |
| 192 | +""".strip() |
| 193 | + |
| 194 | + |
| 195 | +def wait_for_response(client: PiRpcClient, req_id: str) -> dict[str, Any]: |
| 196 | + while True: |
| 197 | + msg = client._queue.get() |
| 198 | + if msg.get("type") == "response" and msg.get("id") == req_id: |
| 199 | + return msg |
| 200 | + |
| 201 | + |
| 202 | +def rpc_command(client: PiRpcClient, payload: dict[str, Any]) -> dict[str, Any]: |
| 203 | + req_id = client.send(payload) |
| 204 | + resp = wait_for_response(client, req_id) |
| 205 | + if not resp.get("success"): |
| 206 | + raise RuntimeError(f"pi {payload.get('type')} failed: {resp.get('error')}") |
| 207 | + return resp.get("data") or {} |
| 208 | + |
| 209 | + |
| 210 | +def run_agent_prompt(client: PiRpcClient, prompt: str) -> str: |
| 211 | + rpc_command(client, {"type": "prompt", "message": prompt}) |
| 212 | + |
| 213 | + # Stream text deltas live while waiting for completion. |
| 214 | + while True: |
| 215 | + msg = client._queue.get() |
| 216 | + if msg.get("type") == "message_update": |
| 217 | + evt = msg.get("assistantMessageEvent", {}) |
| 218 | + if evt.get("type") == "text_delta": |
| 219 | + sys.stdout.write(evt.get("delta", "")) |
| 220 | + sys.stdout.flush() |
| 221 | + elif msg.get("type") == "agent_end": |
| 222 | + break |
| 223 | + |
| 224 | + sys.stdout.write("\n") |
| 225 | + |
| 226 | + return rpc_command(client, {"type": "get_last_assistant_text"}).get("text") or "" |
| 227 | + |
| 228 | + |
| 229 | +def main() -> int: |
| 230 | + parser = argparse.ArgumentParser(description=__doc__) |
| 231 | + parser.add_argument("issue", type=int, help="CircuitPython issue/PR number") |
| 232 | + parser.add_argument( |
| 233 | + "--repo", default=DEFAULT_REPO, help="GitHub repo (default: adafruit/circuitpython)" |
| 234 | + ) |
| 235 | + parser.add_argument( |
| 236 | + "--max-comments", type=int, default=15, help="Include up to this many latest comments" |
| 237 | + ) |
| 238 | + parser.add_argument("--provider", help="pi provider") |
| 239 | + parser.add_argument("--model", help="pi model") |
| 240 | + parser.add_argument("--thinking", help="pi thinking level") |
| 241 | + parser.add_argument( |
| 242 | + "--apply-fix", |
| 243 | + action="store_true", |
| 244 | + help="Allow pi to create branch and implement minimal fix", |
| 245 | + ) |
| 246 | + parser.add_argument( |
| 247 | + "--log-dir", |
| 248 | + default=".triage-reports", |
| 249 | + help="Directory (relative to repo root) for exported pi HTML logs", |
| 250 | + ) |
| 251 | + args = parser.parse_args() |
| 252 | + |
| 253 | + repo_root = Path(__file__).resolve().parents[1] |
| 254 | + if not (repo_root / ".git").exists(): |
| 255 | + raise RuntimeError(f"Could not find repository root at {repo_root}") |
| 256 | + |
| 257 | + context = fetch_issue_context(args.repo, args.issue, args.max_comments) |
| 258 | + prompt = build_prompt(context, args.issue, args.apply_fix) |
| 259 | + |
| 260 | + print(f"Fetched #{args.issue} from {args.repo}. Starting pi triage...", file=sys.stderr) |
| 261 | + if args.apply_fix: |
| 262 | + print( |
| 263 | + "Note: --apply-fix enabled. Agent will decide whether to create a branch from main and commit minimal changes.", |
| 264 | + file=sys.stderr, |
| 265 | + ) |
| 266 | + |
| 267 | + export_dir = repo_root / args.log_dir |
| 268 | + export_dir.mkdir(parents=True, exist_ok=True) |
| 269 | + ts = datetime.now().strftime("%Y%m%d-%H%M%S") |
| 270 | + export_path = export_dir / f"pi-triage-{args.issue}-{ts}.html" |
| 271 | + |
| 272 | + client = PiRpcClient( |
| 273 | + repo_root=repo_root, provider=args.provider, model=args.model, thinking=args.thinking |
| 274 | + ) |
| 275 | + try: |
| 276 | + final_text = run_agent_prompt(client, prompt) |
| 277 | + stats = rpc_command(client, {"type": "get_session_stats"}) |
| 278 | + rpc_command(client, {"type": "export_html", "outputPath": str(export_path)}) |
| 279 | + finally: |
| 280 | + client.close() |
| 281 | + |
| 282 | + cost = stats.get("cost") |
| 283 | + print("\n===== Final triage report =====\n") |
| 284 | + print(final_text) |
| 285 | + print("\n===== Metadata =====") |
| 286 | + if isinstance(cost, (int, float)): |
| 287 | + print(f"Analysis cost: ${cost:.6f}") |
| 288 | + else: |
| 289 | + print(f"Analysis cost: {cost}") |
| 290 | + print(f"pi log export: {export_path}") |
| 291 | + return 0 |
| 292 | + |
| 293 | + |
| 294 | +if __name__ == "__main__": |
| 295 | + raise SystemExit(main()) |
0 commit comments