Compare commits

...

14 Commits

Author SHA1 Message Date
Alexandre
872cfe2994 Update on_issues_ai_triage.yaml 2026-07-23 16:27:34 +02:00
github-actions
debaabc047 Github bot : issues linked to readme 2026-07-23 13:52:09 +00:00
Alexandre
3046e6be80 Merge pull request #2899 from alexbelgium/fix/ai-triage-live-readiness
fix: remove tier-1 dry-run, self-provision ai:blocked
2026-07-23 15:49:14 +02:00
alexbelgium
c44206decf fix: clear stale tier-1 control labels on manual re-triage
Verified: gh issue edit --add-label=... is purely additive, and the
"owned" branch exited without touching labels at all — so a manual
workflow_dispatch re-triage that changes the verdict (e.g. a prior
addon-bug run now comes back needs-info, upstream-bug, or owned) left
the old ai-triage label in place, and daily_ai_fix.yaml would still pick
the issue up for the unattended fix pass despite the fresh verdict.

Both label-applying paths now also remove whichever of
ai-triage/ai:classified/ai:needs-human this run did NOT re-apply, as a
separate best-effort call that can't block the add. Simulated every
verdict/confidence transition, including the reported case (addon-bug ->
needs-info): ai-triage is now correctly removed instead of left stale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:44:33 +02:00
alexbelgium
82085bca71 fix: don't tier-2 low-confidence verdicts; stop recoloring labels
Two findings from Codex review of #2899, both live now that DRY_RUN is gone:

- A low-confidence `addon-bug` had ai:needs-human set by the low branch and
  then ai-triage appended right back unconditionally, so it would enter the
  unattended tier-2 fix pass despite Rule 2 saying an uncertain call should
  only flag a human. Guard the ai-triage add on CONF != low.
- The label-create loop used `--force`, which updates existing labels; with
  a model-supplied cosmetic label like `bug` that already exists, triage
  recolored it to ededed as a side effect. Drop `--force` so existing labels
  are left untouched (create fails harmlessly via || true) while missing
  ones are still created.

tier 2's own label step keeps --force intentionally: its list is a fixed
set of workflow-owned ai:* labels meant to be gray, not model input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:41:16 +02:00
Alexandre
adcb8c8341 Merge pull request #2900 from alexbelgium/feat/triage-single-issue-dispatch
feat: manual tier-1 triage of a single issue number
2026-07-23 15:38:56 +02:00
alexbelgium
0fae882a68 feat: allow manual tier-1 triage of a single issue number
Adds a workflow_dispatch trigger with a required `issue` input to
on_issues_ai_triage.yaml, so a specific (existing) issue can be triaged
on demand instead of only on issues.opened.

- Every issue-number reference now reads
  `github.event.issue.number || inputs.issue`, so it resolves from the
  event on the auto path and from the input on manual dispatch.
- The job's auto-trigger guards (skip the maintainer's own issues, honour
  no-ai) are bypassed on workflow_dispatch — a manual run is a deliberate
  override.
- The 60s ping_submitter wait is skipped on manual dispatch; there's no
  race to lose against an issue whose ping already landed.

The input flows only through env vars and expression contexts, never
inline into a run: block, so there's no shell-injection surface; a bad
number just fails `gh issue view` cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:37:45 +02:00
alexbelgium
3755fd97bc fix: grant id-token: write for claude-code-action OAuth flow
A manual AI fix sweep failed with "Could not fetch an OIDC token. Did you
remember to add id-token: write to your workflow permissions?". The action
mints a GitHub OIDC token to authenticate the claude_code_oauth_token flow,
which needs id-token: write — absent from both jobs' permissions. Tier 2
failed on it now; tier 1 would have failed identically the first time it
ran live. Added to both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:35:31 +02:00
alexbelgium
105543690e fix: go live on tier 1, self-provision ai:blocked
- on_issues_ai_triage.yaml: remove DRY_RUN entirely. AI_PR_TOKEN is now
  configured, tier 1 has been watched in dry-run, and the toggle was
  meant to be temporary scaffolding, not a permanent code path — verdicts
  now apply labels/comments unconditionally.
