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
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:
@@ -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
@@ -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.
|
||||||
|
|||||||
+53
-66
@@ -1,64 +1,50 @@
|
|||||||
#!/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
|
||||||
BASE_REF base branch name (e.g. main)
|
BASE_REF base branch name (e.g. main)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
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:
|
||||||
|
|||||||
Reference in New Issue
Block a user