From 8d8f38d62f28b1117691aed0fd0b38c007d308f2 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 25 Apr 2026 10:42:18 -0400 Subject: [PATCH] feat(agents): triage fires on comments + claude-triaging lifecycle label Mirrors the changes from adcontextprotocol/adcp#3146 + #3155: - Workflow fires on issue_comment.created with self-loop / bot / /triage / PR-conversation filtering. Plain comments now reach the routine through the new comment.created event kind. - claude-triaging lifecycle label: routine applies it after the concurrency + already-engaged checks pass, swaps to claude-triaged at end of run. Gives humans a visible "I'm on this" signal. - Manual triage docs in .agents/routines/README.md. - clear-stuck-claude-triaging.yml: cron every 30 min clears orphaned labels on issues stuck >30 min. - triage-webhook-miss-sweep.yml: cron hourly catches issues opened in last 24h that the issues.opened webhook silently missed. - .agents/scripts/triage-local.sh: local fire script. claude-triaging label created in this repo ahead of merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- .agents/routines/README.md | 36 +++++ .agents/routines/triage-prompt.md | 84 ++++++++++- .agents/scripts/triage-local.sh | 138 +++++++++++++++++ .github/workflows/claude-issue-triage.yml | 58 +++++++- .../workflows/clear-stuck-claude-triaging.yml | 78 ++++++++++ .../workflows/triage-webhook-miss-sweep.yml | 140 ++++++++++++++++++ 6 files changed, 524 insertions(+), 10 deletions(-) create mode 100755 .agents/scripts/triage-local.sh create mode 100644 .github/workflows/clear-stuck-claude-triaging.yml create mode 100644 .github/workflows/triage-webhook-miss-sweep.yml diff --git a/.agents/routines/README.md b/.agents/routines/README.md index 4e67f230..e307aa0b 100644 --- a/.agents/routines/README.md +++ b/.agents/routines/README.md @@ -59,3 +59,39 @@ GitHub identity is what commits appear as. For AdCP we want For Claude-opened PRs, enable auto-fix via the CI status bar on the PR (or `/autofix-pr` locally while on the branch). Requires the Claude GitHub App. + + +## Triage Routine — Manual Nudge + +The triage routine fires on issue open/reopen, on `/triage` +slash-commands (via `slash-command-dispatch.yml`), or on plain +non-bot, non-self, non-`/triage`, non-PR-conversation comments +landing on open issues. + +| What you want | How | +|---|---| +| Re-trigger triage on a missed issue | Comment `/triage` | +| Bias toward Execute on a borderline issue | Comment `/triage execute` | +| Force a clarifying-question comment | Comment `/triage clarify` | +| Force defer | Comment `/triage defer` | +| Add new info to a stuck Clarify | Plain comment with the new info | + +**What does NOT trigger triage:** prose like "Pinging triage" or +"@claude please look at this" without the literal `/triage` token +(the slash-command-dispatch only matches the exact token); comments +on PR conversations (auto-fix's job, not triage); bot authors; +self-loops (filtered via the `Triaged by Claude Code` footer). + +**How to know if triage is on it:** + +- Label `claude-triaging` → routine is actively working (1-3 min). + Don't start a parallel PR. +- Label `claude-triaged` (without `claude-triaging`) → routine + finished. Triage comment / draft PR / silent-defer is the outcome. +- Neither label, no `## Triage` comment, issue >a few minutes old + → triage didn't fire. Webhook miss likely. Comment `/triage` to + recover, or run `.agents/scripts/triage-local.sh `. + +The `Clear stuck claude-triaging labels` workflow clears the label +automatically every 30 min; the `Triage webhook-miss sweep` catches +silent webhook misses hourly. diff --git a/.agents/routines/triage-prompt.md b/.agents/routines/triage-prompt.md index f2173e17..013f9a85 100644 --- a/.agents/routines/triage-prompt.md +++ b/.agents/routines/triage-prompt.md @@ -8,7 +8,9 @@ right experts, form an opinion, produce one of four outcomes. ## Prerequisites -- Label `claude-triaged` must exist. Stop and report if missing. +- Labels `claude-triaging` and `claude-triaged` must exist (apply per + the **Lifecycle labels** section below). Stop and report if either + is missing. ## Read first, every run @@ -26,10 +28,22 @@ quoting only. ## Run type -- **Event-driven:** user message contains issue context — act on - that single issue. -- **Scheduled:** walk open issues without `claude-triaged`, skip - bots / stale >90d, cap at 10. +The `Event:` line at the top of the user message tells you which +trigger fired: + +- **`auto.opened` / `auto.reopened`:** issue was just filed (or + re-filed). Act on that one issue with full triage. +- **`comment.created`:** a non-bot, non-`/triage`, non-self comment + landed on an open issue (workflow filters PR comments, /triage + slash-commands, and routine self-loops). Both + `<<>>` (the new comment) and + `<<>>` (original issue) are in the payload. + See **Comment engagement** below. +- **`manual.triage`:** a member commented `/triage [modifier]`. + Payload has `MANUAL NUDGE:` line; honor the modifier. +- **Scheduled:** no issue context. Walk open issues without + `claude-triaged`, skip bots and stale >90d, cap at 10 per run. + ## Four outcomes @@ -94,6 +108,35 @@ Silent-defer (apply `claude-triaged`, no comment) if any of these: Don't post a competing analysis on work a human is already engaged on. +## Lifecycle labels — apply `claude-triaging` before any work + +Once concurrency + already-engaged checks pass and you're going to +do real work, **immediately** apply `claude-triaging`: + +``` +gh issue edit --repo adcontextprotocol/adcp-client-python --add-label claude-triaging +``` + +This is the "I'm on this" signal. At end of run (any outcome), swap +to `claude-triaged`: + +``` +gh issue edit --repo adcontextprotocol/adcp-client-python \ + --remove-label claude-triaging \ + --add-label claude-triaged +``` + +Skip cases (apply `claude-triaged` directly, no `claude-triaging`): + +- **Concurrency-skip** — another session is running. Don't apply + either; let the other session finish. +- **Already-engaged silent-defer** — apply `claude-triaged` + directly; you're not doing real work. +- **Comment-driven non-substantive run** — silent skip; no labels. + +If the run errors before end, `claude-triaging` is left orphaned. A +scheduled sweep clears stuck `claude-triaging` >30 min old. + ## Decision order ### Step 1 — Pre-classification @@ -366,8 +409,35 @@ have read the diff before a human reviewer does. ## Comment engagement -Same as adcp-client — skip +1/emoji, never self-reply, re-evaluate -on new substantive info. +Fires on `comment.created` runs (plain non-`/triage` comments on +issues; the workflow filters bots, self-loops, /triage, and PR +conversations). Payload has `<<>>` plus +the original `<<>>`. + +1. Read the full thread on GitHub before deciding (`gh api + repos///issues//comments`). +2. Decide if the comment is **substantive**: new info, + counter-argument, direct question, refined proposal, or + cross-reference that changes the picture. Non-substantive + ("+1", emoji, "thanks!", "lgtm", bare pings) → silent skip, + no labels. +3. If substantive and **challenges a prior triage**: re-run the + relevant experts; reply with the new conclusion (even if "no + change, here's why"). +4. If substantive and **unlocks a stuck Clarify**: move forward + per outcome rules. +5. If substantive but the issue is in a final state (PR drafted, + deferred with linkage, flagged): post a brief acknowledgment + that routes the new info to the open PR or refreshes the defer + reasoning. +6. Never reply to your own previous comments (workflow filters + most cases via the `Triaged by Claude Code` footer). Never + reply to bots. + +**PR conversations are out of scope.** The workflow filters +`issue_comment` events where `issue.pull_request != null`. PR +review feedback is the **auto-fix** feature's job, not triage. + ## Failure handling diff --git a/.agents/scripts/triage-local.sh b/.agents/scripts/triage-local.sh new file mode 100755 index 00000000..02a433b4 --- /dev/null +++ b/.agents/scripts/triage-local.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Manually fire the Claude Issue Triage routine on an issue, bypassing +# GitHub webhooks. Useful when the webhook delivery missed (silent failure) +# or when you want to nudge the routine without leaving a public `/triage` +# comment trail. +# +# Usage: +# .agents/scripts/triage-local.sh [execute|clarify|defer] +# +# Examples: +# .agents/scripts/triage-local.sh 3112 # fresh triage +# .agents/scripts/triage-local.sh 3112 execute # bias toward Execute +# .agents/scripts/triage-local.sh 3112 clarify # force clarify +# +# Required env vars (or .env file in the cwd): +# CLAUDE_ROUTINE_TRIAGE_URL — full /fire URL for the routine +# CLAUDE_ROUTINE_TRIAGE_TOKEN — bearer token for that routine +# +# Both can be loaded from a local .env, op:// references resolved with +# `op run`, or your shell rc. The script does NOT touch GitHub repo state +# beyond reading the issue — no comments, no labels are written by this +# script; the routine itself does that on the GitHub side. + +set -euo pipefail + +ISSUE_NUM="${1:-}" +MODIFIER="${2:-}" + +if [ -z "$ISSUE_NUM" ]; then + echo "usage: $(basename "$0") [execute|clarify|defer]" >&2 + exit 64 +fi + +# Optional .env loader. +if [ -f .env ]; then + set -a + # shellcheck disable=SC1091 + . .env + set +a +fi + +: "${CLAUDE_ROUTINE_TRIAGE_URL:?env var CLAUDE_ROUTINE_TRIAGE_URL must be set}" +: "${CLAUDE_ROUTINE_TRIAGE_TOKEN:?env var CLAUDE_ROUTINE_TRIAGE_TOKEN must be set}" + +if ! command -v gh >/dev/null 2>&1; then + echo "error: gh CLI not found" >&2 + exit 1 +fi +if ! command -v jq >/dev/null 2>&1; then + echo "error: jq not found" >&2 + exit 1 +fi + +REPO=$(gh repo view --json owner,name --jq '.owner.login + "/" + .name') +echo "Repo: $REPO" +echo "Firing triage for issue #$ISSUE_NUM${MODIFIER:+ (modifier: /$MODIFIER)}" + +issue=$(gh api "repos/$REPO/issues/$ISSUE_NUM") +title=$(echo "$issue" | jq -r '.title') +body=$(echo "$issue" | jq -r '.body // ""') +author=$(echo "$issue" | jq -r '.user.login') +assoc=$(echo "$issue" | jq -r '.author_association // "NONE"') +labels=$(echo "$issue" | jq -c '[.labels[].name]') +html_url=$(echo "$issue" | jq -r '.html_url') + +body_safe=$(printf '%s' "$body" | tr -d '\000' | head -c 8192) + +if [ -n "$MODIFIER" ]; then + case "$MODIFIER" in + execute|clarify|defer) + ;; + *) + echo "error: modifier must be one of: execute, clarify, defer (got: $MODIFIER)" >&2 + exit 64 + ;; + esac + nudge="MANUAL NUDGE: triage-local.sh requested triage with /$MODIFIER. Treat as an explicit request; skip already-engaged check. Honor the modifier (execute / clarify / defer)." + kind="manual" + action="triage" +else + nudge="" + kind="auto" + action="opened" +fi + +payload=$(jq -n \ + --arg repo "$REPO" \ + --arg num "$ISSUE_NUM" \ + --arg title "$title" \ + --arg url "$html_url" \ + --arg author "$author" \ + --arg assoc "$assoc" \ + --arg kind "$kind" \ + --arg action "$action" \ + --argjson labels "$labels" \ + --arg body "$body_safe" \ + --arg nudge "$nudge" \ + '{text: ( + "Event: " + $kind + "." + $action + "\n" + + "Repo: " + $repo + "\n" + + "Issue: #" + $num + " \"" + $title + "\"\n" + + "URL: " + $url + "\n" + + "Author: @" + $author + " (association: " + $assoc + ")\n" + + "Labels: " + ($labels | join(", ")) + "\n" + + (if $nudge == "" then "" else $nudge + "\n" end) + + "\n" + + "<<>>\n" + + $body + "\n" + + "<<>>" + )}') + +set +e +http_code=$(curl --fail-with-body -sS -o /tmp/triage-local-response.json -w "%{http_code}" \ + -X POST "$CLAUDE_ROUTINE_TRIAGE_URL" \ + -H "Authorization: Bearer $CLAUDE_ROUTINE_TRIAGE_TOKEN" \ + -H "anthropic-beta: experimental-cc-routine-2026-04-01" \ + -H "anthropic-version: 2023-06-01" \ + -H "Content-Type: application/json" \ + -d "$payload") +curl_rc=$? +set -e + +echo "HTTP $http_code" +sed 's/[Bb]earer [A-Za-z0-9._-]*/Bearer [REDACTED]/g' /tmp/triage-local-response.json +echo + +if [ $curl_rc -ne 0 ]; then + echo "error: curl failed (rc=$curl_rc)" >&2 + exit 1 +fi + +if [ "${http_code:-000}" -ge 400 ]; then + echo "error: routine returned HTTP $http_code" >&2 + exit 1 +fi + +echo "✓ Fired triage routine for $REPO#$ISSUE_NUM" +echo " Watch for the claude-triaging label to appear, then claude-triaged + outcome comment." diff --git a/.github/workflows/claude-issue-triage.yml b/.github/workflows/claude-issue-triage.yml index 22aedd55..e827682e 100644 --- a/.github/workflows/claude-issue-triage.yml +++ b/.github/workflows/claude-issue-triage.yml @@ -18,6 +18,8 @@ name: Claude Issue Triage on: issues: types: [opened, reopened] + issue_comment: + types: [created] repository_dispatch: types: [triage-command] @@ -35,13 +37,31 @@ jobs: timeout-minutes: 3 # Skip bot-opened issues. Manual path already gated by slash-dispatch # permission check. + # Three trigger paths, each gated: + # - issues.opened/reopened → skip bot-opened issues + # - issue_comment.created → skip bots, skip routine self-loops + # ("Triaged by Claude Code" footer), + # skip /triage (handled by repo_dispatch), + # skip PR conversations (auto-fix's job) + # - repository_dispatch → always allow (slash-dispatch already + # gated by member-association check) if: >- - github.event_name != 'issues' || ( + github.event_name == 'issues' && github.event.issue.user.type != 'Bot' && !endsWith(github.event.issue.user.login, '[bot]') && github.event.sender.type != 'Bot' - ) + ) || + ( + github.event_name == 'issue_comment' && + github.event.comment.user.type != 'Bot' && + !endsWith(github.event.comment.user.login, '[bot]') && + github.event.sender.type != 'Bot' && + !startsWith(github.event.comment.body, '/triage') && + !contains(github.event.comment.body, 'Triaged by Claude Code') && + github.event.issue.pull_request == null + ) || + github.event_name == 'repository_dispatch' defaults: run: shell: bash @@ -57,6 +77,13 @@ jobs: echo "commenter=" >> "$GITHUB_OUTPUT" echo "args=" >> "$GITHUB_OUTPUT" echo "comment_id=" >> "$GITHUB_OUTPUT" + elif [ "${{ github.event_name }}" = "issue_comment" ]; then + echo "number=${{ github.event.issue.number }}" >> "$GITHUB_OUTPUT" + echo "kind=comment" >> "$GITHUB_OUTPUT" + echo "action=created" >> "$GITHUB_OUTPUT" + echo "commenter=${{ github.event.comment.user.login }}" >> "$GITHUB_OUTPUT" + echo "args=" >> "$GITHUB_OUTPUT" + echo "comment_id=${{ github.event.comment.id }}" >> "$GITHUB_OUTPUT" else echo "number=${{ github.event.client_payload.github.payload.issue.number }}" >> "$GITHUB_OUTPUT" echo "kind=manual" >> "$GITHUB_OUTPUT" @@ -78,6 +105,7 @@ jobs: ACTION: ${{ steps.ctx.outputs.action }} COMMENTER: ${{ steps.ctx.outputs.commenter }} ARGS: ${{ steps.ctx.outputs.args }} + COMMENT_ID: ${{ steps.ctx.outputs.comment_id }} run: | set -euo pipefail @@ -86,7 +114,7 @@ jobs: exit 0 fi - # Fetch the issue fresh so both event paths use the same source of truth. + # Fetch the issue fresh so all event paths use the same source of truth. issue=$(gh api "repos/$REPO/issues/$ISSUE_NUMBER") title=$(echo "$issue" | jq -r '.title') body=$(echo "$issue" | jq -r '.body // ""') @@ -97,6 +125,20 @@ jobs: body_safe=$(printf '%s' "$body" | tr -d '\000' | head -c 8192) + # For comment-driven runs, fetch the specific comment so the routine + # can act on it. The full issue body is also included so the routine + # has the original context, not just the new prose. + comment_body_safe="" + comment_author="" + comment_assoc="" + if [ "$EVENT_KIND" = "comment" ] && [ -n "${COMMENT_ID:-}" ]; then + comment=$(gh api "repos/$REPO/issues/comments/$COMMENT_ID") + comment_body=$(echo "$comment" | jq -r '.body // ""') + comment_author=$(echo "$comment" | jq -r '.user.login') + comment_assoc=$(echo "$comment" | jq -r '.author_association // "NONE"') + comment_body_safe=$(printf '%s' "$comment_body" | tr -d '\000' | head -c 4096) + fi + nudge_note="" if [ "$EVENT_KIND" = "manual" ]; then nudge_note="MANUAL NUDGE: @${COMMENTER} requested triage via /triage ${ARGS}. Treat as an explicit request; skip already-engaged check. Honor any modifier (execute / clarify / defer) in the args." @@ -114,6 +156,9 @@ jobs: --argjson labels "$labels" \ --arg body "$body_safe" \ --arg nudge "$nudge_note" \ + --arg comment_body "$comment_body_safe" \ + --arg comment_author "$comment_author" \ + --arg comment_assoc "$comment_assoc" \ '{text: ( "Event: " + $kind + "." + $action + "\n" + "Repo: " + $repo + "\n" + @@ -122,6 +167,13 @@ jobs: "Author: @" + $author + " (association: " + $assoc + ")\n" + "Labels: " + ($labels | join(", ")) + "\n" + (if $nudge == "" then "" else $nudge + "\n" end) + + (if $comment_body == "" then "" else + "\nNew comment by @" + $comment_author + + " (association: " + $comment_assoc + "):\n" + + "<<>>\n" + + $comment_body + "\n" + + "<<>>\n" + end) + "\n" + "<<>>\n" + $body + "\n" + diff --git a/.github/workflows/clear-stuck-claude-triaging.yml b/.github/workflows/clear-stuck-claude-triaging.yml new file mode 100644 index 00000000..0cd4eb75 --- /dev/null +++ b/.github/workflows/clear-stuck-claude-triaging.yml @@ -0,0 +1,78 @@ +name: Clear stuck claude-triaging labels + +# Removes the `claude-triaging` lifecycle label from issues where it's been +# applied for >30 minutes. The label is the "I'm on this" signal for the +# triage routine; under normal flow the routine swaps it for `claude-triaged` +# at the end of the run (1-3 min). Anything older means the routine errored +# mid-run and orphaned the label. +# +# Without this sweep, orphaned `claude-triaging` labels stay forever and +# poison the "is anyone working on this?" signal for humans. + +on: + schedule: + - cron: '*/30 * * * *' # every 30 minutes + workflow_dispatch: {} + +permissions: + issues: write + contents: read + +concurrency: + group: clear-stuck-claude-triaging + cancel-in-progress: false + +jobs: + clear: + name: Clear stuck labels + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Find and clear stuck claude-triaging labels + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + # List open issues currently labeled claude-triaging. + # Filter out PRs (the label is meant for issues only). + mapfile -t numbers < <( + gh api "repos/$REPO/issues?labels=claude-triaging&state=open&per_page=100" --paginate \ + --jq '.[] | select(.pull_request == null) | .number' + ) + + if [ ${#numbers[@]} -eq 0 ]; then + echo "::notice::No issues currently labeled claude-triaging." + exit 0 + fi + + echo "Checking ${#numbers[@]} issues with claude-triaging label..." + cleared=0 + + for num in "${numbers[@]}"; do + # Find the most recent labeled-claude-triaging timeline event. + added_at=$( + gh api "repos/$REPO/issues/$num/timeline?per_page=100" --paginate \ + --jq '[.[] | select(.event == "labeled" and .label.name == "claude-triaging")] | last | .created_at // empty' + ) + + if [ -z "$added_at" ]; then + echo "::warning::#$num has claude-triaging label but no labeled event found — skipping (rare race)." + continue + fi + + now_seconds=$(date -u +%s) + added_seconds=$(date -u -d "$added_at" +%s) + age_minutes=$(( (now_seconds - added_seconds) / 60 )) + + if [ $age_minutes -gt 30 ]; then + echo "Clearing stuck claude-triaging on #$num (applied $age_minutes min ago at $added_at)" + gh issue edit "$num" --repo "$REPO" --remove-label claude-triaging + cleared=$((cleared + 1)) + else + echo " #$num — claude-triaging applied $age_minutes min ago (within 30 min window, leaving)." + fi + done + + echo "::notice::Cleared $cleared stuck claude-triaging label(s) of ${#numbers[@]} checked." diff --git a/.github/workflows/triage-webhook-miss-sweep.yml b/.github/workflows/triage-webhook-miss-sweep.yml new file mode 100644 index 00000000..9b51bbec --- /dev/null +++ b/.github/workflows/triage-webhook-miss-sweep.yml @@ -0,0 +1,140 @@ +name: Triage webhook-miss sweep + +# Catches issues that the `Claude Issue Triage` workflow should have run on +# but didn't (silent webhook misses). For every open, non-bot issue created +# in the last 24h that lacks claude-triaged / claude-triaging labels AND +# has no `## Triage` comment from the routine, fire the routine manually. +# +# Motivated by issue #3112 in adcp where a normal issue creation event never +# triggered the triage workflow — webhook delivery silently dropped, no audit +# trail, the issue sat unprocessed until a human noticed. + +on: + schedule: + - cron: '17 * * * *' # hourly, offset to avoid the top of the hour + workflow_dispatch: {} + +permissions: + issues: read + contents: read + +concurrency: + group: triage-webhook-miss-sweep + cancel-in-progress: false + +jobs: + sweep: + name: Catch missed issues + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Find untriaged issues + fire routine + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + ROUTINE_URL: ${{ secrets.CLAUDE_ROUTINE_TRIAGE_URL }} + ROUTINE_TOKEN: ${{ secrets.CLAUDE_ROUTINE_TRIAGE_TOKEN }} + run: | + set -euo pipefail + + if [ -z "${ROUTINE_URL:-}" ] || [ -z "${ROUTINE_TOKEN:-}" ]; then + echo "::warning::CLAUDE_ROUTINE_TRIAGE_URL or _TOKEN not set — skipping." + exit 0 + fi + + cutoff=$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ) + echo "Looking for untriaged issues created since $cutoff..." + + # Open issues created in last 24h, not PRs, not bot-authored, + # not already labeled claude-triaged or claude-triaging. + mapfile -t numbers < <( + gh api "repos/$REPO/issues?state=open&since=$cutoff&per_page=100" --paginate \ + --jq '.[] | select( + .pull_request == null + and (.user.type != "Bot") + and ((.user.login | endswith("[bot]")) | not) + and (.created_at >= "'"$cutoff"'") + and ([.labels[].name] | (contains(["claude-triaged"]) or contains(["claude-triaging"])) | not) + ) | .number' + ) + + if [ ${#numbers[@]} -eq 0 ]; then + echo "::notice::No untriaged issues from last 24h." + exit 0 + fi + + echo "Found ${#numbers[@]} candidate issues without triage labels: ${numbers[*]}" + fired=0 + skipped=0 + + for num in "${numbers[@]}"; do + # Belt-and-suspenders: skip if a `## Triage` comment already + # exists. The label might have been removed manually. + has_triage_comment=$( + gh api "repos/$REPO/issues/$num/comments" --paginate \ + --jq '[.[] | select(.body | startswith("## Triage"))] | length' + ) + if [ "$has_triage_comment" -gt 0 ]; then + echo " #$num — has ## Triage comment already, skipping." + skipped=$((skipped + 1)) + continue + fi + + echo "Firing triage manually for missed issue #$num" + + issue=$(gh api "repos/$REPO/issues/$num") + title=$(echo "$issue" | jq -r '.title') + body=$(echo "$issue" | jq -r '.body // ""') + author=$(echo "$issue" | jq -r '.user.login') + assoc=$(echo "$issue" | jq -r '.author_association // "NONE"') + labels=$(echo "$issue" | jq -c '[.labels[].name]') + html_url=$(echo "$issue" | jq -r '.html_url') + + body_safe=$(printf '%s' "$body" | tr -d '\000' | head -c 8192) + + payload=$(jq -n \ + --arg repo "$REPO" \ + --arg num "$num" \ + --arg title "$title" \ + --arg url "$html_url" \ + --arg author "$author" \ + --arg assoc "$assoc" \ + --argjson labels "$labels" \ + --arg body "$body_safe" \ + '{text: ( + "Event: recovery.swept\n" + + "Repo: " + $repo + "\n" + + "Issue: #" + $num + " \"" + $title + "\"\n" + + "URL: " + $url + "\n" + + "Author: @" + $author + " (association: " + $assoc + ")\n" + + "Labels: " + ($labels | join(", ")) + "\n" + + "RECOVERY SWEEP: this issue was created >0h ago without triage labels and without a ## Triage comment. The original webhook likely missed. Treat as a fresh auto.opened event.\n" + + "\n" + + "<<>>\n" + + $body + "\n" + + "<<>>" + )}') + + set +e + http_code=$(curl --fail-with-body -sS -o /tmp/fire-response.json -w "%{http_code}" \ + -X POST "$ROUTINE_URL" \ + -H "Authorization: Bearer $ROUTINE_TOKEN" \ + -H "anthropic-beta: experimental-cc-routine-2026-04-01" \ + -H "anthropic-version: 2023-06-01" \ + -H "Content-Type: application/json" \ + -d "$payload") + curl_rc=$? + set -e + + if [ $curl_rc -ne 0 ] || [ "${http_code:-000}" -ge 400 ]; then + echo "::error::Failed to fire routine for #$num (HTTP $http_code, curl rc=$curl_rc)" + sed 's/[Bb]earer [A-Za-z0-9._-]*/Bearer [REDACTED]/g' /tmp/fire-response.json || true + continue + fi + + fired=$((fired + 1)) + # Throttle a bit so we don't fire 10 routines in 1 second. + sleep 5 + done + + echo "::notice::Fired $fired routine(s); skipped $skipped (already had ## Triage comment) of ${#numbers[@]} checked."