From d0895c71df7685cc7488927f6d3cae2592c58606 Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 3 Jul 2026 08:28:20 +0100 Subject: [PATCH] fix(ci): AI review via Claude Code CLI; reuse REGISTRY_TOKEN for PR comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL --- .gitea/workflows/pr-checks.yml | 15 +++-- docs/DEPLOY.md | 15 +++-- scripts/ci/ai_review.py | 119 +++++++++++++++------------------ 3 files changed, 71 insertions(+), 78 deletions(-) diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 189888f..b0859a5 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -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 }} diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 0bc3148..e7ca948 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -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. diff --git a/scripts/ci/ai_review.py b/scripts/ci/ai_review.py index a7a828e..1ce4a05 100644 --- a/scripts/ci/ai_review.py +++ b/scripts/ci/ai_review.py @@ -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: -- 2.54.0