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
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
This commit is contained in:
@@ -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