Files
hassio-addons/.github/workflows/on_issues_ai_triage.yaml
dependabot[bot] 96380fdf5f Bump anthropics/claude-code-action from 1.0.183 to 1.0.187 (#2957)
Bumps [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action) from 1.0.183 to 1.0.187.
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](be7b93b190...1623c36729)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.187
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 20:11:01 +02:00

645 lines
35 KiB
YAML

---
# 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=<that script> 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 '<sub>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.</sub>\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