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
6 changed files with 186 additions and 6 deletions
+3 -1
View File
@@ -178,7 +178,9 @@ jobs:
- name: Review PR diff with Claude Code
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
GITEA_TOKEN: ${{ secrets.REGISTRY_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 }}
+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
+22 -3
View File
@@ -56,7 +56,7 @@ fail the E2E gate. That's the point: staging absorbs the risk.
| Secret | Purpose |
|---|---|
| `REGISTRY_TOKEN` | push images to the registry + post PR review comments (already set) |
| `REGISTRY_TOKEN` | push images to privaterepo.sitaru.org (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 |
@@ -125,7 +125,26 @@ numbers, so scheduled data refreshes don't break the gate.
`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
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.
## 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 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('comparing two schools shows both side by side', async ({ page }) => {
+3
View File
@@ -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"
/>
+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())