Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d52d384cf2 | ||
|
|
ff606dad71 | ||
|
|
acec8135e1 | ||
|
|
0a370e3b63 | ||
|
|
6c872ce726 | ||
|
|
23b4e1c453 | ||
|
|
deeef23131 | ||
|
|
4ece55b031 | ||
|
|
515494dbf0 | ||
|
|
5772c54ccd | ||
|
|
c62ba0ca25 | ||
|
|
b5a63e82d4 | ||
|
|
f5de745a8b | ||
|
|
d0895c71df |
@@ -167,13 +167,20 @@ 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 }}
|
||||
# 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_REPOSITORY: ${{ gitea.repository }}
|
||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||
|
||||
+4
-1
@@ -834,7 +834,10 @@ async def get_rankings(
|
||||
request: Request,
|
||||
metric: str = Query("rwm_expected_pct", description="Metric to rank by", max_length=50),
|
||||
year: Optional[int] = Query(
|
||||
None, description="Specific year (defaults to most recent)", ge=2000, le=2100
|
||||
None,
|
||||
description="Academic year code, e.g. 201819 (defaults to most recent)",
|
||||
ge=2000,
|
||||
le=210100,
|
||||
),
|
||||
limit: int = Query(20, ge=1, le=100, description="Number of schools to return"),
|
||||
local_authority: Optional[str] = Query(
|
||||
|
||||
+8
-6
@@ -57,8 +57,7 @@ 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) |
|
||||
| `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,10 @@ 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 using the per-run
|
||||
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.
|
||||
|
||||
@@ -43,8 +43,36 @@ test('school detail page renders name and performance data', async ({ page }) =>
|
||||
await firstSchool.click();
|
||||
await page.waitForURL(/\/school\//);
|
||||
await expect(page.locator('h1').first()).toBeVisible();
|
||||
// The detail page renders at least one chart canvas (performance history)
|
||||
await expect(page.locator('canvas').first()).toBeVisible({ timeout: 15_000 });
|
||||
// The detail page renders at least one *visible* chart canvas. Plain
|
||||
// .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('school hero map opens fullscreen on mobile without the Fullscreen API', async ({ page }) => {
|
||||
// iOS Safari has no Element.requestFullscreen; the map must fall back to a
|
||||
// CSS overlay. Simulate that by removing the API before any page script runs.
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.addInitScript(() => {
|
||||
// @ts-expect-error deliberate API removal
|
||||
delete Element.prototype.requestFullscreen;
|
||||
});
|
||||
|
||||
await searchByName(page, 'primary');
|
||||
const firstSchool = schoolLinks(page).first();
|
||||
await expect(firstSchool).toBeVisible({ timeout: 15_000 });
|
||||
await firstSchool.click();
|
||||
await page.waitForURL(/\/school\//);
|
||||
|
||||
const openMap = page.getByRole('button', { name: 'Open full map' });
|
||||
await expect(openMap).toBeVisible({ timeout: 15_000 });
|
||||
await openMap.click();
|
||||
|
||||
const closeMap = page.getByRole('button', { name: 'Close map' });
|
||||
await expect(closeMap).toBeVisible();
|
||||
await closeMap.click();
|
||||
await expect(openMap).toBeVisible();
|
||||
});
|
||||
|
||||
test('comparing two schools shows both side by side', async ({ page }) => {
|
||||
@@ -70,3 +98,24 @@ test('rankings page loads a populated table', async ({ page }) => {
|
||||
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
|
||||
expect(await rows.count()).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
test('rankings stay populated after picking a specific year', async ({ page }) => {
|
||||
// Years are academic-year codes (e.g. 201819); the API must accept them
|
||||
// as the `year` query param rather than rejecting with a 422.
|
||||
await page.goto('/rankings');
|
||||
const yearSelect = page.locator('#year-select');
|
||||
await expect(yearSelect).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Pick the last option — the most recent explicit year. The default view
|
||||
// already proved this year has rows, so an empty table after selecting it
|
||||
// can only mean the year param was rejected. (The oldest year is no good
|
||||
// here: staging doesn't always carry the full data history.)
|
||||
const yearValue = await yearSelect.locator('option').last().getAttribute('value');
|
||||
expect(yearValue).toBeTruthy();
|
||||
await yearSelect.selectOption(yearValue!);
|
||||
await page.waitForURL(/year=/);
|
||||
|
||||
const rows = page.locator('table tbody tr');
|
||||
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
|
||||
expect(await rows.count()).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
@@ -76,9 +76,12 @@ export default function RootLayout({
|
||||
<head>
|
||||
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
||||
<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
|
||||
src="https://analytics.schoolcompare.co.uk/script.js"
|
||||
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
|
||||
data-domains="schoolcompare.co.uk,www.schoolcompare.co.uk"
|
||||
data-performance="true"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
|
||||
@@ -34,6 +34,15 @@
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Fallback fullscreen (iOS Safari — no Element.requestFullscreen): the API
|
||||
can't promote the element, so pin it over the page ourselves. Above the
|
||||
comparison toast (3000) and everything else except modals (9999+). */
|
||||
.wrapper[data-fs-fallback] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 5000;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -29,25 +29,50 @@ interface SchoolHeroMapProps {
|
||||
export const SchoolHeroMap = forwardRef<SchoolHeroMapHandle, SchoolHeroMapProps>(
|
||||
function SchoolHeroMap({ lat, lng }, ref) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [nativeFullscreen, setNativeFullscreen] = useState(false);
|
||||
// iOS Safari has no Element.requestFullscreen — fall back to a
|
||||
// fixed-position overlay driven by state instead of the Fullscreen API.
|
||||
const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
|
||||
const isFullscreen = nativeFullscreen || fallbackFullscreen;
|
||||
|
||||
const open = useCallback(() => {
|
||||
wrapperRef.current?.requestFullscreen?.().catch(() => {});
|
||||
const el = wrapperRef.current;
|
||||
if (!el) return;
|
||||
if (el.requestFullscreen) {
|
||||
el.requestFullscreen().catch(() => setFallbackFullscreen(true));
|
||||
} else {
|
||||
setFallbackFullscreen(true);
|
||||
}
|
||||
}, []);
|
||||
const close = useCallback(() => {
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
|
||||
setFallbackFullscreen(false);
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({ open }), [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
const onChange = () => setNativeFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||
}, []);
|
||||
|
||||
// The fallback overlay sits on top of the page rather than replacing it,
|
||||
// so lock body scroll while it is up.
|
||||
useEffect(() => {
|
||||
if (!fallbackFullscreen) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, [fallbackFullscreen]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={styles.wrapper} data-fullscreen={isFullscreen || undefined}>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={styles.wrapper}
|
||||
data-fullscreen={isFullscreen || undefined}
|
||||
data-fs-fallback={fallbackFullscreen || undefined}
|
||||
>
|
||||
<LeafletHeroMap lat={lat} lng={lng} interactive={isFullscreen} />
|
||||
|
||||
{isFullscreen ? (
|
||||
|
||||
+53
-66
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user