Skip to content

Commit 2a7dab5

Browse files
committed
Issue triage script
1 parent e3b63a3 commit 2a7dab5

1 file changed

Lines changed: 295 additions & 0 deletions

File tree

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
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

Comments
 (0)