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
9 changed files with 177 additions and 135 deletions
-28
View File
@@ -1,28 +0,0 @@
# TEMPORARY diagnostic workflow — delete after the rankings year= bug is closed.
name: Staging Rankings Diagnostic
on:
push:
branches:
- diag/staging-rankings-year
jobs:
staging-api-diagnostic:
name: Staging rankings year diagnostic
runs-on: ubuntu-latest
steps:
- name: Probe staging rankings year handling
env:
BASE: ${{ secrets.STAGING_BASE_URL }}
run: |
probe() {
echo "== $1 =="
curl -s --max-time 15 -D /tmp/h.txt -o /tmp/b.txt "$BASE$1"
echo "--- headers:"; cat /tmp/h.txt
echo "--- body (first 400 bytes):"; head -c 400 /tmp/b.txt; echo
}
probe "/api/data-info"
probe "/api/filters"
probe "/api/rankings?metric=rwm_expected_pct&limit=3"
probe "/api/rankings?metric=rwm_expected_pct&limit=3&year=202425"
probe "/"
+1 -19
View File
@@ -12,25 +12,7 @@ env:
PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline
jobs: jobs:
# TEMPORARY: evidence gathering for the rankings year= empty-list bug on frontend-checks:
# staging. Remove before merging. Prints status codes and row counts only.
staging-api-diagnostic:
name: Staging rankings year diagnostic
runs-on: ubuntu-latest
steps:
- name: Probe staging rankings year handling
env:
BASE: ${{ secrets.STAGING_BASE_URL }}
run: |
echo "== /api/filters years =="
curl -s --max-time 15 "$BASE/api/filters" -o /tmp/f.json -w "status %{http_code}\n"
python3 -c "import json; print(json.load(open('/tmp/f.json')).get('years'))" || head -c 300 /tmp/f.json
echo "== /api/rankings probes (metric=rwm_expected_pct, limit=3) =="
for Q in "" "&year=202425" "&year=201819" "&year=2024"; do
CODE=$(curl -s --max-time 15 -o /tmp/r.json -w "%{http_code}" "$BASE/api/rankings?metric=rwm_expected_pct&limit=3$Q")
echo "query [$Q] -> status $CODE"
python3 -c "import json; d=json.load(open('/tmp/r.json')); print(' year:', d.get('year'), 'total:', d.get('total'), 'rows:', len(d.get('rankings', [])))" || head -c 300 /tmp/r.json
done
name: Frontend Typecheck + Tests name: Frontend Typecheck + Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+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
+1 -4
View File
@@ -834,10 +834,7 @@ async def get_rankings(
request: Request, request: Request,
metric: str = Query("rwm_expected_pct", description="Metric to rank by", max_length=50), metric: str = Query("rwm_expected_pct", description="Metric to rank by", max_length=50),
year: Optional[int] = Query( year: Optional[int] = Query(
None, None, description="Specific year (defaults to most recent)", ge=2000, le=2100
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"), limit: int = Query(20, ge=1, le=100, description="Number of schools to return"),
local_authority: Optional[str] = Query( local_authority: Optional[str] = Query(
+18
View File
@@ -130,3 +130,21 @@ token Gitea Actions provides automatically (`secrets.GITEA_TOKEN` — no setup
needed), and fails the check only when a finding is rated needed), and fails the check only when a finding is rated
**severe** (would break prod, leak data, or corrupt data). Minor findings are **severe** (would break prod, leak data, or corrupt data). Minor findings are
informational and never block a merge. 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.
-46
View File
@@ -50,31 +50,6 @@ test('school detail page renders name and performance data', async ({ page }) =>
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 }); 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 }) => { test('comparing two schools shows both side by side', async ({ page }) => {
// Collect two school URNs from search results, then load the share URL // Collect two school URNs from search results, then load the share URL
await searchByName(page, 'primary'); await searchByName(page, 'primary');
@@ -98,24 +73,3 @@ test('rankings page loads a populated table', async ({ page }) => {
await expect(rows.first()).toBeVisible({ timeout: 15_000 }); await expect(rows.first()).toBeVisible({ timeout: 15_000 });
expect(await rows.count()).toBeGreaterThan(5); 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);
});
@@ -34,15 +34,6 @@
background: #fff; 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 { .skeleton {
width: 100%; width: 100%;
height: 100%; height: 100%;
+4 -29
View File
@@ -29,50 +29,25 @@ interface SchoolHeroMapProps {
export const SchoolHeroMap = forwardRef<SchoolHeroMapHandle, SchoolHeroMapProps>( export const SchoolHeroMap = forwardRef<SchoolHeroMapHandle, SchoolHeroMapProps>(
function SchoolHeroMap({ lat, lng }, ref) { function SchoolHeroMap({ lat, lng }, ref) {
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const [nativeFullscreen, setNativeFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = 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(() => { const open = useCallback(() => {
const el = wrapperRef.current; wrapperRef.current?.requestFullscreen?.().catch(() => {});
if (!el) return;
if (el.requestFullscreen) {
el.requestFullscreen().catch(() => setFallbackFullscreen(true));
} else {
setFallbackFullscreen(true);
}
}, []); }, []);
const close = useCallback(() => { const close = useCallback(() => {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
setFallbackFullscreen(false);
}, []); }, []);
useImperativeHandle(ref, () => ({ open }), [open]); useImperativeHandle(ref, () => ({ open }), [open]);
useEffect(() => { useEffect(() => {
const onChange = () => setNativeFullscreen(!!document.fullscreenElement); const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange); document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('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 ( return (
<div <div ref={wrapperRef} className={styles.wrapper} data-fullscreen={isFullscreen || undefined}>
ref={wrapperRef}
className={styles.wrapper}
data-fullscreen={isFullscreen || undefined}
data-fs-fallback={fallbackFullscreen || undefined}
>
<LeafletHeroMap lat={lat} lng={lng} interactive={isFullscreen} /> <LeafletHeroMap lat={lat} lng={lng} interactive={isFullscreen} />
{isFullscreen ? ( {isFullscreen ? (
+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())