fix(ci): AI review via Claude Code CLI; reuse REGISTRY_TOKEN for PR comments
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m39s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 42s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 27s

- ai_review.py now pipes the diff through headless Claude Code (claude -p,
  --output-format json) authenticated with CLAUDE_CODE_OAUTH_TOKEN from
  'claude setup-token' — subscription auth, no Anthropic API billing
- stdlib-only script (urllib instead of requests/anthropic)
- PR comments posted with the existing REGISTRY_TOKEN secret; the separate
  GITEA_TOKEN secret is no longer needed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
This commit is contained in:
Tudor
2026-07-03 08:28:20 +01:00
co-authored by Claude Fable 5
parent df0bf1c4d6
commit d0895c71df
3 changed files with 71 additions and 78 deletions
+10 -5
View File
@@ -167,13 +167,18 @@ jobs:
with:
python-version: "3.12"
- name: Install dependencies
run: pip install anthropic requests
- name: Set up Node.js
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:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
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 |
|---|---|
| `REGISTRY_TOKEN` | push images to privaterepo.sitaru.org (already set) |
| `ANTHROPIC_API_KEY` | Claude PR review (`scripts/ci/ai_review.py`) |
| `GITEA_TOKEN` | post PR review comments (needs issue-comment scope) |
| `REGISTRY_TOKEN` | push images to the registry + post PR review comments (already set) |
| `CLAUDE_CODE_OAUTH_TOKEN` | Claude Code subscription auth for the PR review — generate with `claude setup-token` on your machine |
| `PORTAINER_STAGING_WEBHOOK` | staging 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 |
@@ -124,7 +123,9 @@ numbers, so scheduled data refreshes don't break the gate.
## AI code review
`scripts/ci/ai_review.py` sends the PR diff to Claude (`claude-opus-4-8`),
posts the structured findings as a PR comment, 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.
`scripts/ci/ai_review.py` pipes the PR diff through headless Claude Code
(`claude -p`, authenticated with the subscription OAuth token — no API
billing), posts the structured findings as a PR comment via the Gitea API
(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.
+53 -66
View File
@@ -1,64 +1,50 @@
#!/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
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.
Reads the PR diff (base branch vs HEAD), asks Claude Code (headless `claude -p`)
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.
Uses only the Python standard library; the review itself runs through the
Claude Code CLI, authenticated with a subscription OAuth token.
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)
CLAUDE_CODE_OAUTH_TOKEN token from `claude setup-token` (subscription auth)
GITEA_TOKEN Gitea access token for posting PR comments
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
import urllib.request
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
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).
The PR diff is provided on stdin.
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."""
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:
@@ -79,29 +65,25 @@ def get_diff(base_ref: str) -> str:
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)
proc = subprocess.run(
["claude", "-p", PROMPT, "--output-format", "json"],
input=diff,
capture_output=True,
text=True,
timeout=900,
)
if proc.returncode != 0:
raise RuntimeError(f"claude CLI failed:\n{proc.stderr}")
envelope = json.loads(proc.stdout)
result = envelope["result"].strip()
# Defensive: strip markdown fences if the model added them anyway
if result.startswith("```"):
result = result.split("\n", 1)[1].rsplit("```", 1)[0]
return json.loads(result)
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"]
minor = [f for f in result["findings"] if f["severity"] == "minor"]
if severe:
@@ -121,13 +103,18 @@ 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(
req = urllib.request.Request(
f"{server}/api/v1/repos/{repo}/issues/{pr}/comments",
headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}"},
json={"body": body},
timeout=30,
data=json.dumps({"body": body}).encode(),
headers={
"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: