Compare commits

...
Author SHA1 Message Date
TudorandClaude Fable 5 d0100cce69 feat(ci): comment-triggered PR fix-ups via @claude
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m41s
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 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m30s
Commenting '@claude <instruction>' on a PR runs headless Claude Code on the
PR branch (subscription auth), pushes the resulting commit — re-running the
PR checks — and replies with a summary. Owner-only trigger; runs unsandboxed
inside the ephemeral runner container per explicit maintainer sign-off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 16:26:09 +01:00
tudor 23b4e1c453 Merge pull request 'fix(e2e): scroll detail-page chart into view before asserting' (#5) from fix/e2e-detail-chart-scroll into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 49s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 33s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 8s
2026-07-03 13:56:00 +00:00
TudorandClaude Fable 5 deeef23131 fix(e2e): assert on a visible canvas — the first canvas is hidden by design
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m41s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
The admissions card stacks year/trend views in one grid cell and keeps the
inactive view visibility:hidden; its canvas is first in the DOM. Use
canvas:visible instead of scrolling. Verified: full suite passes against prod.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 14:40:13 +01:00
TudorandClaude Fable 5 4ece55b031 fix(e2e): scroll the detail-page chart into view before asserting visibility
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m41s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 55s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 34s
The canvas renders below the fold and stays 'hidden' to Playwright until
scrolled to; wait for attachment, scroll, then assert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 14:34:17 +01:00
tudor 515494dbf0 Merge pull request 'fix(analytics): restrict Umami to production hostnames' (#4) from fix/umami-prod-domains-only into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 50s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 0s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Failing after 1m7s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
2026-07-03 13:17:53 +00:00
TudorandClaude Fable 5 5772c54ccd fix(ci): use the documented GITHUB_TOKEN name for the run-scoped token
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m37s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m27s
GITEA_TOKEN worked (the review comment posted with it) but GITHUB_TOKEN is
the documented name in Gitea Actions; use it to keep the reviewer and the
docs aligned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 13:53:57 +01:00
TudorandClaude Fable 5 c62ba0ca25 fix(ci): post AI review comments with the run-scoped Gitea token
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m37s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 1m56s
REGISTRY_TOKEN lacks issue-write scope (403 on comment post). Gitea Actions
auto-provides a repo-scoped per-run token as secrets.GITEA_TOKEN — no
user-managed secret needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 13:39:59 +01:00
TudorandClaude Fable 5 b5a63e82d4 fix(analytics): restrict Umami to production hostnames
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m37s
PR Checks / Backend Smoke (pull_request) Successful in 4s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 36s
The same frontend image runs on staging and prod (build once, promote), so a
build-time env var can't tell them apart. Umami's data-domains attribute
scopes the tracker client-side: events only fire when location.hostname is a
production domain, so staging traffic and the E2E journeys never pollute the
dashboards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 13:24:14 +01:00
tudor f5de745a8b Merge pull request 'fix(ci): AI review via Claude Code CLI; reuse REGISTRY_TOKEN' (#3) from fix/ai-review-claude-code into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 18s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 46s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Failing after 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Has been skipped
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
2026-07-03 08:50:26 +00:00
TudorandClaude Fable 5 d0895c71df 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
2026-07-03 08:28:20 +01:00
7 changed files with 252 additions and 79 deletions
+12 -5
View File
@@ -167,13 +167,20 @@ 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 }} # Auto-provided per-run token from Gitea Actions (repo-scoped).
# GITHUB_TOKEN is the documented name; GITEA_TOKEN is its alias.
GITEA_TOKEN: ${{ secrets.GITHUB_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 }}
+47
View File
@@ -0,0 +1,47 @@
name: PR Comment Agent
on:
issue_comment:
types: [created]
jobs:
ai-fixup:
name: Claude Fix-up (@claude comment)
runs-on: ubuntu-latest
# Only PR comments, only from the repo owner, only when addressed to @claude.
# The owner guard matters: the job pushes code to the PR branch.
if: >-
gitea.event.issue.pull_request &&
startsWith(gitea.event.comment.body, '@claude') &&
gitea.event.comment.user.login == gitea.repository_owner
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Claude Code
run: npm install -g @anthropic-ai/claude-code
- name: Apply the requested fix-up
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
PR_NUMBER: ${{ gitea.event.issue.number }}
COMMENT_BODY: ${{ gitea.event.comment.body }}
# Claude Code runs as root inside the runner container; this flag
# acknowledges the container *is* the sandbox.
IS_SANDBOX: "1"
run: python scripts/ci/ai_fixup.py
+26 -6
View File
@@ -57,8 +57,7 @@ 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 privaterepo.sitaru.org (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,28 @@ 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 using the per-run
Minor findings are informational and never block a merge. token Gitea Actions provides automatically (`secrets.GITEA_TOKEN` — no setup
needed), 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.
## Comment-triggered fix-ups (@claude)
Comment `@claude <instruction>` on any PR and `.gitea/workflows/pr-comment.yml`
runs `scripts/ci/ai_fixup.py`: it checks out the PR branch, hands the
instruction to headless Claude Code (same subscription auth as the reviewer),
commits and pushes whatever changed, and replies on the PR with a summary.
The push re-runs the PR checks automatically.
Guard rails:
- Only comments from the **repo owner** trigger it (the job pushes code).
- Only comments starting with `@claude` — the bot's own replies never re-trigger.
- Each comment is one full agentic session on the Claude subscription; batch
related asks into one comment rather than several small ones.
Caveat: if the checks don't re-run after the bot's push, Gitea is suppressing
workflows for pushes made with the run token — create a personal access token
secret and swap it in for the push, or re-run the checks manually.
+5 -2
View File
@@ -43,8 +43,11 @@ test('school detail page renders name and performance data', async ({ page }) =>
await firstSchool.click(); await firstSchool.click();
await page.waitForURL(/\/school\//); await page.waitForURL(/\/school\//);
await expect(page.locator('h1').first()).toBeVisible(); await expect(page.locator('h1').first()).toBeVisible();
// The detail page renders at least one chart canvas (performance history) // The detail page renders at least one *visible* chart canvas. Plain
await expect(page.locator('canvas').first()).toBeVisible({ timeout: 15_000 }); // .first() is wrong here: the admissions card stacks its year/trend views
// in one grid cell and keeps the inactive view's canvas visibility:hidden
// by design, and that canvas comes first in the DOM.
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
}); });
test('comparing two schools shows both side by side', async ({ page }) => { test('comparing two schools shows both side by side', async ({ page }) => {
+3
View File
@@ -76,9 +76,12 @@ export default function RootLayout({
<head> <head>
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" /> <link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
<link rel="preconnect" href="https://api.postcodes.io" /> <link rel="preconnect" href="https://api.postcodes.io" />
{/* data-domains: the tracker only fires on the production hostnames,
so staging (same image, different host) never pollutes Umami */}
<Script <Script
src="https://analytics.schoolcompare.co.uk/script.js" src="https://analytics.schoolcompare.co.uk/script.js"
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd" data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
data-domains="schoolcompare.co.uk,www.schoolcompare.co.uk"
data-performance="true" data-performance="true"
strategy="afterInteractive" strategy="afterInteractive"
/> />
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Comment-triggered PR fix-ups, powered by Claude Code.
Runs when a maintainer comments `@claude <instruction>` on a pull request.
Checks out the PR head branch, hands the instruction to headless Claude Code
(subscription OAuth auth — no API billing), commits and pushes whatever it
changed (which re-runs the PR checks), and replies on the PR with a summary.
Uses only the Python standard library plus the `claude` CLI.
Required environment:
CLAUDE_CODE_OAUTH_TOKEN token from `claude setup-token`
GITEA_TOKEN run-scoped token (checkout auth handles the push)
GITEA_SERVER_URL e.g. https://privaterepo.sitaru.org
GITEA_REPOSITORY owner/repo
PR_NUMBER pull request index
COMMENT_BODY the triggering comment text
"""
import json
import os
import subprocess
import sys
import urllib.request
TRIGGER = "@claude"
CLAUDE_TIMEOUT_S = 2400 # 40 min ceiling for one fix-up session
def api(path: str, payload: dict | None = None) -> dict:
server = os.environ["GITEA_SERVER_URL"].rstrip("/")
repo = os.environ["GITEA_REPOSITORY"]
req = urllib.request.Request(
f"{server}/api/v1/repos/{repo}{path}",
data=json.dumps(payload).encode() if payload is not None else None,
headers={
"Authorization": f"token {os.environ['GITEA_TOKEN']}",
"Content-Type": "application/json",
},
method="POST" if payload is not None else "GET",
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
def run(*cmd: str, **kwargs) -> subprocess.CompletedProcess:
return subprocess.run(cmd, check=True, capture_output=True, text=True, **kwargs)
def main() -> int:
pr_number = os.environ["PR_NUMBER"]
instruction = os.environ["COMMENT_BODY"].strip()
if instruction.lower().startswith(TRIGGER):
instruction = instruction[len(TRIGGER):].strip()
if not instruction:
print("Empty instruction after trigger word; nothing to do")
return 0
pr = api(f"/pulls/{pr_number}")
head_ref = pr["head"]["ref"]
base_ref = pr["base"]["ref"]
run("git", "fetch", "origin", head_ref, base_ref)
run("git", "checkout", head_ref)
prompt = f"""You are working on pull request #{pr_number} ("{pr['title']}")
in the SchoolCompare repository. The PR branch is checked out; its base is
{base_ref}. A maintainer left this instruction on the PR:
{instruction}
Implement exactly what was asked, following the conventions in CLAUDE.md.
Run any relevant tests or typechecks you can. Do NOT commit or push — the
harness handles that. When done, summarise in a few sentences what you
changed and how you verified it."""
proc = subprocess.run(
["claude", "-p", prompt, "--dangerously-skip-permissions", "--output-format", "json"],
capture_output=True,
text=True,
timeout=CLAUDE_TIMEOUT_S,
)
if proc.returncode != 0:
raise RuntimeError(f"claude CLI failed:\n{proc.stderr[-2000:]}")
summary = json.loads(proc.stdout)["result"].strip()
changed = run("git", "status", "--porcelain").stdout.strip()
if changed:
run("git", "config", "user.name", "Claude (CI)")
run("git", "config", "user.email", "noreply@anthropic.com")
run("git", "add", "-A")
title = instruction.splitlines()[0][:60]
run("git", "commit", "-m", f"ai: {title}\n\nRequested via PR comment; applied by Claude Code in CI.")
run("git", "push", "origin", head_ref)
sha = run("git", "rev-parse", "--short", "HEAD").stdout.strip()
reply = f"## 🤖 Claude fix-up applied (`{sha}`)\n\n{summary}\n\n_PR checks re-run automatically on the new commit._"
else:
reply = f"## 🤖 Claude fix-up — no changes made\n\n{summary}"
api(f"/issues/{pr_number}/comments", {"body": reply})
print(reply)
return 0
if __name__ == "__main__":
sys.exit(main())
+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: