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