"""CI orchestrator for code-review-agent. Fetches a PR diff from Gitea, runs Claude Code to analyze it, and posts the review back to the Gitea PR. Usage: python scripts/review_pr.py \ --target-repo owner/repo \ --pr 42 \ --gitea-url https://gitea.example.com \ --api-token """ import argparse import json import os import re import shutil import subprocess import sys import urllib.error import urllib.request MAX_DIFF_CHARS = 200_000 # ~50k words, safe for Claude context def _find_claude(): """Resolve the claude CLI binary path. Checks (in order): 1. CLAUDE_BIN env var (explicit override) 2. ``claude`` on PATH 3. Common install locations (npm global, homebrew, etc.) Returns the path string, or None. """ # 1. Explicit override env_bin = os.environ.get("CLAUDE_BIN", "") if env_bin: if os.path.isfile(env_bin) and os.access(env_bin, os.X_OK): return env_bin print(f"WARNING: CLAUDE_BIN={env_bin} is not executable, falling back.", file=sys.stderr) # 2. On PATH path_bin = shutil.which("claude") if path_bin: return path_bin # 3. Common install locations candidates = [ os.path.expanduser("~/.npm-global/bin/claude"), "/usr/local/bin/claude", "/usr/bin/claude", "/home/linuxbrew/.linuxbrew/bin/claude", ] # Also try npm global prefix try: result = subprocess.run( ["npm", "config", "get", "prefix"], capture_output=True, text=True, timeout=5, ) npm_prefix = result.stdout.strip() if npm_prefix: candidates.append(os.path.join(npm_prefix, "bin", "claude")) except Exception: pass for candidate in candidates: if os.path.isfile(candidate) and os.access(candidate, os.X_OK): return candidate return None def _check_claude(): claude_bin = _find_claude() if not claude_bin: print( "ERROR: claude CLI not found. Install Claude Code or set CLAUDE_BIN env var.", file=sys.stderr, ) sys.exit(1) missing = [] for var in ("ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"): if not os.environ.get(var): missing.append(var) if missing: print( f"ERROR: Required env vars not set: {', '.join(missing)}. " f"Set them in the CI workflow or shell environment.", file=sys.stderr, ) sys.exit(1) return claude_bin def _api_req(gitea_url, api_token, method, path): """Send a Gitea API request, return parsed JSON or raw bytes.""" url = f"{gitea_url}/api/v1/repos{path}" req = urllib.request.Request(url, method=method) req.add_header("Authorization", f"token {api_token}") req.add_header("Content-Type", "application/json") try: with urllib.request.urlopen(req) as resp: raw = resp.read() if not raw: return {} if path.endswith("/merge") else None content_type = resp.headers.get("Content-Type", "") if "application/json" in content_type: return json.loads(raw) return raw # raw bytes for .diff endpoints except urllib.error.HTTPError as e: body = e.read().decode(errors="replace") print(f"API Error {e.code} on {method} {path}: {body}", file=sys.stderr) sys.exit(1) def fetch_pr_metadata(gitea_url, api_token, target_repo, pr_num): """Fetch PR details: title, description, head SHA.""" pr = _api_req(gitea_url, api_token, "GET", f"/{target_repo}/pulls/{pr_num}") return { "number": pr["number"], "title": pr["title"], "body": pr.get("body", ""), "head_sha": pr.get("head", {}).get("sha", ""), "html_url": pr.get("html_url", ""), } def fetch_pr_diff(gitea_url, api_token, target_repo, pr_num): """Fetch raw unified diff for a PR.""" raw = _api_req(gitea_url, api_token, "GET", f"/{target_repo}/pulls/{pr_num}.diff") return raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else "" def fetch_pr_files(gitea_url, api_token, target_repo, pr_num): """Fetch changed file list with stats.""" files = _api_req(gitea_url, api_token, "GET", f"/{target_repo}/pulls/{pr_num}/files") result = [] for f in files: result.append({ "filename": f["filename"], "status": f["status"], "additions": f.get("additions", 0), "deletions": f.get("deletions", 0), }) return result def format_files_summary(files): """Build a one-line-per-file summary for the Claude prompt.""" lines = [] for f in files: lines.append( f" {f['status']:7} {f['filename']} " f"(+{f['additions']} -{f['deletions']})" ) return "\n".join(lines) def build_prompt(meta, files, diff_path): """Build the prompt string for Claude.""" files_summary = format_files_summary(files) total_additions = sum(f["additions"] for f in files) total_deletions = sum(f["deletions"] for f in files) return ( f"You are reviewing PR #{meta['number']} in repository.\n\n" f"PR Title: {meta['title']}\n" f"PR Description:\n{meta.get('body', '(no description)')}\n\n" f"Changed Files ({len(files)} files, +{total_additions} -{total_deletions}):\n" f"{files_summary}\n\n" f"The complete diff has been saved to: {diff_path}\n" f"Read that file to see every line changed.\n\n" f"Analyze the diff thoroughly. Focus on: security, correctness, " f"error handling, performance, and code quality — in that order.\n\n" f"Output your review as a single JSON object inside a ```json code block. " f"Do NOT output anything else." ) def run_claude(claude_bin, agent_path, project_root, prompt): """Run claude -p with the agent definition, return stdout.""" cmd = [ claude_bin, "-p", "--agent", agent_path, "--permission-mode", "acceptEdits", "--verbose", prompt, ] print(f"[review_pr] Running: {' '.join(cmd[:5])} ...", file=sys.stderr) proc = subprocess.Popen( cmd, cwd=project_root, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", ) stdout_lines = [] for line in proc.stdout: line = line.rstrip("\n") stdout_lines.append(line) if line: print(f"[claude] {line}", file=sys.stderr) proc.wait(timeout=600) # Drain remaining stderr stderr_output = proc.stderr.read() if stderr_output: print(stderr_output, file=sys.stderr) if proc.returncode != 0: print(f"Claude exited with code {proc.returncode}", file=sys.stderr) sys.exit(1) return "\n".join(stdout_lines) def _try_parse_json(text): """Try to parse text as JSON, return (obj, None) on success or (None, error) on failure.""" try: return json.loads(text), None except json.JSONDecodeError as e: return None, str(e) def extract_json_from_output(stdout): """Extract the JSON block from Claude's output.""" # ── Try each ```json ... ``` block ── for match in re.finditer(r"```json\s*([\s\S]*?)\s*```", stdout): obj, err = _try_parse_json(match.group(1)) if obj is not None and isinstance(obj, dict) and ("body" in obj or "review" in obj): return obj if err: print(f"[review_pr] Warning: JSON block parse failed ({err[:80]}), trying next...", file=sys.stderr) # ── Try to find JSON object by balancing braces ── for ch in ('{',): idx = stdout.find(ch) while idx != -1: end = _find_matching_brace(stdout, idx) if end > idx: obj, err = _try_parse_json(stdout[idx:end + 1]) if obj is not None and isinstance(obj, dict) and ("body" in obj or "review" in obj): return obj idx = stdout.find(ch, idx + 1) print("ERROR: Could not find valid review JSON in Claude output. Raw stdout:", file=sys.stderr) print(stdout[:3000], file=sys.stderr) sys.exit(1) def _find_matching_brace(text, start): """Given text and the position of an opening brace ``{``, return the position of the matching closing brace ``}``, or -1 if not found.""" if start >= len(text) or text[start] != '{': return -1 depth = 0 i = start in_string = False escape = False while i < len(text): c = text[i] if escape: escape = False i += 1 continue if c == '\\': escape = True i += 1 continue if c == '"': in_string = not in_string elif not in_string: if c == '{': depth += 1 elif c == '}': depth -= 1 if depth == 0: return i i += 1 return -1 def _unwrap_review(obj): """If Claude wrapped the review in a ``{"review": {...}}`` envelope, unwrap it.""" if isinstance(obj, dict) and "review" in obj and isinstance(obj["review"], dict): inner = obj["review"] if "body" in inner: return inner return obj def validate_review(review): """Validate the review JSON structure.""" if not isinstance(review, dict): print(f"ERROR: review is not a JSON object, got {type(review).__name__}", file=sys.stderr) print(f"Raw: {json.dumps(review, ensure_ascii=False)[:2000]}", file=sys.stderr) sys.exit(1) event = review.get("event", "") if event not in ("APPROVED", "REQUEST_CHANGES", "COMMENT", ""): print(f"ERROR: invalid event '{event}'", file=sys.stderr) sys.exit(1) body = review.get("body", "") if not body or not isinstance(body, str): print(f"ERROR: review.body is required and must be a string.", file=sys.stderr) print(f"Got type: {type(body).__name__}, value: {json.dumps(body, ensure_ascii=False)[:500]}", file=sys.stderr) print(f"Full review keys: {list(review.keys())}", file=sys.stderr) sys.exit(1) comments = review.get("comments", []) if not isinstance(comments, list): print("ERROR: review.comments must be an array", file=sys.stderr) sys.exit(1) for i, c in enumerate(comments): if not isinstance(c, dict): print(f"ERROR: comment[{i}] is not an object", file=sys.stderr) sys.exit(1) if "path" not in c or "body" not in c: print(f"ERROR: comment[{i}] missing 'path' or 'body'", file=sys.stderr) sys.exit(1) return True def post_review(gitea_url, api_token, target_repo, pr_num, review): """Post a PR review to Gitea.""" payload = { "body": review["body"], "event": review.get("event", "COMMENT"), } comments = review.get("comments", []) if comments: # Translate {line} -> {new_line} for Gitea API gitea_comments = [] for c in comments: gc = { "path": c["path"], "body": c["body"], } if c.get("line"): gc["new_line"] = c["line"] if c.get("old_line"): gc["old_line"] = c["old_line"] gitea_comments.append(gc) payload["comments"] = gitea_comments body = json.dumps(payload).encode("utf-8") url = f"{gitea_url}/api/v1/repos/{target_repo}/pulls/{pr_num}/reviews" req = urllib.request.Request(url, data=body, method="POST") req.add_header("Authorization", f"token {api_token}") req.add_header("Content-Type", "application/json") try: with urllib.request.urlopen(req) as resp: result = json.loads(resp.read()) print(f"Review posted: {result.get('html_url', result.get('url', 'unknown'))}") return result except urllib.error.HTTPError as e: err_body = e.read().decode(errors="replace") print(f"Failed to post review: {e.code} - {err_body}", file=sys.stderr) sys.exit(1) def main(): parser = argparse.ArgumentParser( description="Code Review Agent — fetch PR, analyze with Claude, post review" ) parser.add_argument("--target-repo", required=True, help="Target repository path (e.g. owner/repo)") parser.add_argument("--pr", type=int, required=True, help="PR number to review") parser.add_argument("--gitea-url", required=True, help="Gitea instance URL") parser.add_argument("--api-token", required=True, help="Gitea API token with read:repository + write:repository") args = parser.parse_args() claude_bin = _check_claude() project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) agent_path = os.path.join(project_root, ".claude", "agents", "code-review-agent.md") if not os.path.exists(agent_path): print(f"ERROR: agent definition not found at {agent_path}", file=sys.stderr) sys.exit(1) # ── 1. Fetch PR metadata ───────────────────────────────────────────── print(f"[review_pr] Fetching PR #{args.pr} metadata from {args.target_repo}...") meta = fetch_pr_metadata(args.gitea_url, args.api_token, args.target_repo, args.pr) print(f" Title: {meta['title']}") # ── 2. Fetch changed files ─────────────────────────────────────────── print("[review_pr] Fetching changed files...") files = fetch_pr_files(args.gitea_url, args.api_token, args.target_repo, args.pr) print(f" {len(files)} file(s) changed") if not files: print("No files changed — skipping review.") return # ── 3. Fetch diff ──────────────────────────────────────────────────── print("[review_pr] Fetching diff...") diff_text = fetch_pr_diff(args.gitea_url, args.api_token, args.target_repo, args.pr) if not diff_text.strip(): print("Empty diff — skipping review.") return if len(diff_text) > MAX_DIFF_CHARS: print( f"Warning: diff is {len(diff_text)} chars " f"(>{MAX_DIFF_CHARS}). Review may be incomplete.", file=sys.stderr, ) diff_text = diff_text[:MAX_DIFF_CHARS] print(f" Diff: {len(diff_text)} chars, ~{diff_text.count(chr(10))} lines") # ── 4. Write diff inside project_root so claude CLI can read it ───── diff_dir = os.path.join(project_root, ".reviews") os.makedirs(diff_dir, exist_ok=True) diff_path = os.path.join(diff_dir, f"pr{args.pr}.diff") with open(diff_path, "w", encoding="utf-8") as f: f.write(diff_text) print(f" Diff saved to: {diff_path}") try: # ── 5. Build prompt & run Claude ───────────────────────────────── prompt = build_prompt(meta, files, diff_path) print("[review_pr] Invoking Claude for analysis...") stdout = run_claude(claude_bin, agent_path, project_root, prompt) # ── 6. Parse Claude output ─────────────────────────────────────── review = _unwrap_review(extract_json_from_output(stdout)) validate_review(review) print(f" Review event: {review.get('event', 'COMMENT')}") print(f" Body length: {len(review['body'])} chars") print(f" Inline comments: {len(review.get('comments', []))}") # ── 7. Post review to Gitea ────────────────────────────────────── print("[review_pr] Posting review to Gitea...") post_review(args.gitea_url, args.api_token, args.target_repo, args.pr, review) print("[review_pr] Done.") finally: # Clean up temp file if os.path.exists(diff_path): os.unlink(diff_path) if __name__ == "__main__": main()