- daily_ai_fix.yaml: fold ai:blocked into the existing "ensure labels
  exist up front" step (renamed to reflect that). It was the one control
  label neither workflow ever created: the forbidden-paths guard applies
  it directly, and under set -euo pipefail a missing label there aborts
  that step's loop entirely, silently skipping every remaining PR behind
  the one that failed. No repo had hit this yet only because no label in
  the ai:*/ai-* namespace existed at all before now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:31:20 +02:00
Alexandre
2ff5753146 Merge pull request #2895 from alexbelgium/chore/ai-issue-triage
chore: two-tier AI issue triage (draft)
2026-07-23 15:17:34 +02:00
alexbelgium
174ffffd8f fix: address review findings from CodeRabbit/Codex
Verified each against current code before fixing; verification details are
in the PR description update.

Fixed:
- issue-classify.md: Rule 0 now requires the addon-submitter-ping marker to
  appear in a comment headed "### @github-actions[bot]", not just anywhere
  in a comment or issue body, so it can't be spoofed to suppress triage.
- ai_triage_context.sh: separator-insensitive addon-slug matching (fixes
  "Calibre-web" -> calibre_web, and the earlier ImmichFrame -> immich_frame
  miss) before falling back to substring matching; sparse-checkout failure
  now surfaces "UNRESOLVED" into the bundle instead of silently proceeding
  addon-less; duplicate-issue search excludes the issue being triaged from
  its own candidate list.
- on_issues_ai_triage.yaml: persist-credentials: false on the read-only
  tooling checkout (nothing in that job pushes); both actions pinned to
  commit SHAs (Dependabot already covers github-actions repo-wide, and
  on_issues_ai.yml already sets this precedent for another AI action);
  model-supplied labels are now filtered to drop anything in the ai-*/ai:*
  control namespace before merging with the deterministic ai-triage/
  ai:classified additions, closing a path where a verdict could
  self-trigger tier 2 regardless of its actual classification.
- daily_ai_fix.yaml: both actions pinned to the same commit SHAs;
  workflow_dispatch inputs.issue/inputs.limit moved out of direct
  ${{ }} interpolation in the run: script and into env vars with numeric
  validation (template-injection); Guard forbidden paths' PR listing
  limit raised 50 -> 300 so it can't silently drop ai-fix/ PRs behind
  unrelated open PRs before the branch-name filter applies.

Skipped (reasons in PR description):
- persist-credentials on daily_ai_fix.yaml's checkout: disabling it
  breaks the only auth path git push currently uses, and the same
  AI_PR_TOKEN is already directly readable via GH_TOKEN env by that job's
  unrestricted Bash(git:*)/Bash(gh:*) tools regardless.
- Splitting untrusted AI analysis into a separate job from PR-creation/
  write access: legitimate defense in depth, but a full architecture
  redesign, not a minimal fix.
- Full hard-limit enforcement (config.yaml immutability, diff caps,
  draft-only status) replicated at the workflow level: heavy lift: the
  prompt already covers these as Claude-followed instructions; only the
  protected-paths check is duplicated as deterministic enforcement,
  which is the single highest-severity one to enforce outside the model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:02:26 +02:00
alexbelgium
fecae3241f chore: relabel handled issues so daily sweeps don't re-treat them
Now that tier 2 runs daily instead of weekly, an issue left carrying
ai-triage after a sweep would be re-selected and fully re-analysed the very
next morning, before there's been a working day to review the first PR.
Nothing previously dropped issues out of the ai-triage backlog once handled.

- issue-fix.md: new hard limit 6 — relabel every issue as the last action
  before moving to the next one. ai:fixed / ai:upstream / ai:needs-human
  replace ai-triage depending on outcome.
- daily_ai_fix.yaml: pre-create the three replacement labels once, up front
  (Claude never has to improvise a color or retry a missing-label error —
  wasted turns multiplied by batch size). Add a "Guard against repeat
  processing" step, same belt-and-braces pattern as the existing forbidden-
  paths guard: force-relabel to ai:needs-human anything the batch still
  finds carrying ai-triage after the run, independent of whether Claude's
  own relabeling succeeded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:52:42 +02:00
