fix(ci): AI review via Claude Code CLI; reuse REGISTRY_TOKEN #3

Merged
tudor merged 1 commits from fix/ai-review-claude-code into main 2026-07-03 08:50:27 +00:00
3 changed files with 71 additions and 78 deletions
+10 -5
View File
@@ -167,13 +167,18 @@ jobs:
with: with:
python-version: "3.12" python-version: "3.12"
- name: Install dependencies - name: Set up Node.js
run: pip install anthropic requests uses: actions/setup-node@v4
with:
node-version: 22
- name: Review PR diff with Claude - name: Install Claude Code
run: npm install -g @anthropic-ai/claude-code
- name: Review PR diff with Claude Code
env: env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }} GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }} GITEA_REPOSITORY: ${{ gitea.repository }}
PR_NUMBER: ${{ gitea.event.pull_request.number }} PR_NUMBER: ${{ gitea.event.pull_request.number }}
+8 -7
View File
@@ -56,9 +56,8 @@ fail the E2E gate. That's the point: staging absorbs the risk.
| Secret | Purpose | | Secret | Purpose |
|---|---| |---|---|
| `REGISTRY_TOKEN` | push images to privaterepo.sitaru.org (already set) | | `REGISTRY_TOKEN` | push images to the registry + post PR review comments (already set) |
| `ANTHROPIC_API_KEY` | Claude PR review (`scripts/ci/ai_review.py`) | | `CLAUDE_CODE_OAUTH_TOKEN` | Claude Code subscription auth for the PR review — generate with `claude setup-token` on your machine |
| `GITEA_TOKEN` | post PR review comments (needs issue-comment scope) |
| `PORTAINER_STAGING_WEBHOOK` | staging stack redeploy webhook URL | | `PORTAINER_STAGING_WEBHOOK` | staging stack redeploy webhook URL |
| `PORTAINER_PROD_WEBHOOK` | production stack redeploy webhook URL | | `PORTAINER_PROD_WEBHOOK` | production stack redeploy webhook URL |
| `STAGING_BASE_URL` | e.g. `http://10.0.1.151:3000` — health poll + E2E target | | `STAGING_BASE_URL` | e.g. `http://10.0.1.151:3000` — health poll + E2E target |
@@ -124,7 +123,9 @@ numbers, so scheduled data refreshes don't break the gate.
## AI code review ## AI code review
`scripts/ci/ai_review.py` sends the PR diff to Claude (`claude-opus-4-8`), `scripts/ci/ai_review.py` pipes the PR diff through headless Claude Code
posts the structured findings as a PR comment, and fails the check only when a (`claude -p`, authenticated with the subscription OAuth token — no API
finding is rated **severe** (would break prod, leak data, or corrupt data). billing), posts the structured findings as a PR comment via the Gitea API
Minor findings are informational and never block a merge. (reusing `REGISTRY_TOKEN`), and fails the check only when a finding is rated
**severe** (would break prod, leak data, or corrupt data). Minor findings are
informational and never block a merge.
+49 -62
View File
@@ -1,14 +1,17 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""AI code review for Gitea pull requests. """AI code review for Gitea pull requests, powered by Claude Code.
Reads the PR diff (base branch vs HEAD), asks Claude to review it, posts the Reads the PR diff (base branch vs HEAD), asks Claude Code (headless `claude -p`)
findings as a PR comment via the Gitea API, and exits non-zero only when the to review it, posts the findings as a PR comment via the Gitea API, and exits
review contains at least one severe finding — so the job can gate merges non-zero only when the review contains at least one severe finding — so the
without blocking on nitpicks. job can gate merges without blocking on nitpicks.
Uses only the Python standard library; the review itself runs through the
Claude Code CLI, authenticated with a subscription OAuth token.
Required environment: Required environment:
ANTHROPIC_API_KEY Anthropic API key CLAUDE_CODE_OAUTH_TOKEN token from `claude setup-token` (subscription auth)
GITEA_TOKEN Gitea token with permission to comment on PRs GITEA_TOKEN Gitea access token for posting PR comments
GITEA_SERVER_URL e.g. https://privaterepo.sitaru.org GITEA_SERVER_URL e.g. https://privaterepo.sitaru.org
GITEA_REPOSITORY owner/repo GITEA_REPOSITORY owner/repo
PR_NUMBER pull request index PR_NUMBER pull request index
@@ -19,46 +22,29 @@ import json
import os import os
import subprocess import subprocess
import sys import sys
import urllib.request
import requests
from anthropic import Anthropic
MAX_DIFF_CHARS = 150_000 MAX_DIFF_CHARS = 150_000
REVIEW_SCHEMA = { PROMPT = """You are reviewing a pull request for SchoolCompare, a UK school
"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, comparison site (FastAPI backend, Next.js frontend, Airflow/dbt data pipeline,
deployed via Gitea Actions to a staging-then-production Docker setup). deployed via Gitea Actions to a staging-then-production Docker setup).
The PR diff is provided on stdin.
Report correctness bugs, security issues, data-loss risks, and broken deploy/CI Report correctness bugs, security issues, data-loss risks, and broken deploy/CI
configuration. Mark a finding "severe" only if it would break production, leak configuration. Mark a finding "severe" only if it would break production, leak
data, or corrupt data — severe findings block the merge. Everything else data, or corrupt data — severe findings block the merge. Everything else
(style, performance suggestions, minor cleanups) is "minor". Do not invent (style, performance suggestions, minor cleanups) is "minor". Do not invent
findings: an empty findings list is a perfectly good review of a clean diff.""" findings: an empty findings list is a perfectly good review of a clean diff.
Respond with ONLY a JSON object (no markdown fences, no prose) of this shape:
{
"summary": "two or three sentences on what the change does and its health",
"findings": [
{"severity": "severe" | "minor", "file": "path", "issue": "description"}
]
}"""
def get_diff(base_ref: str) -> str: def get_diff(base_ref: str) -> str:
@@ -79,29 +65,25 @@ def get_diff(base_ref: str) -> str:
def review(diff: str) -> dict: def review(diff: str) -> dict:
client = Anthropic() proc = subprocess.run(
with client.messages.stream( ["claude", "-p", PROMPT, "--output-format", "json"],
model="claude-opus-4-8", input=diff,
max_tokens=16000, capture_output=True,
thinking={"type": "adaptive"}, text=True,
system=SYSTEM_PROMPT, timeout=900,
output_config={"format": {"type": "json_schema", "schema": REVIEW_SCHEMA}}, )
messages=[ if proc.returncode != 0:
{ raise RuntimeError(f"claude CLI failed:\n{proc.stderr}")
"role": "user", envelope = json.loads(proc.stdout)
"content": f"Review this pull request diff:\n\n```diff\n{diff}\n```", result = envelope["result"].strip()
} # Defensive: strip markdown fences if the model added them anyway
], if result.startswith("```"):
) as stream: result = result.split("\n", 1)[1].rsplit("```", 1)[0]
message = stream.get_final_message() return json.loads(result)
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: def format_comment(result: dict) -> str:
lines = ["## 🤖 AI Code Review (Claude)", "", result["summary"], ""] lines = ["## 🤖 AI Code Review (Claude Code)", "", result["summary"], ""]
severe = [f for f in result["findings"] if f["severity"] == "severe"] severe = [f for f in result["findings"] if f["severity"] == "severe"]
minor = [f for f in result["findings"] if f["severity"] == "minor"] minor = [f for f in result["findings"] if f["severity"] == "minor"]
if severe: if severe:
@@ -121,13 +103,18 @@ def post_comment(body: str) -> None:
server = os.environ["GITEA_SERVER_URL"].rstrip("/") server = os.environ["GITEA_SERVER_URL"].rstrip("/")
repo = os.environ["GITEA_REPOSITORY"] repo = os.environ["GITEA_REPOSITORY"]
pr = os.environ["PR_NUMBER"] pr = os.environ["PR_NUMBER"]
resp = requests.post( req = urllib.request.Request(
f"{server}/api/v1/repos/{repo}/issues/{pr}/comments", f"{server}/api/v1/repos/{repo}/issues/{pr}/comments",
headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}"}, data=json.dumps({"body": body}).encode(),
json={"body": body}, headers={
timeout=30, "Authorization": f"token {os.environ['GITEA_TOKEN']}",
"Content-Type": "application/json",
},
method="POST",
) )
resp.raise_for_status() with urllib.request.urlopen(req, timeout=30) as resp:
if resp.status >= 300:
raise RuntimeError(f"Comment post failed: HTTP {resp.status}")
def main() -> int: def main() -> int: