diff --git a/.gitea/workflows/pr-comment.yml b/.gitea/workflows/pr-comment.yml new file mode 100644 index 0000000..cd227e4 --- /dev/null +++ b/.gitea/workflows/pr-comment.yml @@ -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 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 6080777..68cc596 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -130,3 +130,21 @@ 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 ` 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. diff --git a/scripts/ci/ai_fixup.py b/scripts/ci/ai_fixup.py new file mode 100644 index 0000000..0f8e3c8 --- /dev/null +++ b/scripts/ci/ai_fixup.py @@ -0,0 +1,106 @@ +#!/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())