alexbelgium
17d29762d7 chore: use Claude subscription auth, run fix sweep daily
- Swap anthropic_api_key -> claude_code_oauth_token in both Claude steps,
  reading CLAUDE_CODE_OAUTH_TOKEN from the CR_PAT GitHub Environment. Both
  jobs now declare `environment: CR_PAT` so the environment-scoped secret
  is reachable.
- Rename weekly_ai_fix.yaml -> daily_ai_fix.yaml (matches this repo's
  daily_/weekly_ filename convention) and change its cron from
  "0 3 * * SUN" to "0 3 * * *".
- on_issues_ai_triage.yaml already triggered on issues.opened; no schedule
  change was needed there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:44:31 +02:00
alexbelgium
954f1db7eb chore: add two-tier AI issue triage (workflows, script, prompts)
Install tier-1 classifier (on_issues_ai_triage.yaml) and tier-2 fix sweep
(weekly_ai_fix.yaml) plus their helper script and prompts.

Pre-merge fixes from verification:
- Wait-for-ping sleep 150s -> 60s. on_issues_ping_submitter completes in
  6-11s of job time across the last 10 runs; 60s covers runner-queue skew
  with margin.
- Rule 0 rewritten to match the real ownership signal: ping_submitter posts
  a github-actions[bot] comment with a stable marker
  `<!-- addon-submitter-ping:<addon> -->`. Rule 0 now keys off that literal
  marker instead of fuzzy prose, and guards against @<user> == alexbelgium.
- Silence one intentional shellcheck SC2016 (literal Markdown backticks) so
  actionlint runs clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 09:59:54 +02:00
6 changed files with 675 additions and 0 deletions

102
.github/prompts/issue-classify.md vendored Normal file
View File

@@ -0,0 +1,102 @@
# Issue classifier — tier 1
You are triaging a new issue on `alexbelgium/hassio-addons`, a monorepo of
100+ Home Assistant add-ons. Each add-on is a thin wrapper (Dockerfile,
`run.sh`, s6 services, nginx config, `config.yaml`) around an upstream
application that Alex does not maintain.
Your entire output is one JSON object written to `/tmp/ai-triage/verdict.json`.
You do not comment, label, or edit anything.
## Rule 0 — ownership short-circuit
Read the existing comments in the context bundle first. The
`on_issues_ping_submitter` workflow signals ownership by posting a **comment**
(authored by `github-actions[bot]`) that pings the add-on's original submitter.
Its exact, machine-stable format is:
```
<!-- addon-submitter-ping:<addon> -->
Heads up @<user>: this issue appears to mention `<addon>`.
```
Match it on the literal marker `<!-- addon-submitter-ping:` — that string is
the reliable signal; do not infer ownership from prose. The bundle renders
each comment under a `### @<login>` heading — the marker only counts when that
heading reads `### @github-actions[bot]`. A marker pasted inside the issue
body, or inside a comment from any other login, is not the workflow's signal
and must be ignored. If a comment satisfying both conditions is present **and**
the pinged `@<user>` is not `alexbelgium`, stop immediately and emit:
```json
{"verdict": "owned", "confidence": "high"}
```
Do not spend turns on anything else. (The workflow only ever pings a mapped
submitter, so in practice `@<user>` is always someone other than `alexbelgium`;
the check is a guard, not a common case.)
## Rule 1 — pick exactly one verdict
| verdict | when |
|---|---|
| `duplicate` | An existing open or closed issue reports the same thing. Set `duplicate_of`. |
| `needs-info` | You cannot tell what is wrong without the add-on version, HA version, architecture, config, or the actual log output. |
| `question` | A usage question answerable from `DOCS.md`, the wiki, or the add-on config. Not a defect. |
| `upstream-bug` | The fault is in the upstream application or its image, not in this repo's wrapper. |
| `addon-bug` | The fault is in something this repo owns: the Dockerfile, `run.sh`, s6 service files, nginx config, `config.yaml` schema, or an option that is not being passed through. |
| `feature-request` | New capability, new add-on, new option. |
**The `upstream-bug` / `addon-bug` split is the one that matters.** Only
`addon-bug` triggers the expensive fix pass. Getting it wrong means the bot
opens a pull request against code that does not exist in this repository.
Test it explicitly: name the file in this repo you would have to change. If you
cannot name one, it is not `addon-bug`.
## Rule 2 — confidence is a real signal
Set `confidence` to `low` whenever any of these hold:
- The add-on could not be resolved from the title (`UNRESOLVED` in the bundle).
- The issue mixes several unrelated problems.
- You are choosing between `upstream-bug` and `addon-bug` and could argue both.
- The report is in a language you are not confident reading.
`low` confidence suppresses the comment entirely and flags a human instead.
Prefer that over a fluent guess. A wrong answer on a support issue costs Alex
more trust than no answer.
## Rule 3 — writing the comment
Only `duplicate`, `needs-info`, and `question` get a comment. The other verdicts
are labelled silently and handled later.
- **duplicate** — one line, link the other issue, no explanation.
- **needs-info** — ask only for what is *strictly* required to proceed, as a
short checklist. Never more than four items. Say where to find each one
(e.g. the add-on log tab, the Configuration tab). Do not ask for anything
already present in the issue body.
- **question** — answer only from files in the context bundle, and quote the
file path you took it from. If the bundle does not contain the answer, this
is `needs-info`, not `question`. Never invent option names.
Never close an issue. Never promise a timeline. Never say a fix is coming.
## Output schema
```json
{
"verdict": "owned|duplicate|needs-info|question|upstream-bug|addon-bug|feature-request",
"addon": "birdnet-go",
"confidence": "high|medium|low",
"duplicate_of": 1234,
"labels": ["bug"],
"root_cause_hint": "one sentence for the tier-2 pass, or empty",
"comment": "markdown, or empty string"
}
```
`labels` should contain at most two, from the repo's existing set. Do not
invent new label names; the workflow adds `ai-triage` and `ai:classified`
on its own.

