"""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 tempfile 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", prompt, ] print(f"[review_pr] Running: {' '.join(cmd[:4])} ...") result = subprocess.run( cmd, cwd=project_root, capture_output=True, text=True, timeout=600, # 10 min timeout for review encoding="utf-8", ) if result.returncode != 0: print(f"Claude exited with code {result.returncode}", file=sys.stderr) if result.stderr: print(f"stderr:\n{result.stderr}", file=sys.stderr) sys.exit(1) return result.stdout def extract_json_from_output(stdout): """Extract the JSON block from Claude's output.""" # Try ```json ... ``` block first match = re.search(r"```json\s*([\s\S]*?)\s*```", stdout) if match: return json.loads(match.group(1)) # Try bare JSON object match = re.search(r'\{[\s\S]*"event"[\s\S]*\}', stdout) if match: return json.loads(match.group(0)) print("ERROR: Could not find JSON in Claude output:", file=sys.stderr) print(stdout[:2000], file=sys.stderr) sys.exit(1) def validate_review(review): """Validate the review JSON structure.""" if not isinstance(review, dict): print("ERROR: review is not a JSON object", 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("ERROR: review.body is required and must be a string", 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 to temp file ─────────────────────────────────────── tmp = tempfile.NamedTemporaryFile( mode="w", suffix=".diff", prefix=f"pr{args.pr}_", delete=False, encoding="utf-8", ) tmp.write(diff_text) tmp.close() diff_path = tmp.name 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 = 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()