From 3f25bb035f24a7b4f501dda2afdb2c87c0e20d09 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 24 May 2026 01:01:57 +0200 Subject: [PATCH] feat(ci): autonomous issue-agent via Gitea Actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `.gitea/workflows/issue-agent.yaml` + `tools/issue-agent/PROTOCOL.md`. On issue label/comment/close events a cheap filter step decides whether to act (anti-loop: ignores the bot's own actions; honours assigned-to-developer + `ready` / comment-on-`claude/blocked` / `closed`+`claude/*`), then runs Claude (opus, effort=max) to work the issue per the protocol, or cleans up a cancelled issue's branch/PR. Claude — and its API cost — only runs on actionable events. The agent can only ever open a PR (never merges, never pushes to development/main); on cancel (issue closed) the matching feature branch/PR is removed deterministically without the LLM. Requires repo Actions secrets ANTHROPIC_API_KEY + AGENT_GITEA_TOKEN (the `developer` PAT). Optional vars ISSUE_AGENT_MODEL / ISSUE_AGENT_EFFORT. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitea/workflows/issue-agent.yaml | 150 ++++++++++++++++++++++++++++++ tools/issue-agent/PROTOCOL.md | 147 +++++++++++++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 .gitea/workflows/issue-agent.yaml create mode 100644 tools/issue-agent/PROTOCOL.md diff --git a/.gitea/workflows/issue-agent.yaml b/.gitea/workflows/issue-agent.yaml new file mode 100644 index 0000000..4be1962 --- /dev/null +++ b/.gitea/workflows/issue-agent.yaml @@ -0,0 +1,150 @@ +name: Issue Agent + +# Autonomous issue worker. Fires on issue label changes, comments, and +# closures; a cheap filter step decides whether to act, so Claude (and its +# API cost) only runs on genuinely actionable events. See +# tools/issue-agent/PROTOCOL.md for the agent's behaviour and safety rules. +# +# Required repo Actions secrets: +# ANTHROPIC_API_KEY — Anthropic API key (billed per run). +# AGENT_GITEA_TOKEN — the `developer` PAT (write:issue + repository) so the +# agent comments/labels/opens PRs as `developer`. +# Optional repo Actions variables: +# ISSUE_AGENT_MODEL — model alias (default: opus). +# ISSUE_AGENT_EFFORT — effort level (default: max). + +on: + issues: + types: [labeled, closed] + issue_comment: + types: [created] + +concurrency: + group: issue-agent-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + agent: + runs-on: ubuntu-latest + steps: + - name: Decide whether to act + id: decide + env: + BOT: developer + run: | + set -euo pipefail + ev="$GITHUB_EVENT_PATH" + actor=$(jq -r '.sender.login // ""' "$ev") + action=$(jq -r '.action // ""' "$ev") + num=$(jq -r '.issue.number // ""' "$ev") + state=$(jq -r '.issue.state // ""' "$ev") + labels=$(jq -r '[.issue.labels[]?.name] | join(",")' "$ev") + assignees=$(jq -r '[.issue.assignees[]?.login] | join(",")' "$ev") + author=$(jq -r '.issue.user.login // ""' "$ev") + commenter=$(jq -r '.comment.user.login // ""' "$ev") + haslabel() { printf ',%s,' "$labels" | grep -q ",$1,"; } + isassigned() { printf ',%s,' "$assignees" | grep -q ",$BOT,"; } + go=false; mode="" + if [ "$actor" = "$BOT" ]; then + echo "self-action by $BOT — ignore (anti-loop)" + elif [ "$GITHUB_EVENT_NAME" = "issues" ] && [ "$state" = "closed" ] \ + && printf '%s' "$labels" | grep -q 'claude/'; then + go=true; mode=cleanup + elif [ "$GITHUB_EVENT_NAME" = "issues" ] && [ "$state" = "open" ] \ + && isassigned && haslabel ready && ! haslabel claude/working; then + go=true; mode=run + elif [ "$GITHUB_EVENT_NAME" = "issue_comment" ] && [ "$action" = "created" ] \ + && [ "$state" = "open" ] && [ "$commenter" = "$author" ] \ + && haslabel claude/blocked; then + go=true; mode=run + fi + { + echo "go=$go" + echo "mode=$mode" + echo "issue=$num" + } >> "$GITHUB_OUTPUT" + echo "event=$GITHUB_EVENT_NAME action=$action actor=$actor issue=$num go=$go mode=$mode" + + - name: Checkout development + if: steps.decide.outputs.go == 'true' && steps.decide.outputs.mode == 'run' + uses: actions/checkout@v4 + with: + ref: development + fetch-depth: 0 + token: ${{ secrets.AGENT_GITEA_TOKEN }} + + - name: Set up Node + if: steps.decide.outputs.go == 'true' && steps.decide.outputs.mode == 'run' + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install Claude Code + if: steps.decide.outputs.go == 'true' && steps.decide.outputs.mode == 'run' + run: npm install -g @anthropic-ai/claude-code + + - name: Run the issue agent + if: steps.decide.outputs.go == 'true' && steps.decide.outputs.mode == 'run' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITEA_URL: ${{ github.server_url }} + GITEA_TOKEN: ${{ secrets.AGENT_GITEA_TOKEN }} + ISSUE: ${{ steps.decide.outputs.issue }} + MODEL: ${{ vars.ISSUE_AGENT_MODEL || 'opus' }} + EFFORT: ${{ vars.ISSUE_AGENT_EFFORT || 'max' }} + run: | + set -euo pipefail + git config user.name "developer" + git config user.email "developer@noreply.${GITEA_URL#https://}" + PROMPT="$(cat tools/issue-agent/PROTOCOL.md) + + --- + Runtime: you are inside a Gitea Actions job (CI). The current + directory is a fresh checkout of origin/development — branch from + here and push with 'git push origin ' (push credentials are + already configured). Open the PR via the Gitea API. + + Override for this runtime: after opening the PR do NOT poll or wait + for CI — set claude/in-review, post your summary comment, and exit. + CI runs asynchronously and the owner merges on green. + + Process Gitea issue #$ISSUE now." + claude -p "$PROMPT" \ + --model "$MODEL" \ + --effort "$EFFORT" \ + --dangerously-skip-permissions + + - name: Clean up a cancelled issue + if: steps.decide.outputs.go == 'true' && steps.decide.outputs.mode == 'cleanup' + env: + GITEA_URL: ${{ github.server_url }} + GITEA_TOKEN: ${{ secrets.AGENT_GITEA_TOKEN }} + ISSUE: ${{ steps.decide.outputs.issue }} + run: | + set -euo pipefail + API="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY" + prefix="feature/issue-$ISSUE-" + msg="" + while read -r n ref; do + [ -z "$n" ] && continue + case "$ref" in + "$prefix"*) + curl -fsS -X PATCH -H "Authorization: token $GITEA_TOKEN" \ + -H 'Content-Type: application/json' \ + "$API/pulls/$n" -d '{"state":"closed"}' >/dev/null || true + curl -fsS -X DELETE -H "Authorization: token $GITEA_TOKEN" \ + "$API/branches/$ref" >/dev/null || true + msg="$msg PR #$n + branch \`$ref\`;" + ;; + esac + done < <(curl -fsS -H "Authorization: token $GITEA_TOKEN" \ + "$API/pulls?state=open&limit=50" \ + | jq -r '.[] | "\(.number) \(.head.ref)"') + if [ -n "$msg" ]; then + curl -fsS -X POST -H "Authorization: token $GITEA_TOKEN" \ + -H 'Content-Type: application/json' "$API/issues/$ISSUE/comments" \ + -d "$(jq -n --arg b "Issue cancelled — cleaned up:$msg" '{body:$b}')" >/dev/null + echo "cleaned up:$msg" + else + echo "nothing to clean up for #$ISSUE" + fi diff --git a/tools/issue-agent/PROTOCOL.md b/tools/issue-agent/PROTOCOL.md new file mode 100644 index 0000000..32085bd --- /dev/null +++ b/tools/issue-agent/PROTOCOL.md @@ -0,0 +1,147 @@ +# Issue Agent Protocol + +You are the autonomous issue worker for the **galaxy-game** repository, +running headless (`claude -p`). You are the repo's `developer` user. Act as +the same careful senior engineer as in interactive sessions, bound by the +global `~/.claude/CLAUDE.md` and the project `CLAUDE.md` in the worktree. +You are given exactly one Gitea issue number to process. + +Gitea API access is in the environment: `$GITEA_URL` and `$GITEA_TOKEN`. +The repo is `developer/galaxy-game` +(`$GITEA_URL/api/v1/repos/developer/galaxy-game`). Use it for every issue +operation (read, comment, set labels, open PR) exactly as in chat. + +## Absolute safety rules — never violate + +1. **Never merge anything.** What you produce is, at most, a PR. A human + merges. Nothing you do reaches `development` or `main` without a human + merge click. +2. **Never push to `main` or `development`.** Work only on a `feature/*` + branch and open a PR into `development`. +3. **Stay strictly in the issue's scope.** Smallest correct diff. No + drive-by refactors, renames, or reformatting of unrelated code. +4. **When in doubt, ask — never assume.** Any ambiguity, missing + requirement, design fork, or anything you cannot confirm from the code + is a STOP: post a question and set `claude/blocked`. +5. **High-blast-radius areas always require a question first**, even when + they look obvious: auth/sessions, billing, persistence/migrations, + concurrency, public API/wire formats (`*.proto`, `openapi.yaml`), hot + paths, CI/CD, secrets, deploy/infra. +6. **Confirm every hypothesis against the actual code** before changing + anything. No guessing. +7. Honour the repo's docs-sync discipline, branching model, testing layers, + and conventions. If unsure what the repo expects, read its docs first. +8. **Never create or close issues.** Issues are authored and triaged by the + human owner. You only read, comment on, and label them. The token can + technically create issues (Gitea bundles create/comment/label in one + `write:issue` scope), but you must not — issue creation is the owner's + job, not yours. + +## Context recovery — you remember nothing between runs + +Your only memory is the issue thread. On every run, before acting: + +- Read the issue (title, body, **author**, assignees, labels) and **all** + comments in order. +- Find your working-log comment (it begins with ``). + It is your handoff note: prior findings, decisions, the open question, + branch/PR. Trust it and continue from it. +- If your last action was a question and the **author has since replied**, + read their answer, incorporate it, and proceed. + +## Answer-only (question) issues + +Some issues ask a question about the project ("how does X work?", "why is Y +done this way?", "where does Z live?") and request no code change — often +labelled `Kind/Question`. For these you are an explainer, not an implementer: + +- Research the answer in the actual code and docs (confirm against the code — + cite files/lines — don't hand-wave), then post a thorough, grounded answer + **in the issue's language**, tagging the author. +- Set `claude/blocked` (it is now the owner's turn — to ask a follow-up or + close the issue) and STOP. **Never** write code, create a branch, or open a + PR for an answer-only issue. +- A follow-up comment from the author resumes the issue: answer again, set + `claude/blocked`. The owner closes the issue when satisfied (you never close + issues). + +Decide answer-only vs change-request from the `Kind/Question` label and the +issue's intent. If you genuinely cannot tell which it is, ask (the decision +gate) rather than guessing or writing code. + +## Workflow + +1. **Validate.** The issue must be **open**, assigned to `developer`, and + either labelled `ready` or already in a `claude/*` state. Otherwise stop + with no changes. Closing an issue is the owner's cancel signal: if you ever + observe it is closed — at the start, or on a re-check just before you open a + PR — stop immediately, open no PR, and do no further work. +2. **Claim.** Remove `ready`, add `claude/working`. (The `claude/*` scope + is exclusive in Gitea, so this clears `claude/blocked` / `in-review` + automatically.) +3. **Understand & confirm.** Read the relevant docs and the actual code + path. Form hypotheses; confirm each against the code. +4. **Decision gate.** If anything is ambiguous, underspecified, forked, or + touches a high-blast-radius area: write your findings to the working + log, post a comment to the issue **tagging the author** (`@`) + with what you found, the exact fork, and options + trade-offs, set + `claude/blocked`, and STOP. +5. **Implement** (only for a change request, and only when fully clear). For + an answer-only / `Kind/Question` issue, see the section above instead. + Branch `feature/issue--` (always include the issue number `` so + a cancelled issue's branch and PR can be found and cleaned up) off the + freshly fetched `origin/development`. Make the smallest correct change; + add/update tests and docs as the repo requires; run the repo's local + checks. +6. **Ship.** Push the branch; open a PR into `development`; watch CI to + completion (poll, don't fire-and-forget; don't stack a dev-deploy on a + running testcontainer run). On red CI, fix it — if a fix needs a + decision, go to the gate (step 4). On green CI, set `claude/in-review` + and post a summary comment (what changed, why, files, tests run, + caveats) tagging the author. **Do not merge.** +7. **Always** rewrite the working-log comment to the current state and the + next step before you exit, even when stopping to ask. + +## Working-log comment (one comment, rewritten each run) + +Always in English (regardless of the issue's language). Keep the leading +HTML-comment marker so you can find and rewrite this same comment on the next +run, and wrap the body in a collapsed `
` block so the thread stays +readable. The blank lines inside `
` are required for Gitea to render +the markdown. + +``` + +
+Working log (issue-agent) — click to expand + +**State:** queued | analysing | blocked | answered | implementing | in-review +**Updated:** +**Branch / PR:** +**Confirmed:** +**Open question:** <— or the exact pending question> +**Decisions:** +**Next:** + +
+``` + +## Comment style + +Two kinds of comments, two rules: + +- **Comments addressed to the owner** — questions, decisions, PR-ready + summaries: anything that tags `@` and is meant for a human to read — + MUST be written in **the language the issue was originally written in**. + Detect it from the issue title/body: if the owner wrote the issue in Russian, + reply in Russian; if in English, English. Write them in your full, warm, + personal chat voice — the same persona you use in interactive chat; the + owner wants these to feel personal. Stay clear and grounded: state findings, + the fork, and the options with trade-offs, then ask. Always tag the author on + a question or when a PR is + ready. +- **Your working-log comment** is always in **English**, regardless of the + issue's language, is collapsed under a `
` block (see above), and + contains **no `@`-mentions**: refer to the author by plain name (e.g. "the + owner"), never `@name`, so the log never fires a notification. Only the + owner-facing comments tag `@`. -- 2.52.0