92
.github/prompts/issue-fix.md vendored Normal file
View File

@@ -0,0 +1,92 @@
# Issue fix sweep — tier 2
You are working through a batch of confirmed add-on bugs on
`alexbelgium/hassio-addons`. Each add-on is a thin wrapper around an upstream
application. You own the wrapper. You do not own the upstream app.
Read `/tmp/ai-fix/batch.json`. Work add-on by add-on, not issue by issue —
grouping is the point of the batch.
## Hard limits
These are not guidelines. A workflow step enforces them after you finish, and
anything that violates them gets blocked and flagged.
1. **Never modify `.github/` or `.templates/`.** Those are inherited by every
add-on in the repo. A change there is a 100-add-on incident, not a fix.
2. **Never touch the `version` or `upstream` fields in `config.yaml`.** The
`addons_updater` job owns those. Editing them causes merge conflicts you
will not be around to resolve.
3. **One add-on per branch, one branch per pull request.** Branch name
`ai-fix/<addon>-<issue-number>`.
4. **Draft pull requests only.** Never merge, never mark ready for review,
never close an issue.
5. If the fix requires changing more than roughly 60 lines, or touching more
than three files, stop. Post the analysis, open no pull request, and say
plainly that the change is too large for an unattended fix.
6. **Relabel every issue before moving to the next one.** This sweep runs
daily over the same `ai-triage`-labelled backlog. An issue you have
already finished with must drop that label immediately — otherwise
tomorrow's sweep re-selects it, opens a second competing branch, and
burns another full read-the-source-and-fix pass on work that is already
done. Do this as your last action on the issue, right after commenting,
not batched at the end: `gh issue edit <n> --remove-label ai-triage
--add-label <replacement>`, where `<replacement>` is exactly one of:
- `ai:fixed` — you opened a draft pull request for it.
- `ai:upstream` — you reversed tier 1's call and the fault is upstream,
so no pull request.
- `ai:needs-human` — too large for an unattended fix (rule 5), or you
found no fix and are reporting what you ruled out.
A workflow step checks this after you finish and force-corrects to
`ai:needs-human` for anything still carrying `ai-triage` — treat that as
a bug in your run, not a safety net to lean on.
## Per add-on, do this in order
**1. Read before you write.** The add-on's `CLAUDE.md` if it has one, then
`DOCS.md`, `config.yaml`, `Dockerfile`, and everything under `rootfs/`. Read
`CHANGELOG.md` and `git log` for the last few weeks — a bug that appeared
suddenly usually has a commit behind it, and finding that commit is worth more
than reading the whole tree.
**2. Establish the root cause, and be honest about confidence.** Name the exact
file and line. If you cannot, you have a hypothesis, not a root cause, and you
must label it as such in the comment. Do not dress a guess up as a diagnosis.
Alex has to trust these comments without re-deriving them.
**3. Re-check the upstream/wrapper split.** Tier 1 already made this call, but
it made it cheaply and without reading the source. If the real fault is
upstream, say so, do not open a pull request, and suggest what to file with the
upstream project instead. Reversing tier 1's classification is a correct and
valuable outcome, not a failure.
**4. Fix it.** Match the surrounding style — this repo is bash and Dockerfiles,
and the conventions vary between add-ons. Run `shellcheck` on any shell you
change. Add a `CHANGELOG.md` entry in the add-on's existing format.
**5. Open the draft pull request.** Body must contain: the root cause with file
and line, what the change does, how you verified it (or an explicit statement
that you could not verify it), and `Closes #<n>`.
**6. Comment on the issue, then relabel it (hard limit 6).** Root cause, the
fix in one or two sentences, and the pull request link. Plain language — the
reader is a Home Assistant user, not a Go developer. If you found no fix, say
what you ruled out and what you would need to go further. Close with a note
that this is automated analysis pending Alex's review. Then remove
`ai-triage` and add the one replacement label that matches the outcome,
before starting the next issue.
## Meta-findings
This is the part a per-issue run cannot do, so do not skip it.
After the batch, look across everything you read. If several issues share a
cause — one base image bump, one s6 change, one upstream release, one bad
option default replicated across add-ons — open a single issue titled
`[meta] <pattern>` describing it, linking the affected issues, and proposing
the systemic fix rather than the individual patches.
Report honestly if the batch produced nothing. A sweep that fixes zero issues
and says so clearly is more useful than one that manufactures three plausible
patches. You will be judged on whether Alex can trust the output without
checking it, not on how many pull requests you opened.

