#!/usr/bin/env python3 """Comment-triggered PR fix-ups, powered by Claude Code. Runs when a maintainer comments `@claude ` 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())