--- # Destination: .github/workflows/on_issues_ai_triage.yaml # # Tier 1 of the AI triage system. Fires on every new issue, costs cents, # finishes in ~2 minutes on Sonnet-low. Classifies, de-duplicates, asks for # missing info, answers simple questions, and applies the `ai-triage` label # that tier 2 (daily_ai_fix.yaml) picks up. # # Runs once per issue, with exactly one automatic re-run: when it asks the # reporter for info (`ai:needs-info`), the reporter's reply re-triggers a single # fresh classification (issue_comment path below). A daily 03:30 catch-up job # also re-dispatches any issue that never got triaged (e.g. a failed run). # # Full tier map: # Tier 1 on_issues_ai_triage.yaml (this) Sonnet-low classify on issue open # Tier 2 daily_ai_fix.yaml Opus 5-xhigh daily fix/plan sweep # Tier 3 on_issue_approved.yaml Opus 5-high execute an approved plan # @claude on_claude_mention.yml Sonnet-low maintainer-only interactive # PR on_pr_coderabbit.yml Sonnet-low one-shot CodeRabbit follow-up # Kill switch: set repo variable AI_DISABLED=true to pause every AI workflow. # # Auth: Claude Pro/Max subscription via the CR_PAT GitHub Environment, which # holds the CLAUDE_CODE_OAUTH_TOKEN secret (generate with `claude setup-token`). name: AI issue triage on: issues: types: [opened] issue_comment: types: [created] schedule: # 03:30 — half an hour after the tier-2 sweep, so its relabels have settled. - cron: "30 3 * * *" workflow_dispatch: inputs: issue: description: "Issue number to (re-)triage manually" required: true permissions: contents: read issues: write concurrency: group: ai-triage-${{ github.event.issue.number || inputs.issue || github.run_id }} cancel-in-progress: false env: MAINTAINER: alexbelgium jobs: classify: # Three entry paths: # * issues.opened — the normal fire-on-every-open path, with the guards # that keep it from self-triaging the maintainer's own issues or issues # that opted out with `no-ai`. # * issue_comment — the ONE automatic re-run: the reporter replied to a # needs-info request (issue carries `ai:needs-info`, commenter is the # issue author, not the maintainer). Re-classifies with the new info. # * workflow_dispatch — a deliberate manual/catch-up override that skips # the open-path guards. # The 03:30 schedule does NOT run this job; it runs `catchup` below. if: >- vars.AI_DISABLED != 'true' && ( github.event_name == 'workflow_dispatch' || (github.event_name == 'issues' && github.event.issue.user.login != 'alexbelgium' && !contains(github.event.issue.labels.*.name, 'no-ai')) || (github.event_name == 'issue_comment' && github.event.comment.user.login == github.event.issue.user.login && github.event.comment.user.login != 'alexbelgium' && contains(github.event.issue.labels.*.name, 'ai:needs-info') && !contains(github.event.issue.labels.*.name, 'no-ai')) ) runs-on: ubuntu-latest timeout-minutes: 15 environment: CR_PAT steps: # A reporter reply re-triggered this run. Multiple replies can each pass # the job `if` before the first run clears the flag; cancel-in-progress # is false, so without this they would each run a full classification. # Re-check the LIVE label inside the serialized job and consume it here: # the first queued run finds it present and proceeds (go=true); any run # behind it finds it already gone and skips every downstream step. # apply-verdict re-adds the flag if the issue still needs info (one more # round), or restores it if no verdict was produced (so a later reply can # still retry instead of the issue silently dropping out). - name: Claim needs-info reply id: claim if: github.event_name == 'issue_comment' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE: ${{ github.event.issue.number }} REPO: ${{ github.repository }} run: | set -euo pipefail HAS=$(gh issue view "$ISSUE" --repo "$REPO" --json labels \ --jq '[.labels[].name] | index("ai:needs-info") != null') if [ "$HAS" != "true" ]; then echo "ai:needs-info already consumed by an earlier queued run; skipping" echo "go=false" >> "$GITHUB_OUTPUT" exit 0 fi gh issue edit "$ISSUE" --repo "$REPO" --remove-label ai:needs-info || true echo "go=true" >> "$GITHUB_OUTPUT" # on_issues_ping_submitter.yml has to land first: the classifier reads # the existing comments and bails out if someone already owns the issue. # Both workflows fire on the same issues.opened event and race. The # submitter ping completes in 6-11s of job time across recent runs; 60s # leaves a generous margin for runner-queue skew between the two jobs. # A manual dispatch runs against an existing issue whose ping (if any) # landed long ago, so there is nothing to wait for. - name: Wait for ping_submitter if: github.event_name == 'issues' run: sleep 60 # Skip everything below for a needs-info reply that was already consumed # by an earlier queued run (steps.claim.go == false). Non-comment events # (issues.opened, dispatch) never set claim, so they always proceed. - name: Checkout tooling if: github.event_name != 'issue_comment' || steps.claim.outputs.go == 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 persist-credentials: false sparse-checkout: | .github/prompts .github/scripts sparse-checkout-cone-mode: false - name: Build context bundle if: github.event_name != 'issue_comment' || steps.claim.outputs.go == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue }} REPO: ${{ github.repository }} run: bash .github/scripts/ai_triage_context.sh - name: Classify id: classify if: github.event_name != 'issue_comment' || steps.claim.outputs.go == 'true' continue-on-error: true uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # Without this the action falls back to the OIDC -> Claude App token # exchange, which 401s ("User does not have write access on this # repository") whenever github.actor is the outside reporter who # opened the issue or replied to a needs-info request. Same token the # step already exports as GH_TOKEN; classify only reads. github_token: ${{ secrets.GITHUB_TOKEN }} show_full_output: true prompt: | Read /tmp/ai-triage/context.md, then follow the instructions in .github/prompts/issue-classify.md exactly. Write your verdict as a single JSON object to /tmp/ai-triage/verdict.json and write nothing else anywhere. Do NOT comment on or label the issue yourself. claude_args: | --model claude-sonnet-5 --effort low --max-turns 12 --allowedTools "Read,Write,Glob,Grep,Bash(gh issue list:*),Bash(gh search issues:*)" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Apply verdict if: github.event_name != 'issue_comment' || steps.claim.outputs.go == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE: ${{ github.event.issue.number || inputs.issue }} REPO: ${{ github.repository }} EVENT_NAME: ${{ github.event_name }} run: | set -euo pipefail F=/tmp/ai-triage/verdict.json if [ ! -s "$F" ] || ! jq -e . "$F" >/dev/null 2>&1; then echo "::warning::no usable verdict produced, leaving issue untouched" # A reporter reply consumed ai:needs-info in the claim step above. # With no verdict we would otherwise leave the issue with the flag # gone, so the next reply could never re-trigger — restore it. if [ "${EVENT_NAME:-}" = "issue_comment" ]; then gh issue edit "$ISSUE" --repo "$REPO" --add-label ai:needs-info >/dev/null 2>&1 || true fi exit 0 fi echo "--- verdict ---"; jq . "$F"; echo "---------------" VERDICT=$(jq -r '.verdict // "unknown"' "$F") CONF=$(jq -r '.confidence // "low"' "$F") COMMENT=$(jq -r '.comment // ""' "$F") # Model-supplied labels are cosmetic only (e.g. "bug"). ai-triage / # ai:classified / ai:needs-human are workflow-owned control labels; # strip anything in that namespace so a verdict can't self-trigger # tier 2 (the deterministic add below is the only legitimate source # of ai-triage). mapfile -t LABELS < <(jq -r '.labels[]? // empty' "$F" | grep -vE '^ai[:-]' || true) # Someone already owns this one: ping_submitter did its job. Best- # effort clear of a manual re-triage's stale control labels (e.g. a # prior addon-bug run) — nothing to do if they were never set. if [ "$VERDICT" = "owned" ]; then echo "issue already has an owner, nothing to do" gh issue edit "$ISSUE" --repo "$REPO" \ --remove-label=ai-triage --remove-label=ai:classified \ --remove-label=ai:needs-human --remove-label=ai:needs-info \ >/dev/null 2>&1 || true exit 0 fi # Low confidence never speaks. It just flags for a human. if [ "$CONF" = "low" ]; then LABELS=("ai:needs-human"); COMMENT="" fi # ai-triage is the tier-2 trigger, so it must never be added to a # low-confidence verdict — Rule 2 of issue-classify.md says an # uncertain addon/upstream call should only flag a human, not enter # the unattended fix pass. (Above, low confidence already reset # LABELS to ai:needs-human; this guard keeps ai-triage from being # appended right back.) if [ "$VERDICT" = "addon-bug" ] && [ "$CONF" != "low" ]; then LABELS+=("ai-triage") fi # needs-info flags the thread so the reporter's reply re-triggers one # more classification (see the issue_comment path). Only on a # confident needs-info — a low-confidence verdict already became # ai:needs-human above, which is a human hand-off, not an info wait. if [ "$VERDICT" = "needs-info" ] && [ "$CONF" != "low" ]; then LABELS+=("ai:needs-info") fi LABELS+=("ai:classified") # No --force: an existing label (e.g. a model-supplied cosmetic # "bug") must be left as-is. --force would update it, recoloring # every such label to ededed as a side effect of triage. Without it, # create fails harmlessly on labels that already exist (|| true), # and still creates the workflow-owned ones the first time. for l in "${LABELS[@]}"; do gh label create "$l" --repo "$REPO" --color ededed >/dev/null 2>&1 || true done gh issue edit "$ISSUE" --repo "$REPO" \ "${LABELS[@]/#/--add-label=}" # Manual re-triage can flip the verdict (e.g. a prior addon-bug # re-run now comes back needs-info/upstream-bug): clear whichever # of tier 1's own control labels this run did NOT re-apply, so a # stale ai-triage doesn't keep the issue in tomorrow's fix sweep. # Separate, best-effort call — must not block the add above. declare -A FRESH=() for l in "${LABELS[@]}"; do FRESH["$l"]=1; done STALE=() for l in ai-triage ai:classified ai:needs-human ai:needs-info; do [ -z "${FRESH[$l]:-}" ] && STALE+=("$l") done if [ "${#STALE[@]}" -gt 0 ]; then gh issue edit "$ISSUE" --repo "$REPO" "${STALE[@]/#/--remove-label=}" >/dev/null 2>&1 || true fi if [ -n "$COMMENT" ]; then { printf '%s\n\n' "$COMMENT" printf -- '---\n' printf 'Automated triage. Not verified by a human yet ' # shellcheck disable=SC2016 # backticks are literal Markdown, not a subshell printf -- '— @%s will confirm. Add the `no-ai` label to opt out.\n' "$MAINTAINER" } > /tmp/ai-triage/comment.md gh issue comment "$ISSUE" --repo "$REPO" --body-file /tmp/ai-triage/comment.md fi # Self-healing catch-up. Tier 1 fires on issue-open, but a run can fail # (Claude overload, a transient error) and leave the issue untriaged forever. # Once a day, re-dispatch classification for any open issue that never got an # ai:* label — cheap pure shell, no Claude in this job. catchup: if: ${{ github.event_name == 'schedule' && vars.AI_DISABLED != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 environment: CR_PAT steps: - name: Re-dispatch untriaged issues env: # AI_PR_TOKEN (repo scope) can dispatch workflows; GITHUB_TOKEN would # need actions:write added to the whole workflow. GH_TOKEN: ${{ secrets.AI_PR_TOKEN }} REPO: ${{ github.repository }} run: | set -euo pipefail NOW=$(date -u +%s) # Filter untriaged issues SERVER-SIDE so the total open-issue count is # irrelevant (a plain --limit would silently drop everything past the # cap, and gh lists newest-first). The search excludes every ai:* / # ai-triage / no-ai label, so what comes back is already the candidate # set; newest 50 is far more than the daily cap of 5. gh issue list --repo "$REPO" --limit 50 \ --search 'is:open sort:created-desc -label:ai-triage -label:"ai:classified" -label:"ai:needs-info" -label:"ai:needs-human" -label:"ai:plan-pending" -label:"ai:approved" -label:"ai:fixed" -label:"ai:upstream" -label:no-ai' \ --json number,createdAt,author > /tmp/catchup.json # Not the maintainer's own issue, and older than 2h (so a just-opened # issue whose tier-1 run is still in flight is not double-dispatched). # Cap 5 per day. jq -r --argjson now "$NOW" ' .[] | select(.author.login != "alexbelgium") | select((.createdAt | fromdateiso8601) < ($now - 7200)) | .number' /tmp/catchup.json | head -n 5 > /tmp/todo.txt COUNT=$(grep -c . /tmp/todo.txt || true) echo "untriaged issues to re-dispatch: $COUNT" while IFS= read -r n; do [ -n "$n" ] || continue echo "re-dispatching tier 1 for #$n" gh workflow run "AI issue triage" --repo "$REPO" -f issue="$n" || \ echo "::warning::could not dispatch classify for #$n" done < /tmp/todo.txt