121
.github/scripts/ai_triage_context.sh vendored Executable file
View File

@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# Destination: .github/scripts/ai_triage_context.sh
#
# Builds /tmp/ai-triage/context.md so Claude does not have to explore a
# 100-addon, 34k-commit monorepo to answer one question. Everything the
# model needs is assembled here by cheap shell instead of by expensive turns.
#
# Env: GH_TOKEN, ISSUE_NUMBER, REPO
set -euo pipefail
OUT=/tmp/ai-triage
mkdir -p "$OUT"
CTX="$OUT/context.md"
: > "$CTX"
gh issue view "$ISSUE_NUMBER" --repo "$REPO" \
--json number,title,body,author,labels,createdAt,comments > "$OUT/issue.json"
TITLE=$(jq -r '.title' "$OUT/issue.json")
# ---------------------------------------------------------------- addon slug
# Titles follow "🐛 [Immich Frame] ENV_VARS arent being picked up".
RAW=$(sed -n 's/.*\[\([^]]*\)\].*/\1/p' <<<"$TITLE" | head -n1)
ADDON=""
if [ -n "$RAW" ]; then
CAND=$(tr '[:upper:] ' '[:lower:]_' <<<"$RAW")
# Directory list without checking out any of them.
git ls-tree -d --name-only HEAD > "$OUT/dirs.txt"
for guess in "$CAND" "${CAND//_/-}" "${CAND//_/.}"; do
if grep -qxF "$guess" "$OUT/dirs.txt"; then ADDON="$guess"; break; fi
done
# Separator-insensitive exact match: a title like "[Calibre-web]" (hyphen)
# against a directory named calibre_web (underscore) matches neither exact
# guess above, and would otherwise fall through to the substring fallback
# below, which picks the shorter "calibre" instead — the wrong add-on.
# Stripping -, _, . from both sides before comparing catches this case.
if [ -z "$ADDON" ]; then
CAND_STRIPPED=$(tr -d '_.-' <<<"$CAND")
while IFS= read -r dir; do
if [ "$(tr -d '_.-' <<<"$dir")" = "$CAND_STRIPPED" ]; then ADDON="$dir"; break; fi
done < "$OUT/dirs.txt"
fi
# Last resort: longest directory name contained in the candidate.
if [ -z "$ADDON" ]; then
ADDON=$(awk -v c="$CAND" 'length($0)>2 && index(c,$0){print length($0)"\t"$0}' \
"$OUT/dirs.txt" | sort -rn | head -n1 | cut -f2)
fi
fi
{
echo "# Issue #${ISSUE_NUMBER}"
echo
echo "Repo: ${REPO}"
echo "Addon resolved from title: ${ADDON:-UNRESOLVED}"
echo
echo "## Title"
echo "$TITLE"
echo
echo "## Author"
jq -r '.author.login' "$OUT/issue.json"
echo
echo "## Body"
echo '```'
jq -r '.body // "(empty)"' "$OUT/issue.json"
echo '```'
echo
echo "## Existing comments (in order)"
jq -r '.comments[]? | "### @\(.author.login)\n\(.body)\n"' "$OUT/issue.json"
echo
echo "## Existing labels"
jq -r '[.labels[]?.name] | join(", ")' "$OUT/issue.json"
} >> "$CTX"
# ------------------------------------------------------------- addon sources
if [ -n "$ADDON" ]; then
{
echo
echo "## Addon files: ${ADDON}/"
if ! git sparse-checkout set --no-cone .github/prompts .github/scripts "$ADDON" 2>&1; then
# Swallowing this used to leave ADDON resolved with no files behind it,
# so the classifier could still reach high confidence off the addon
# name alone. Say so explicitly, in the same word Rule 2 already keys
# its low-confidence check on.
echo
echo "**Could not check out this add-on's source. Treat as UNRESOLVED for confidence purposes.**"
else
for f in config.yaml config.json Dockerfile CHANGELOG.md DOCS.md README.md; do
[ -f "$ADDON/$f" ] || continue
echo
echo "### ${ADDON}/${f}"
echo '```'
head -c 8000 "$ADDON/$f"
echo '```'
done
echo
echo "## Recent commits touching ${ADDON}/"
git log -n 15 --date=short --pretty='- %ad %h %s' -- "$ADDON" 2>/dev/null || true
fi
} >> "$CTX"
fi
# -------------------------------------------------------- possible duplicates
{
echo
echo "## Similar existing issues (candidate duplicates)"
KEYWORDS=$(tr -cs '[:alnum:]' ' ' <<<"$TITLE" \
| tr '[:upper:]' '[:lower:]' \
| tr ' ' '\n' | awk 'length($0)>3' | head -n6 | paste -sd' ')
# Excludes the issue being triaged: if it's already indexed by GitHub search
# by the time this runs, keyword overlap with its own title would otherwise
# list it as a "candidate duplicate" of itself.
gh search issues --repo "$REPO" --limit 15 \
--json number,title,state,url -- "$KEYWORDS" 2>/dev/null \
| jq -r --argjson self "$ISSUE_NUMBER" \
'.[] | select(.number != $self) | "- #\(.number) [\(.state)] \(.title)"' \
|| echo "(search unavailable)"
} >> "$CTX"
echo "context bundle: $(wc -c < "$CTX") bytes, addon=${ADDON:-none}"

