151 lines
4.9 KiB
Python
151 lines
4.9 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""AI code review for Gitea pull requests.
|
||
|
|
|
||
|
|
Reads the PR diff (base branch vs HEAD), asks Claude to review it, posts the
|
||
|
|
findings as a PR comment via the Gitea API, and exits non-zero only when the
|
||
|
|
review contains at least one severe finding — so the job can gate merges
|
||
|
|
without blocking on nitpicks.
|
||
|
|
|
||
|
|
Required environment:
|
||
|
|
ANTHROPIC_API_KEY Anthropic API key
|
||
|
|
GITEA_TOKEN Gitea token with permission to comment on PRs
|
||
|
|
GITEA_SERVER_URL e.g. https://privaterepo.sitaru.org
|
||
|
|
GITEA_REPOSITORY owner/repo
|
||
|
|
PR_NUMBER pull request index
|
||
|
|
BASE_REF base branch name (e.g. main)
|
||
|
|
"""
|
||
|
|
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
|
||
|
|
import requests
|
||
|
|
from anthropic import Anthropic
|
||
|
|
|
||
|
|
MAX_DIFF_CHARS = 150_000
|
||
|
|
|
||
|
|
REVIEW_SCHEMA = {
|
||
|
|
"type": "object",
|
||
|
|
"properties": {
|
||
|
|
"summary": {
|
||
|
|
"type": "string",
|
||
|
|
"description": "Two or three sentences on what the change does and its overall health.",
|
||
|
|
},
|
||
|
|
"findings": {
|
||
|
|
"type": "array",
|
||
|
|
"items": {
|
||
|
|
"type": "object",
|
||
|
|
"properties": {
|
||
|
|
"severity": {"type": "string", "enum": ["severe", "minor"]},
|
||
|
|
"file": {"type": "string"},
|
||
|
|
"issue": {"type": "string"},
|
||
|
|
},
|
||
|
|
"required": ["severity", "file", "issue"],
|
||
|
|
"additionalProperties": False,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
"required": ["summary", "findings"],
|
||
|
|
"additionalProperties": False,
|
||
|
|
}
|
||
|
|
|
||
|
|
SYSTEM_PROMPT = """You are reviewing a pull request for SchoolCompare, a UK school
|
||
|
|
comparison site (FastAPI backend, Next.js frontend, Airflow/dbt data pipeline,
|
||
|
|
deployed via Gitea Actions to a staging-then-production Docker setup).
|
||
|
|
|
||
|
|
Report correctness bugs, security issues, data-loss risks, and broken deploy/CI
|
||
|
|
configuration. Mark a finding "severe" only if it would break production, leak
|
||
|
|
data, or corrupt data — severe findings block the merge. Everything else
|
||
|
|
(style, performance suggestions, minor cleanups) is "minor". Do not invent
|
||
|
|
findings: an empty findings list is a perfectly good review of a clean diff."""
|
||
|
|
|
||
|
|
|
||
|
|
def get_diff(base_ref: str) -> str:
|
||
|
|
subprocess.run(
|
||
|
|
["git", "fetch", "origin", base_ref],
|
||
|
|
check=True,
|
||
|
|
capture_output=True,
|
||
|
|
)
|
||
|
|
diff = subprocess.run(
|
||
|
|
["git", "diff", f"origin/{base_ref}...HEAD"],
|
||
|
|
check=True,
|
||
|
|
capture_output=True,
|
||
|
|
text=True,
|
||
|
|
).stdout
|
||
|
|
if len(diff) > MAX_DIFF_CHARS:
|
||
|
|
diff = diff[:MAX_DIFF_CHARS] + "\n\n[diff truncated for review]"
|
||
|
|
return diff
|
||
|
|
|
||
|
|
|
||
|
|
def review(diff: str) -> dict:
|
||
|
|
client = Anthropic()
|
||
|
|
with client.messages.stream(
|
||
|
|
model="claude-opus-4-8",
|
||
|
|
max_tokens=16000,
|
||
|
|
thinking={"type": "adaptive"},
|
||
|
|
system=SYSTEM_PROMPT,
|
||
|
|
output_config={"format": {"type": "json_schema", "schema": REVIEW_SCHEMA}},
|
||
|
|
messages=[
|
||
|
|
{
|
||
|
|
"role": "user",
|
||
|
|
"content": f"Review this pull request diff:\n\n```diff\n{diff}\n```",
|
||
|
|
}
|
||
|
|
],
|
||
|
|
) as stream:
|
||
|
|
message = stream.get_final_message()
|
||
|
|
if message.stop_reason == "refusal":
|
||
|
|
raise RuntimeError("Claude declined to review this diff")
|
||
|
|
text = next(b.text for b in message.content if b.type == "text")
|
||
|
|
return json.loads(text)
|
||
|
|
|
||
|
|
|
||
|
|
def format_comment(result: dict) -> str:
|
||
|
|
lines = ["## 🤖 AI Code Review (Claude)", "", result["summary"], ""]
|
||
|
|
severe = [f for f in result["findings"] if f["severity"] == "severe"]
|
||
|
|
minor = [f for f in result["findings"] if f["severity"] == "minor"]
|
||
|
|
if severe:
|
||
|
|
lines.append("### 🔴 Severe (blocks merge)")
|
||
|
|
lines += [f"- **{f['file']}**: {f['issue']}" for f in severe]
|
||
|
|
lines.append("")
|
||
|
|
if minor:
|
||
|
|
lines.append("### 🟡 Minor")
|
||
|
|
lines += [f"- **{f['file']}**: {f['issue']}" for f in minor]
|
||
|
|
lines.append("")
|
||
|
|
if not result["findings"]:
|
||
|
|
lines.append("✅ No issues found.")
|
||
|
|
return "\n".join(lines)
|
||
|
|
|
||
|
|
|
||
|
|
def post_comment(body: str) -> None:
|
||
|
|
server = os.environ["GITEA_SERVER_URL"].rstrip("/")
|
||
|
|
repo = os.environ["GITEA_REPOSITORY"]
|
||
|
|
pr = os.environ["PR_NUMBER"]
|
||
|
|
resp = requests.post(
|
||
|
|
f"{server}/api/v1/repos/{repo}/issues/{pr}/comments",
|
||
|
|
headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}"},
|
||
|
|
json={"body": body},
|
||
|
|
timeout=30,
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
diff = get_diff(os.environ["BASE_REF"])
|
||
|
|
if not diff.strip():
|
||
|
|
print("Empty diff, nothing to review")
|
||
|
|
return 0
|
||
|
|
result = review(diff)
|
||
|
|
comment = format_comment(result)
|
||
|
|
print(comment)
|
||
|
|
post_comment(comment)
|
||
|
|
severe = [f for f in result["findings"] if f["severity"] == "severe"]
|
||
|
|
if severe:
|
||
|
|
print(f"\n{len(severe)} severe finding(s) — failing the check", file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|