Files
galaxy-game/.gitea/workflows/issue-agent.yaml
T
Ilia Denisov 3f25bb035f 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>
2026-05-24 01:01:57 +02:00

151 lines
6.2 KiB
YAML

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