Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0100cce69 | ||
|
|
23b4e1c453 | ||
|
|
deeef23131 | ||
|
|
4ece55b031 | ||
|
|
515494dbf0 | ||
|
|
5772c54ccd | ||
|
|
c62ba0ca25 | ||
|
|
b5a63e82d4 |
@@ -178,7 +178,9 @@ jobs:
|
|||||||
- name: Review PR diff with Claude Code
|
- name: Review PR diff with Claude Code
|
||||||
env:
|
env:
|
||||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
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_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 }}
|
||||||
|
|||||||
@@ -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
@@ -56,7 +56,7 @@ fail the E2E gate. That's the point: staging absorbs the risk.
|
|||||||
|
|
||||||
| Secret | Purpose |
|
| 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 |
|
| `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_STAGING_WEBHOOK` | staging stack redeploy webhook URL |
|
||||||
| `PORTAINER_PROD_WEBHOOK` | production 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
|
`scripts/ci/ai_review.py` pipes the PR diff through headless Claude Code
|
||||||
(`claude -p`, authenticated with the subscription OAuth token — no API
|
(`claude -p`, authenticated with the subscription OAuth token — no API
|
||||||
billing), posts the structured findings as a PR comment via the Gitea API
|
billing), posts the structured findings as a PR comment using the per-run
|
||||||
(reusing `REGISTRY_TOKEN`), and fails the check only when a finding is rated
|
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
|
**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.
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user