175
.github/workflows/daily_ai_fix.yaml vendored Normal file
View File

@@ -0,0 +1,175 @@
---
# Destination: .github/workflows/daily_ai_fix.yaml
#
# Tier 2. One Opus 4.8 run at xhigh over the whole batch of `ai-triage` issues,
# grouped by add-on, so it can spot the cross-issue patterns a per-issue run
# never sees ("these four reports are all the same base image bump"). Runs
# daily rather than weekly, so batches (default limit 8) stay small and any
# one day's failure doesn't block a week's worth of issues.
#
# Auth:
# Claude Pro/Max subscription via the CR_PAT GitHub Environment, which
# holds the CLAUDE_CODE_OAUTH_TOKEN secret (generate with `claude setup-token`).
# AI_PR_TOKEN — GitHub App token or PAT (repo scope). NOT GITHUB_TOKEN:
# pull requests created with GITHUB_TOKEN do not trigger
# other workflows, so your PR Check Build would never run.
name: AI fix sweep
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
inputs:
issue:
description: "Single issue number (blank = all ai-triage issues)"
required: false
limit:
description: "Max issues in the batch"
required: false
default: "8"
permissions:
contents: write
issues: write
pull-requests: write
id-token: write # claude-code-action fetches a GitHub OIDC token to auth the OAuth flow
concurrency:
group: ai-fix-sweep
cancel-in-progress: false
jobs:
sweep:
runs-on: ubuntu-latest
timeout-minutes: 180
environment: CR_PAT
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
token: ${{ secrets.AI_PR_TOKEN }}
- name: Collect batch
id: batch
env:
GH_TOKEN: ${{ secrets.AI_PR_TOKEN }}
REPO: ${{ github.repository }}
# workflow_dispatch inputs land here instead of being interpolated
# directly into the script below — expanding "${{ }}" inline would
# splice attacker/typo-controlled text into the shell source itself
# rather than passing it as data.
ISSUE_INPUT: ${{ inputs.issue }}
LIMIT_INPUT: ${{ inputs.limit || '8' }}
run: |
set -euo pipefail
mkdir -p /tmp/ai-fix
[[ "$LIMIT_INPUT" =~ ^[1-9][0-9]*$ ]] || { echo "::error::limit must be a positive integer, got '$LIMIT_INPUT'"; exit 1; }
if [ -n "$ISSUE_INPUT" ]; then
[[ "$ISSUE_INPUT" =~ ^[0-9]+$ ]] || { echo "::error::issue must be a number, got '$ISSUE_INPUT'"; exit 1; }
gh issue view "$ISSUE_INPUT" --repo "$REPO" \
--json number,title,body,labels,comments \
| jq '[.]' > /tmp/ai-fix/batch.json
else
gh issue list --repo "$REPO" --state open \
--label ai-triage --limit "$LIMIT_INPUT" \
--json number,title,body,labels,comments > /tmp/ai-fix/batch.json
fi
N=$(jq 'length' /tmp/ai-fix/batch.json)
echo "count=$N" >> "$GITHUB_OUTPUT"
echo "batch size: $N"
- name: Configure git
if: steps.batch.outputs.count != '0'
run: |
git config user.name "claude-ai-fix[bot]"
git config user.email "claude-ai-fix[bot]@users.noreply.github.com"
# Created up front so issue-fix.md's per-issue relabel never has to
# improvise a color or retry a "label does not exist" error — that's a
# wasted turn multiplied by every issue in the batch. Also covers
# ai:blocked, which the forbidden-paths guard applies later in this
# same job: with set -euo pipefail, `gh pr edit --add-label` on a
# label that doesn't exist yet fails and aborts that step's loop
# entirely, silently skipping every remaining PR behind it.
- name: Ensure control labels exist
if: steps.batch.outputs.count != '0'
env:
GH_TOKEN: ${{ secrets.AI_PR_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
for l in ai:fixed ai:upstream ai:needs-human ai:blocked; do
gh label create "$l" --repo "$REPO" --color ededed --force >/dev/null 2>&1 || true
done
- name: Analyse and fix
if: steps.batch.outputs.count != '0'
uses: anthropics/claude-code-action@44423bdec74b97d67543eb16c110546762c110b2 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
The batch of issues to work through is /tmp/ai-fix/batch.json.
Follow .github/prompts/issue-fix.md exactly. Do not deviate from
the path restrictions in that file under any circumstances.
claude_args: |
--model claude-opus-4-8
--effort xhigh
--max-turns 300
--allowedTools "Read,Write,Edit,Glob,Grep,Bash(git:*),Bash(gh:*),Bash(shellcheck:*),Bash(yamllint:*),Bash(docker build:*)"
env:
GH_TOKEN: ${{ secrets.AI_PR_TOKEN }}
# Belt and braces. issue-fix.md instructs Claude to drop the ai-triage
# label off every issue it finishes with (hard limit 6), so tomorrow's
# sweep never re-selects and re-spends a full read-and-fix pass on
# work that's already done. Enforce it here in case a turn or timeout
# budget runs out before the relabel step of the last issue or two.
- name: Guard against repeat processing
if: always() && steps.batch.outputs.count != '0'
env:
GH_TOKEN: ${{ secrets.AI_PR_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
mapfile -t ISSUES < <(jq -r '.[].number' /tmp/ai-fix/batch.json)
for n in "${ISSUES[@]}"; do
STILL=$(gh issue view "$n" --repo "$REPO" --json labels \
--jq '[.labels[].name] | index("ai-triage") != null' 2>/dev/null) || {
echo "::warning::could not re-check issue #$n (deleted or transferred?), skipping"
continue
}
if [ "$STILL" = "true" ]; then
echo "::warning::issue #$n still carries ai-triage after the sweep, forcing it out of tomorrow's batch"
gh issue edit "$n" --repo "$REPO" \
--remove-label ai-triage --add-label ai:needs-human
fi
done
# Belt and braces. The prompt forbids these paths; this enforces it.
- name: Guard forbidden paths
if: always() && steps.batch.outputs.count != '0'
env:
GH_TOKEN: ${{ secrets.AI_PR_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# gh pr list applies --limit before the headRefName filter below, so
# a low cap could silently drop older ai-fix/ PRs from the check
# once total open PRs (of any kind) grow past it. 300 is far above
# anything this repo runs today; gh paginates to satisfy it.
PRS=$(gh pr list --repo "$REPO" --state open --limit 300 \
--json number,headRefName \
--jq '.[] | select(.headRefName|startswith("ai-fix/")) | .number')
for pr in $PRS; do
BAD=$(gh pr diff "$pr" --repo "$REPO" --name-only \
| grep -E '^(\.github/|\.templates/)' || true)
if [ -n "$BAD" ]; then
echo "::error::PR #$pr touches protected paths:"; echo "$BAD"
gh pr ready "$pr" --repo "$REPO" --undo || true
gh pr edit "$pr" --repo "$REPO" --add-label "ai:blocked"
gh pr comment "$pr" --repo "$REPO" --body \
"Blocked automatically: this PR modifies shared infrastructure (\`.github/\` or \`.templates/\`), which is inherited by every add-on in the repo. Needs manual review before it goes anywhere."
fi
done

View File

@@ -0,0 +1,184 @@
---
# Destination: .github/workflows/on_issues_ai_triage.yaml
#
# Tier 1. Fires on every new issue, costs cents, finishes in ~2 minutes.
# 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.
#
# 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]
workflow_dispatch:
inputs:
issue:
description: "Issue number to (re-)triage manually"
required: true
permissions:
contents: read
issues: write
id-token: write # claude-code-action fetches a GitHub OIDC token to auth the OAuth flow
concurrency:
group: ai-triage-${{ github.event.issue.number || inputs.issue }}
cancel-in-progress: false
env:
MAINTAINER: alexbelgium
jobs:
classify:
# Manual dispatch is a deliberate override: skip the auto-trigger guards
# (don't self-triage the maintainer's own issues; honour the no-ai
# opt-out) that only make sense for the fire-on-every-open path.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.issue.user.login != 'alexbelgium' &&
!contains(github.event.issue.labels.*.name, 'no-ai'))
runs-on: ubuntu-latest
timeout-minutes: 15
environment: CR_PAT
steps:
# 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
- name: Checkout tooling
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 1
persist-credentials: false
sparse-checkout: |
.github/prompts
.github/scripts
sparse-checkout-cone-mode: false
- name: Build context bundle
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
continue-on-error: true
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
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
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE: ${{ github.event.issue.number || inputs.issue }}
REPO: ${{ github.repository }}
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"
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 \
>/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
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; 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

View File

@@ -1,3 +1,4 @@
## &#9888; Open Request : [✨ [REQUEST] qbittorrent (opened 2025-10-11)](https://github.com/alexbelgium/hassio-addons/issues/2139) by [@nyok92](https://github.com/nyok92)
## &#9888; Open Request : [✨ [REQUEST] transmission, document where torrent files are stored (opened 2026-07-14)](https://github.com/alexbelgium/hassio-addons/issues/2852) by [@bilogic](https://github.com/bilogic)
# Hass.io Add-ons: Tor with bridges