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
107 lines
3.9 KiB
Python
107 lines
3.9 KiB
Python
#!/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())
|