--- # 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`). # GitHub side is GITHUB_TOKEN throughout — no PAT. The classify job pairs it # with `allowed_non_write_users` so an outside reporter's issue-open event can # get past the action's write-permission gate; the catch-up job pairs it with a # job-level actions:write so it can dispatch. See the comments at each site. 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 source: # Explicit provenance, set only by the catch-up job below. Previously # this was inferred from github.actor, which is brittle: a re-run, a # dispatch via a PAT or App, or another maintainer all change it, and # the dangerous direction is the false negative — an automated retry # that is never recognised as one keeps retrying forever. An input the # scheduler sets explicitly cannot drift with GitHub's actor semantics. description: "Set to 'catchup' by the daily catch-up job; leave blank for a manual re-triage" required: false default: "" 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 # .templates holds the shared build/runtime scripts (ha_entrypoint.sh, # ha_automodules.sh, the cont-init modules) that nearly every add-on # depends on, so a large share of reports can only be explained by # reading them. Without it the classifier burned 6 of its turns on # #2949 hunting for files that were not checked out, then died on # max_turns. It is a small directory — cheaper to ship than to search # for and not find. sparse-checkout: | .github/prompts .github/scripts .templates 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@1623c36729ac1cd5895198cded705a287de7db79 # 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 }} # THE fix for tier 1. `issues` and `issue_comment` are "entity" # contexts in the action (src/github/context.ts), so it runs # checkWritePermissions() against github.actor — which on an # issue-open event is the outside reporter, who never has write. # Every run failed there ("Actor does not have write permissions") # and continue-on-error painted it green. The bypass branch in # src/github/validation/permissions.ts needs BOTH github_token # (above) and a non-empty allowed_non_write_users — hence this. # `schedule` / `workflow_dispatch` are "automation" contexts and skip # the check entirely, which is why the catch-up path below does not # need it. # # This is the case the input exists for (docs/security.md: "designed # for automation workflows where user permissions are already # restricted by the workflow's permission scope"). The scope here is # contents:read + issues:write, the model gets no credentials and no # Bash, and every value it produces is validated in Apply verdict. allowed_non_write_users: "*" # Separate gate from the one above, and it bit the catch-up path in # production: checkHumanActor (src/github/validation/actor.ts) # rejects any actor whose account type is not User. The catch-up # dispatches with GITHUB_TOKEN, so those runs arrive as # github-actions[bot] and died with "Workflow initiated by non-human # actor". allowed_non_write_users does NOT cover this — it is only # consulted for User accounts. # Named rather than "*": only this repo's own workflows can dispatch # as github-actions, whereas "*" would also admit any other App that # can reach a trigger. The matcher lowercases and strips a trailing # [bot], so this entry matches the github-actions[bot] actor. # Scheduled runs are unaffected either way — they arrive as # actor=alexbelgium, a User. allowed_bots: "github-actions" show_full_output: true # Stated up front, because a wrong guess about the environment costs # turns the analysis then does not have. On #2949, under the earlier # 12-turn budget, the model spent 3 turns retrying Bash and 6 hunting # files outside the sparse checkout and died before reaching a # verdict. The budget is 25 now, but it is meant to buy analysis, not # more failed probing — keep this in step with --max-turns below. prompt: | Read /tmp/ai-triage/context.md, then follow the instructions in .github/prompts/issue-classify.md exactly. Before you start, two facts about this environment. Both are hard limits, not preferences — working around them is not possible and costs you turns you need for the analysis. You have exactly three tools: Read, Glob and Grep. There is no Bash. Do not try to run `find`, `ls`, `cat` or any other command; those calls fail and are not retryable. Use Glob where you would have used `find`, and Grep where you would have used `grep`. This is a SPARSE checkout of a 100+ add-on monorepo. Only these paths exist on disk — everything else is absent, and searching for it will find nothing no matter how you phrase the search: * .templates/ shared build and runtime scripts that most add-ons rely on (ha_entrypoint.sh, ha_automodules.sh, the cont-init modules) * .github/prompts/, .github/scripts/ * the single add-on directory named in the context bundle, if it was resolved — the bundle says which, or says UNRESOLVED Other add-ons are NOT present. If the bundle says UNRESOLVED, no add-on source is on disk at all: judge from the bundle alone and set confidence accordingly rather than searching for the code. You have a budget of 25 turns. The context bundle already contains the issue, its comments, the add-on's config/Dockerfile/docs, its recent commits and candidate duplicates — so read it first and spend turns only on what it does not already answer. Return your verdict as structured output. Do NOT comment on or label the issue yourself. # The model gets NO write capability of any kind — not Bash, not # Write, and no GH_TOKEN in this step's env. That matters more here # than usual: allowed_non_write_users above deliberately admits # untrusted reporters, and the issue body it reads is their text. # With a Write tool an injected instruction could drop a script on # disk and append BASH_ENV= to the runner's $GITHUB_ENV # file command (discoverable under $RUNNER_TEMP with Glob). The # runner applies that between steps, so the next bash step — Apply # verdict, holding an issues:write GH_TOKEN — would source it before # any of the validation below ran. Delivering the verdict through the # action's --json-schema structured output instead of a file removes # the write primitive that chain starts from. # Duplicate lookup is already done too: ai_triage_context.sh ran # `gh search issues` and baked the candidates into context.md, so the # model has nothing left to ask GitHub for either. claude_args: | --model claude-sonnet-5 --effort low --max-turns 25 --allowedTools "Read,Glob,Grep" --json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["owned","duplicate","needs-info","question","upstream-bug","addon-bug","feature-request"]},"addon":{"type":"string"},"confidence":{"type":"string","enum":["high","medium","low"]},"duplicate_of":{"type":"integer"},"labels":{"type":"array","items":{"type":"string"},"maxItems":2},"root_cause_hint":{"type":"string"},"comment":{"type":"string"}},"required":["verdict","confidence"]}' - 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 }} # Distinguishes the automated catch-up retry from a manual # re-triage — see is_automated_retry below. DISPATCH_SOURCE: ${{ inputs.source }} CLASSIFY_OUTCOME: ${{ steps.classify.outcome }} # Through env, never interpolated into the script body: this string # is model output and "${{ }}" inline would splice it into the shell # source itself. STRUCTURED: ${{ steps.classify.outputs.structured_output }} # Written by the action even when it fails (setExecutionFileOutputIfPresent # runs in its catch block), which is what lets the max-turns check below # work on exactly the runs that need it. EXECUTION_FILE: ${{ steps.classify.outputs.execution_file }} run: | set -euo pipefail mkdir -p /tmp/ai-triage F=/tmp/ai-triage/verdict.json # A reporter reply consumed ai:needs-info in the claim step above, so # every early exit below has to restore it or the next reply could # never re-trigger. Defined once here rather than repeated per exit. restore_needs_info() { [ "${EVENT_NAME:-}" = "issue_comment" ] || return 0 gh issue edit "$ISSUE" --repo "$REPO" --add-label ai:needs-info >/dev/null 2>&1 || true } # Is this the automated second look, rather than a first attempt? # EVENT_NAME alone is not enough: workflow_dispatch is BOTH the daily # catch-up retry and the maintainer's manual re-triage, so keying on # it alone escalates a hand-dispatched first attempt immediately. # The catch-up therefore states its provenance explicitly via the # `source` input. Inferring it from github.actor instead was rejected: # a re-run, a PAT- or App-issued dispatch, or a different maintainer # all change the actor, and the failure that matters is the false # NEGATIVE — an automated retry not recognised as one would never # escalate and would retry that issue forever. # Unknown provenance is treated as "not the automated retry", which # is safe here because every non-escalating max-turns path below ends # in a red run rather than a silent green one. is_automated_retry() { [ "${EVENT_NAME:-}" = "workflow_dispatch" ] && [ "${DISPATCH_SOURCE:-}" = "catchup" ] } # Hand the issue to a human and take it out of the retry rotation. # Returns non-zero if the labels did not actually land — callers must # treat that as a failure rather than reporting a hand-off that never # happened, which would leave the issue unlabelled and back in the # retry rotation it was supposed to leave. escalate_to_human() { # Best effort: the label usually exists, and `gh issue edit` fails # on its own below if it does not. gh label create ai:needs-human --repo "$REPO" --color ededed >/dev/null 2>&1 || true # NOT suppressed with `|| true`. ai-triage and ai:needs-info come # off in the same call: leaving ai-triage would keep an issue we # just escalated sitting in tier 2's unattended queue, and leaving # ai:needs-info would let a reporter reply silently re-trigger # classification behind the human's back. Removing a label the # issue does not carry is a no-op, so this cannot fail spuriously. gh issue edit "$ISSUE" --repo "$REPO" \ --add-label ai:needs-human \ --remove-label ai-triage --remove-label ai:needs-info >/dev/null 2>&1 } # Did the run die on its turn budget rather than on a workflow fault? # The execution file is a JSON array of SDK messages; the terminal # result object carries subtype "error_max_turns". # # This MUST fail closed: a false positive here downgrades a genuine # workflow failure from a red run to a warning, which is the exact # silent-failure class this workflow was rebuilt to remove. Hence the # explicit `type == "array"` root check — without it `.[]?` happily # iterates the VALUES of an object, so if the action ever changed the # file's shape, {"result":{"subtype":"error_max_turns"}} would match # and mask the failure. Anything that is not the array we expect is # treated as "not max turns" and falls through to the loud path. # The `?` and per-element type check keep a non-object element from # aborting the step under set -e. hit_max_turns() { [ -n "${EXECUTION_FILE:-}" ] && [ -s "${EXECUTION_FILE:-}" ] || return 1 jq -e '(type == "array") and any(.[]?; (type == "object") and (((.subtype? // "") == "error_max_turns") or ((.terminal_reason? // "") == "max_turns")))' \ "$EXECUTION_FILE" >/dev/null 2>&1 } # GATE 1 — did the action itself run? This is checked BEFORE looking # at the payload, because the action can fail *after* having written # a valid structured output: the object would sail through the shape # check below, labels and a comment would be applied, and the step # would exit 0 — a green run on a failed action, which is the exact # silent-failure mode this workflow was rebuilt to eliminate. # A failed action means its output is not trustworthy, full stop. # # This branch is also deliberately label-neutral. An action failure # (auth, config, an outage) is systemic — it hits every issue the # same way — so quarantining here would silently bury a batch a day # while the real fault sits in the workflow. Fail red, add nothing, # let the catch-up retry once it's fixed. Classify carries # continue-on-error so this step still runs at all; without the # explicit exit 1 the job would report success. if [ "${CLASSIFY_OUTCOME:-}" = "failure" ]; then restore_needs_info # ...with one exception. Exhausting the turn budget is NOT a # workflow fault: the action ran fine and this particular issue was # just too tangled to finish inside the turn budget. Treating it as systemic # meant #2949 failed red and stayed unlabelled, so the catch-up # re-dispatched it every day forever — and being the newest issue # it took the first of only five daily slots each time. # So it is handled like GATE 2 below instead: one retry, then a # human. Warning rather than error, because a red run per day for a # per-issue condition is alarm fatigue, and the outcome is recorded # durably on the issue itself rather than only in a run log. # A green run is only ever justified once the outcome is recorded # somewhere durable. On the automated second look that is the # ai:needs-human label, and only if it actually landed. On a first # attempt nothing is recorded anywhere but this annotation, so # exiting 0 there would be precisely the "green run, work silently # dead" state that left triage broken for weeks. It costs at most # one red run per problem issue, not one per day, because the # second look ends the retry rotation either way. if hit_max_turns; then if is_automated_retry; then echo "::warning::second attempt for #$ISSUE also ran out of turns, handing it to a human" if ! escalate_to_human; then echo "::error::could not label #$ISSUE ai:needs-human — it is NOT escalated and stays in the retry rotation" exit 1 fi exit 0 fi echo "::error::classification for #$ISSUE ran out of turns; leaving it for the catch-up to retry once, after which it goes to a human" exit 1 fi echo "::error::the Classify action failed for #$ISSUE — this is usually a workflow-level fault affecting every issue, so the issue is left untouched for a retry. See the Classify step." exit 1 fi # The verdict arrives as the action's schema-validated structured # output rather than a file the model wrote — see the Classify step. printf '%s' "${STRUCTURED:-}" > "$F" # GATE 2 — the action ran, but is the payload usable? `jq -e .` alone # accepts any truthy JSON, so a verdict of `[1,2]` or `"hi"` would # pass and then die on `.verdict` below with "Cannot index array with # string", killing the step under set -e before the restore. Require # an object. # # Reaching here means the failure is specific to THIS issue — the # model looked at it and produced nothing usable — so a retry is # worth exactly one attempt. Only a dispatch carrying source=catchup # counts as that second attempt (is_automated_retry above); a manual # workflow_dispatch is a first look and does NOT escalate, leaving # the issue unlabelled so the catch-up still gets its own go. On the # automated retry, hand it to a human rather than re-dispatching the # same issue every day forever; ai:needs-human is in the catch-up # exclusion search, so it drops out of the queue instead of starving # newer issues behind it. if [ ! -s "$F" ] || ! jq -e 'type == "object"' "$F" >/dev/null 2>&1; then restore_needs_info echo "::warning::no usable verdict produced for #$ISSUE" if is_automated_retry; then echo "::warning::second attempt produced no verdict, handing #$ISSUE to a human" if ! escalate_to_human; then echo "::error::could not label #$ISSUE ai:needs-human — it is NOT escalated and stays in the retry rotation" exit 1 fi fi exit 0 fi echo "--- verdict ---"; jq . "$F"; echo "---------------" # Everything below is derived from a file the model wrote after # reading an attacker-controlled issue body, so treat all of it as # untrusted input and validate before it reaches a `gh` call. VERDICT=$(jq -r '.verdict // "unknown"' "$F") CONF=$(jq -r '.confidence // "low"' "$F") COMMENT=$(jq -r '.comment // ""' "$F") case "$VERDICT" in owned|duplicate|needs-info|question|upstream-bug|addon-bug|feature-request) ;; *) echo "::warning::unrecognised verdict '$VERDICT', treating as low confidence" VERDICT="unknown"; CONF="low" ;; esac case "$CONF" in high|medium|low) ;; *) CONF="low" ;; esac # A triage comment is a duplicate one-liner or a <=4-item checklist. # Anything longer is a malfunction or an attempt to use the bot's # identity to post a wall of text / mention spam, so cap it. if [ "${#COMMENT}" -gt 4000 ]; then echo "::warning::comment was ${#COMMENT} chars, suppressing it and flagging a human" COMMENT=""; CONF="low" fi # Model-supplied labels are cosmetic only, so this is an explicit # allowlist rather than "any existing label that isn't ai:*". The repo # carries labels that steer things — automerge, Priority, codex, # wontfix, dependency-update, no-ai — and a crafted issue body must not # be able to reach any of them through the classifier. These two are # the only ones tier 1's verdicts actually map onto (addon-bug / # upstream-bug -> bug, feature-request -> enhancement); both already # exist, so nothing is ever created from model output. Cap at 2, as # issue-classify.md already specifies. mapfile -t LABELS < <( jq -r '.labels[]? // empty' "$F" \ | grep -xE 'bug|enhancement' \ | head -n 2 || 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") # Only the workflow-owned control labels are ever created here; the # cosmetic ones were already filtered down to labels that exist. No # --force, so an existing label keeps its colour instead of being # recoloured to ededed as a side effect of triage. for l in ai-triage ai:classified ai:needs-human ai:needs-info; do gh label create "$l" --repo "$REPO" --color ededed >/dev/null 2>&1 || true done # LABELS always picks up ai:classified above, so it cannot be empty # today — but an empty array would expand to zero arguments and make # `gh issue edit` fail with no option supplied, killing the step under # set -e. Guard it so a future branch can't reintroduce that. if [ "${#LABELS[@]}" -gt 0 ]; then gh issue edit "$ISSUE" --repo "$REPO" \ "${LABELS[@]/#/--add-label=}" else echo "::warning::no labels selected, skipping the add" fi # 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 # The comment body is model prose written after reading an # attacker-controlled issue. Defuse @mentions in it so a crafted # issue can't turn the bot into a notification cannon: the empty # HTML comment stops GitHub linkifying (and notifying) the handle # while still rendering as plain "@name". The footer's own mention # of the maintainer is added below, after this, so it still works. COMMENT=$(printf '%s' "$COMMENT" | sed 's/@\([A-Za-z0-9]\)/@\1/g') { 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 # No `environment: CR_PAT` — this job holds no Claude call and now uses # GITHUB_TOKEN, so it needs nothing from that environment's secrets. # Job-level, so only this job gets actions:write — the classify job above # keeps the workflow-level contents:read + issues:write, which is what # allowed_non_write_users is safe under. permissions: contents: read issues: read actions: write steps: - name: Re-dispatch untriaged issues env: # Was secrets.AI_PR_TOKEN, which is a fine-grained PAT WITHOUT the # actions scope: every dispatch returned "HTTP 403: Resource not # accessible by personal access token" and the `|| echo ::warning::` # below swallowed it, so the safety net never caught anything. # GITHUB_TOKEN + the job-level actions:write above needs no PAT at # all, and workflow_dispatch is explicitly exempt from the rule that # GITHUB_TOKEN-triggered events don't start new runs. GH_TOKEN: ${{ secrets.GITHUB_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" FAILED=0 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" -f source=catchup || { echo "::error::could not dispatch classify for #$n" FAILED=$((FAILED + 1)) } done < /tmp/todo.txt # This job IS the safety net. A net that fails silently is worse than # no net — it reported success every day for weeks while dispatching # nothing. Fail the run so the breakage is visible. if [ "$FAILED" -gt 0 ]; then echo "::error::$FAILED of $COUNT catch-up dispatches failed" exit 1 fi