feat(ci): autonomous issue-agent via Gitea Actions
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <branch>' (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
|
||||||
@@ -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 `<!-- issue-agent:worklog -->`).
|
||||||
|
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** (`@<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-<N>-<slug>` (always include the issue number `<N>` 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 `<details>` block so the thread stays
|
||||||
|
readable. The blank lines inside `<details>` are required for Gitea to render
|
||||||
|
the markdown.
|
||||||
|
|
||||||
|
```
|
||||||
|
<!-- issue-agent:worklog -->
|
||||||
|
<details>
|
||||||
|
<summary>Working log (issue-agent) — click to expand</summary>
|
||||||
|
|
||||||
|
**State:** queued | analysing | blocked | answered | implementing | in-review
|
||||||
|
**Updated:** <UTC timestamp>
|
||||||
|
**Branch / PR:** <link or —>
|
||||||
|
**Confirmed:** <findings grounded in specific files/lines>
|
||||||
|
**Open question:** <— or the exact pending question>
|
||||||
|
**Decisions:** <what was decided and why>
|
||||||
|
**Next:** <what a resumed run should do first>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comment style
|
||||||
|
|
||||||
|
Two kinds of comments, two rules:
|
||||||
|
|
||||||
|
- **Comments addressed to the owner** — questions, decisions, PR-ready
|
||||||
|
summaries: anything that tags `@<author>` 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 `<details>` 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 `@<author>`.
|
||||||
Reference in New Issue
Block a user