diff --git a/scripts/review_pr.py b/scripts/review_pr.py index fca864a..b15b1c5 100644 --- a/scripts/review_pr.py +++ b/scripts/review_pr.py @@ -317,9 +317,11 @@ def build_prompt(meta, files, diff_path, agent_md, impact_report_path=None): def run_claude(claude_bin, project_root, prompt, extra_dir=None): - """Run claude -p, return stdout.""" + """Run claude with stream-json output, return all text lines.""" cmd = [ claude_bin, "-p", + "--output-format", "stream-json", + "--include-partial-messages", "--permission-mode", "acceptEdits", "--verbose", ] @@ -327,7 +329,7 @@ def run_claude(claude_bin, project_root, prompt, extra_dir=None): cmd += ["--add-dir", extra_dir] cmd.append(prompt) - print(f"[review_pr] Running: {' '.join(cmd[:4])} ...", file=sys.stderr) + print(f"[review_pr] Command: {' '.join(cmd)}", file=sys.stderr) proc = subprocess.Popen( cmd, cwd=project_root, @@ -340,19 +342,45 @@ def run_claude(claude_bin, project_root, prompt, extra_dir=None): def _stream(pipe, label, dest_lines): for line in pipe: line = line.rstrip("\n") - if line: - print(f"[{label}] {line}", file=sys.stderr) + if not line: + continue dest_lines.append(line) - - stderr_lines = [] - t = threading.Thread(target=_stream, args=(proc.stderr, "claude:stderr", stderr_lines)) - t.start() + # Pretty-print stream-json events for progress visibility + try: + msg = json.loads(line) + t = msg.get("type", "") + if t == "assistant": + text = "" + for block in msg.get("message", {}).get("content", []): + if block.get("type") == "text": + text = block.get("text", "") + if text: + print(f"[claude] {text}", file=sys.stderr) + elif t == "tool_use": + name = msg.get("message", {}).get("name", "?") + inp = msg.get("message", {}).get("input", {}) + print(f"[claude] tool: {name}({json.dumps(inp, ensure_ascii=False)})", file=sys.stderr) + elif t == "tool_result": + print(f"[claude] tool: result", file=sys.stderr) + elif t == "system": + subtype = msg.get("subtype", "") + if subtype == "init": + print(f"[claude] system: init", file=sys.stderr) + # skip heartbeat/user/other events to avoid noise + except (json.JSONDecodeError, KeyError, TypeError): + print(f"[claude] {line}", file=sys.stderr) stdout_lines = [] - _stream(proc.stdout, "claude", stdout_lines) + stderr_lines = [] - t.join(timeout=10) - proc.wait(timeout=600) + t_out = threading.Thread(target=_stream, args=(proc.stdout, "claude", stdout_lines)) + t_err = threading.Thread(target=_stream, args=(proc.stderr, "claude:stderr", stderr_lines)) + t_out.start() + t_err.start() + + t_out.join(timeout=600) + t_err.join(timeout=10) + proc.wait(timeout=10) if proc.returncode != 0: print(f"Claude exited with code {proc.returncode}", file=sys.stderr) @@ -363,30 +391,67 @@ def run_claude(claude_bin, project_root, prompt, extra_dir=None): def extract_json_from_output(stdout): - """Extract the review JSON from Claude's output.""" - match = re.search(r"```json\s*([\s\S]*)\s*```", stdout) - if not match: - print("ERROR: No ```json block found in Claude output.", file=sys.stderr) - print(stdout[:3000], file=sys.stderr) - sys.exit(1) + """Extract the review JSON from Claude's output. + Handles both stream-json lines and plain-text ```json blocks. + """ + # ── Try stream-json: collect assistant text ── + assistant_text = _extract_stream_json_text(stdout) + if assistant_text: + obj = _find_review_json(assistant_text) + if obj: + return obj + print("[review_pr] stream-json text gathered but no review JSON found; trying raw ...", + file=sys.stderr) + + # ── Fall back to plain-text ```json block ── + obj = _find_review_json(stdout) + if obj: + return obj + + print("ERROR: No valid review JSON found in Claude output.", file=sys.stderr) + print(stdout[-3000:], file=sys.stderr) + sys.exit(1) + + +def _extract_stream_json_text(stdout): + """Parse stdout as stream-json lines and return concatenated assistant text.""" + texts = [] + for line in stdout.splitlines(): + line = line.strip() + if not line or not line.startswith("{"): + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + if msg.get("type") != "assistant": + continue + for block in msg.get("message", {}).get("content", []): + if block.get("type") == "text": + texts.append(block.get("text", "")) + return "\n".join(texts) if texts else "" + + +def _find_review_json(text): + """Find and parse a review JSON object in *text*. + + Returns the parsed dict, or None. + """ + match = re.search(r"```json\s*([\s\S]*)\s*```", text) + if not match: + return None try: obj = json.loads(match.group(1)) - except json.JSONDecodeError as e: - print(f"ERROR: Invalid JSON in Claude output: {e}", file=sys.stderr) - print(match.group(1)[:2000], file=sys.stderr) - sys.exit(1) - + except json.JSONDecodeError: + return None + if not isinstance(obj, dict): + return None # Unwrap {review: {...}} envelope if present - if isinstance(obj, dict) and "review" in obj and isinstance(obj["review"], dict): + if "review" in obj and isinstance(obj["review"], dict): obj = obj["review"] - - if not isinstance(obj, dict) or "body" not in obj: - print(f"ERROR: JSON missing required 'body' field. " - f"Keys: {list(obj.keys()) if isinstance(obj, dict) else type(obj).__name__}", - file=sys.stderr) - sys.exit(1) - + if "body" not in obj: + return None return obj