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
|
||||
Reference in New Issue
Block a user