Files
hassio-addons/.github/workflows/on_issues_ai_triage.yaml
dependabot[bot] 5f9ecb7b05 chore(deps): bump anthropics/claude-code-action from 1.0.181 to 1.0.183 (#2914)
Bumps [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action) from 1.0.181 to 1.0.183.
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](44423bdec7...be7b93b190)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.183
  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-07-27 21:35:07 +02:00

322 lines
15 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`).
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 '<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
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