initial
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
"""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 <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 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 with "event" key
|
||||
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. Raw stdout:", file=sys.stderr)
|
||||
print(stdout[:3000], file=sys.stderr)
|
||||
sys.exit(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()
|
||||
Reference in New Issue
Block a user