diff --git a/.github/prompts/issue-execute-plan.md b/.github/prompts/issue-execute-plan.md new file mode 100644 index 0000000000..6c97d385a8 --- /dev/null +++ b/.github/prompts/issue-execute-plan.md @@ -0,0 +1,57 @@ +# Execute an approved plan — tier 3 + +@alexbelgium reviewed an AI-written plan and approved it. Your job is to carry +that plan out and open a pull request. The plan was already accepted, so do not +re-litigate it — execute it. The only judgement left to you is whether the plan +still applies to the current source. + +Read: + +- `/tmp/ai-exec/plan.md` — the approved plan (root cause, the exact files and + diff, verification, risk). This is your spec. +- `/tmp/ai-exec/issue.json` — the issue it fixes (for `Closes #` and context). + +## Hard limits (identical to the fix sweep — a workflow step enforces them) + +1. **Never modify `.github/` or `.templates/`.** Repo-wide infrastructure. +2. **Never touch the `version` or `upstream` fields in `config.yaml`.** +3. **One add-on, one branch:** `ai-fix/-`. +4. **Never merge, never close the issue, never enable auto-merge.** Open the + pull request **ready for review** — CI (`onpr_check-pr.yaml`) validates it, + a human ships it. + +## Do this in order + +1. **Apply the plan.** Make exactly the edits it describes. Match surrounding + style (bash / Dockerfile; conventions vary per add-on). Run `shellcheck` on + any shell you change. Add a `CHANGELOG.md` entry in the add-on's format. + +2. **If the plan is stale** — the source moved since it was written and the diff + no longer applies cleanly: + - Small drift (a line shifted, a nearby rename): adapt minimally to achieve + the plan's stated intent, and note the deviation in the PR body. + - Large drift (the root cause or the target code is gone or now different): + stop. Do **not** guess a new fix. Comment on the issue explaining why the + plan no longer applies, relabel `ai:needs-human` (see step 5), open no PR. + +3. **Open the pull request, ready for review.** Body: the root cause with file + and line, what the change does, how you verified it (or an explicit statement + that you could not), any deviation from the plan, and `Closes #`. Note that + it was executed from an approved plan. + +4. **Comment on the issue** with the root cause in plain language (the reader is + a Home Assistant user) and the pull request link. Close with a note that this + is automated analysis pending Alex's review. + +5. **Relabel, as your last action:** + ``` + gh issue edit --remove-label ai:approved --remove-label ai:plan-pending --add-label + ``` + `` is `ai:fixed` if you opened a PR, or `ai:needs-human` if the plan + was too stale to apply (step 2). A workflow step also strips the approval + labels afterwards and flags `ai:needs-human` if no PR resulted — treat that + as a bug in your run, not a safety net. + +Keep the change within the spirit of the approved plan. If carrying it out +honestly requires substantially more than the plan described, that is a sign the +plan was wrong — stop and relabel `ai:needs-human` rather than expanding scope. diff --git a/.github/prompts/issue-fix.md b/.github/prompts/issue-fix.md index fb16793984..392417f7c2 100644 --- a/.github/prompts/issue-fix.md +++ b/.github/prompts/issue-fix.md @@ -7,6 +7,10 @@ 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. +You are the Opus step of the pipeline: the diagnosis and, when a fix is not a +sure thing, the written plan. Getting the diagnosis right and being honest +about confidence matters more than the number of pull requests you open. + ## Hard limits These are not guidelines. A workflow step enforces them after you finish, and @@ -19,27 +23,18 @@ anything that violates them gets blocked and flagged. will not be around to resolve. 3. **One add-on per branch, one branch per pull request.** Branch name `ai-fix/-`. -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 --remove-label ai-triage - --add-label `, where `` 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. +4. **Never merge, never close an issue, never enable auto-merge.** Opening a + pull request for review is as far as you go — a human ships it. CI + (`onpr_check-pr.yaml`: lint + Docker build) runs on every pull request you + open and is what actually validates the change. +5. **The small-fix ceiling is ~60 changed lines / 3 files.** A change under it, + *and* with a root cause you are confident of, may go out as a ready pull + request (Outcome A). Anything over it does not become a pull request — it + becomes a plan (Outcome B), however confident you are. +6. **Relabel every issue before moving to the next one** (see "Relabel", below). + This sweep runs daily over the same `ai-triage` backlog; an issue you have + finished with must drop that label immediately or tomorrow's sweep re-selects + it and burns another full pass on work already done. ## Per add-on, do this in order @@ -50,41 +45,101 @@ 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. +file and line. If you cannot, you have a hypothesis, not a root cause. Do not +dress a guess up as a diagnosis — Alex has to trust these without re-deriving +them. Your confidence in the root cause is what selects the outcome below. -**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. +**3. Re-check the upstream/wrapper split.** Tier 1 made this call cheaply, +without reading the source. If the real fault is upstream, say so, open no pull +request, and suggest what to file upstream instead. Reversing tier 1 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. +## Decide the outcome -**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 #`. +Pick exactly one per issue. When you are between two, pick the more cautious +(A→B→D): a plan a human approves in one click costs far less trust than a wrong +pull request. -**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. +### Outcome A — ready pull request → `ai:fixed` + +**Only when both hold:** you named the root cause to an exact file and line and +are genuinely confident of it, **and** the fix is within the small-fix ceiling +(rule 5). + +- Fix it. Match the surrounding style — this repo is bash and Dockerfiles, and + conventions vary between add-ons. Run `shellcheck` on any shell you change. + Add a `CHANGELOG.md` entry in the add-on's existing format. +- Open the pull request **ready for review** (not draft). Body: root cause with + file and line, what the change does, how you verified it (or an explicit + statement that you could not), and `Closes #`. + +### Outcome B — plan for approval → `ai:plan-pending` + +**When you have a real diagnosis but** either your confidence is only moderate, +**or** the change is larger than the small-fix ceiling. Do **not** open a pull +request and do **not** commit anything. + +Post one comment that begins with this exact marker on its own first line: + +``` + +``` + +followed by a complete, executable plan: + +- **Root cause** — the exact file and line, and why. +- **The change** — every file to edit and a diff sketch (before/after or a + fenced patch) precise enough that executing it needs no re-investigation. +- **Verification** — how a run should confirm the fix (build, shellcheck, the + behaviour to check). +- **Risk / why not automatic** — one line on what makes this uncertain or large. + +End the comment with exactly: + +> Add the `ai:approved` label to have this plan executed automatically, or reply +> with changes first. This is automated analysis pending @alexbelgium's review. + +(Applying `ai:approved` triggers tier 3, `on_issue_approved.yaml`, which opens a +ready pull request from this plan on Opus. Nothing runs until Alex approves.) + +### Outcome C — upstream → `ai:upstream` + +The fault is in the upstream app or its base image, not the wrapper. Comment +the diagnosis and what to file upstream. No pull request, no plan. + +### Outcome D — needs a human → `ai:needs-human` + +You could not establish a root cause, or the issue is out of scope for an +unattended fix. Comment what you ruled out and what you would need to go +further. No pull request, no plan. + +## Comment, then relabel (do this before the next issue) + +Every issue gets a comment: root cause, and for A the fix in a sentence or two +plus the pull request link; for B the plan above; for C/D the analysis. Plain +language — the reader is a Home Assistant user, not a Go developer. Close A/C/D +comments with a note that this is automated analysis pending Alex's review. + +Then, as your **last action on the issue**, relabel it: + +``` +gh issue edit --remove-label ai-triage --add-label +``` + +where `` is exactly one of `ai:fixed`, `ai:plan-pending`, +`ai:upstream`, `ai:needs-human`, matching the outcome. A workflow step checks +this afterwards and force-corrects anything still carrying `ai-triage` to +`ai:needs-human` — treat that as a bug in your run, not a safety net. ## 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] ` describing it, linking the affected issues, and proposing -the systemic fix rather than the individual patches. +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] ` +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 diff --git a/.github/prompts/pr-coderabbit.md b/.github/prompts/pr-coderabbit.md new file mode 100644 index 0000000000..587abffbcf --- /dev/null +++ b/.github/prompts/pr-coderabbit.md @@ -0,0 +1,49 @@ +# Address CodeRabbit's review — one-shot + +CodeRabbit reviewed an AI-authored pull request. You are checked out on that +PR's branch (`ai-fix/-`). Your job, in a **single pass**: read +CodeRabbit's feedback and either fix each actionable point or reply saying why +it doesn't apply. This runs once — CodeRabbit re-reviewing your pushed fix will +not trigger you again. + +The PR number is in your prompt. Set `PR` to it. + +## Gather the feedback + +- Review summaries and verdicts: + `gh pr view "$PR" --json reviews,title,body,files` +- Inline (line-level) comments — this is where CodeRabbit's concrete suggestions + live: + `gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR}/comments" --paginate` + Each has `path`, `line`, `body`, and an `id` (needed to reply). + +Consider only comments authored by `coderabbitai[bot]`. Ignore its collapsed +"nitpick"/"outside diff" noise unless the point is real. + +## Hard limits (a workflow step enforces the first) + +1. **Never modify `.github/` or `.templates/`.** Repo-wide infrastructure. +2. **Never touch the `version` or `upstream` fields in `config.yaml`.** +3. **Stay within this PR's scope and branch.** Do not open a new PR, do not + touch other add-ons, do not merge, do not mark ready/draft. + +## For each actionable comment + +- **Fix it** when it's a real correctness, safety, or clarity improvement within + scope: make the minimal edit, run `shellcheck` on any shell you change, and if + behaviour changed update the add-on's `CHANGELOG.md` entry. +- **Decline it** when it's wrong, out of scope, or a style nit that fights the + add-on's conventions: reply to that specific comment with one sentence of + reasoning: + `gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR}/comments//replies" -f body='...'` + +## Finish + +- If you changed anything: stage, commit with a short message + (`fix: address CodeRabbit review`), and push to the PR branch + (`git push origin HEAD`). +- Post one summary comment on the PR (`gh pr comment "$PR" --body '...'`) listing + what you fixed and what you deliberately left, in plain language. End with a + note that this is automated and pending @alexbelgium's review. +- If nothing was actionable, post a one-line comment saying so and stop. Do not + invent changes to look busy. diff --git a/.github/scripts/ai_guard_paths.sh b/.github/scripts/ai_guard_paths.sh new file mode 100755 index 0000000000..6e37523c82 --- /dev/null +++ b/.github/scripts/ai_guard_paths.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Destination: .github/scripts/ai_guard_paths.sh +# +# Belt-and-braces enforcement of the one rule every AI fix prompt is told +# never to break: no add-on fix may touch `.github/` or `.templates/`, because +# those are inherited by all 100+ add-ons — a change there is a repo-wide +# incident, not a per-add-on fix. The prompts forbid it; this script is what +# actually enforces it after the model finishes, on the PRs it opened. +# +# Any AI PR that touches a protected path is pulled out of review (converted +# back to draft), labelled `ai:blocked`, and commented on. This is shared by +# every tier that can open or push to an `ai-fix/` PR (tiers 2 and 3, and the +# CodeRabbit follow-up) so the rule is defined and fixed in exactly one place. +# +# Env: +# GH_TOKEN (required) — token with pull-requests:write on REPO +# REPO (required) — owner/name +# PR_NUMBER (optional) — check only this PR; if unset, scan every open +# `ai-fix/` PR in the repo. + +set -euo pipefail + +: "${REPO:?REPO must be set}" +: "${GH_TOKEN:?GH_TOKEN must be set}" + +PROTECTED='^(\.github/|\.templates/)' + +if [ -n "${PR_NUMBER:-}" ]; then + PRS="$PR_NUMBER" +else + # gh pr list applies --limit before the headRefName filter, so a low cap + # could silently drop older ai-fix/ PRs once total open PRs (of any kind) + # grow past it. 300 is far above anything this repo runs; gh paginates. + PRS=$(gh pr list --repo "$REPO" --state open --limit 300 \ + --json number,headRefName \ + --jq '.[] | select(.headRefName|startswith("ai-fix/")) | .number') +fi + +for pr in $PRS; do + [ -n "$pr" ] || continue + BAD=$(gh pr diff "$pr" --repo "$REPO" --name-only | grep -E "$PROTECTED" || true) + if [ -n "$BAD" ]; then + echo "::error::PR #$pr touches protected paths:" + echo "$BAD" + # Ensure the label exists before adding it — with set -e a missing + # label would abort the whole loop and skip any PRs behind this one. + gh label create "ai:blocked" --repo "$REPO" --color ededed >/dev/null 2>&1 || true + 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 diff --git a/.github/workflows/daily_ai_fix.yaml b/.github/workflows/daily_ai_fix.yaml index 057cfa06d3..e3199b6ce1 100644 --- a/.github/workflows/daily_ai_fix.yaml +++ b/.github/workflows/daily_ai_fix.yaml @@ -1,11 +1,28 @@ --- # 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. +# Tier 2 of the AI triage system. 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. +# +# This is the Opus step — root-cause diagnosis and, for anything not clearly a +# small confident fix, the written plan. Per issue it produces one of: +# * high certainty + small change -> a READY-for-review PR (ai:fixed) +# * medium certainty, or too large -> a full plan comment, no PR (ai:plan-pending) +# Alex then adds `ai:approved` and on_issue_approved.yaml (tier 3) executes +# the plan. This is the "create the full plan, ask for approval only when +# not sure" path — cheap to gate, immediate once approved. +# * fault is upstream / no fix -> analysis only (ai:upstream / ai:needs-human) +# +# Full tier map: +# Tier 1 on_issues_ai_triage.yaml Sonnet-low classify on issue open +# Tier 2 daily_ai_fix.yaml (this) Opus-xhigh daily fix/plan sweep +# Tier 3 on_issue_approved.yaml Opus-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 @@ -41,6 +58,9 @@ concurrency: jobs: sweep: + # Global kill switch: set repo variable AI_DISABLED=true to pause the sweep + # (and every other AI workflow) without editing any file. + if: ${{ vars.AI_DISABLED != 'true' }} runs-on: ubuntu-latest timeout-minutes: 180 environment: CR_PAT @@ -100,7 +120,7 @@ jobs: REPO: ${{ github.repository }} run: | set -euo pipefail - for l in ai:fixed ai:upstream ai:needs-human ai:blocked; do + for l in ai:fixed ai:plan-pending ai:approved ai:upstream ai:needs-human ai:blocked; do gh label create "$l" --repo "$REPO" --color ededed --force >/dev/null 2>&1 || true done @@ -109,6 +129,9 @@ jobs: uses: anthropics/claude-code-action@44423bdec74b97d67543eb16c110546762c110b2 # v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # One sticky, auto-updating status comment per run instead of the + # model narrating its own progress in scattered comments. + track_progress: true 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 @@ -147,29 +170,12 @@ jobs: fi done - # Belt and braces. The prompt forbids these paths; this enforces it. + # Belt and braces. The prompt forbids .github/ and .templates/; this + # enforces it over every open ai-fix/ PR (shared by tiers 2, 3 and the + # CodeRabbit follow-up, so the rule lives in exactly one place). - 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 + run: bash .github/scripts/ai_guard_paths.sh diff --git a/.github/workflows/daily_stale.yml b/.github/workflows/daily_stale.yml index 2803606701..6a316d8d39 100644 --- a/.github/workflows/daily_stale.yml +++ b/.github/workflows/daily_stale.yml @@ -29,5 +29,8 @@ jobs: stale-pr-label: 'stale' days-before-stale: '5' days-before-close: '2' - exempt-issue-labels: 'prevent stale' + # AI triage labels are exempt: an issue waiting on @alexbelgium's + # `ai:approved`, or queued for the tier-2 sweep, must not be auto-closed + # out from under the pipeline before it is acted on. + exempt-issue-labels: 'prevent stale,ai-triage,ai:plan-pending,ai:needs-info,ai:needs-human' remove-stale-when-updated: true diff --git a/.github/workflows/on_claude_mention.yml b/.github/workflows/on_claude_mention.yml new file mode 100644 index 0000000000..cbdcc7101a --- /dev/null +++ b/.github/workflows/on_claude_mention.yml @@ -0,0 +1,77 @@ +--- +# Destination: .github/workflows/on_claude_mention.yml +# +# Interactive @claude — the maintainer's manual override, always taking +# precedence over the automated tiers. Mention @claude in an issue, an issue +# comment, or a PR review/comment and Claude acts on that thread: answer a +# question, investigate, make a small change and open a PR. Runs on Sonnet-low +# to stay cheap; for anything large or uncertain it hands off to the ai:approved +# / tier-2 path rather than grinding. +# +# Hard-gated to @alexbelgium: only the maintainer's mentions trigger it. This is +# the official claude-code-action "tag mode" (no `prompt:` input). +# +# Auth: CR_PAT environment (CLAUDE_CODE_OAUTH_TOKEN) + AI_PR_TOKEN as github_token +# so any PR/push it makes triggers onpr_check-pr.yaml. +# Kill switch: repo variable AI_DISABLED=true pauses this (and every AI workflow). + +name: Claude interactive + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + pull_request_review: + types: [submitted] + issues: + types: [opened, assigned] + +jobs: + claude: + # Fire only on a @claude mention authored by the maintainer. The author + # field differs per event type, hence the four branches. + if: >- + vars.AI_DISABLED != 'true' && + ( + (github.event_name == 'issue_comment' && + contains(github.event.comment.body, '@claude') && + github.event.comment.user.login == 'alexbelgium') || + (github.event_name == 'pull_request_review_comment' && + contains(github.event.comment.body, '@claude') && + github.event.comment.user.login == 'alexbelgium') || + (github.event_name == 'pull_request_review' && + contains(github.event.review.body, '@claude') && + github.event.review.user.login == 'alexbelgium') || + (github.event_name == 'issues' && + (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) && + github.event.issue.user.login == 'alexbelgium') + ) + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: CR_PAT + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read # let Claude read CI results on PRs + + steps: + - name: Checkout repository + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + fetch-depth: 1 + + - name: Run Claude Code + uses: anthropics/claude-code-action@44423bdec74b97d67543eb16c110546762c110b2 # v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # AI_PR_TOKEN, not GITHUB_TOKEN, so a PR Claude opens triggers CI. + github_token: ${{ secrets.AI_PR_TOKEN }} + claude_args: | + --model claude-sonnet-5 + --effort low + --max-turns 30 + --allowedTools "Read,Write,Edit,Glob,Grep,Bash(git:*),Bash(gh:*),Bash(shellcheck:*),Bash(yamllint:*),Bash(docker build:*)" + --system-prompt "You are @claude on alexbelgium/hassio-addons, invoked manually by the maintainer, so you take precedence over the automated triage tiers. Each add-on is a thin wrapper around an upstream app. For a small, confident change: make the edit on a branch ai-fix/-, run shellcheck on any shell, add a CHANGELOG.md entry, and open a ready PR that Closes the issue. For anything large or uncertain: do NOT grind on it here on Sonnet — post your analysis and recommend applying the ai:approved label (if a tier-2 plan exists) or dispatching the AI fix sweep. Never modify .github/ or .templates/. Never touch the version or upstream fields in config.yaml. Never merge or enable auto-merge." diff --git a/.github/workflows/on_issue_approved.yaml b/.github/workflows/on_issue_approved.yaml new file mode 100644 index 0000000000..01a70cf089 --- /dev/null +++ b/.github/workflows/on_issue_approved.yaml @@ -0,0 +1,164 @@ +--- +# Destination: .github/workflows/on_issue_approved.yaml +# +# Tier 3 of the AI triage system — the approval executor. +# +# Tier 2 (daily_ai_fix.yaml) posts a full `` plan on any issue +# it is not confident enough to fix unattended, and labels it `ai:plan-pending`. +# When @alexbelgium reviews that plan and applies the `ai:approved` label, this +# workflow fires within a minute and executes the plan on Opus, opening a +# ready-for-review pull request — no waiting for the next daily sweep. +# +# Manual precedence: the label is the maintainer's explicit go-ahead, so this +# runs regardless of `no-ai`. Only @alexbelgium may approve; a label applied by +# anyone else is stripped and ignored (revoke job below). +# +# Auth: CR_PAT environment (CLAUDE_CODE_OAUTH_TOKEN) + AI_PR_TOKEN (so the PR +# triggers onpr_check-pr.yaml, which GITHUB_TOKEN-authored PRs would not). +# Kill switch: repo variable AI_DISABLED=true pauses this (and every AI workflow). + +name: AI approved-plan executor + +on: + issues: + types: [labeled] + workflow_dispatch: + inputs: + issue: + description: "Issue number whose approved plan to execute" + required: true + +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-approve-${{ github.event.issue.number || inputs.issue }} + cancel-in-progress: false + +jobs: + # A non-maintainer applied ai:approved: strip it and do nothing else. Only + # users with triage rights can label at all, so this is belt-and-braces. + revoke: + if: >- + github.event_name == 'issues' && + github.event.label.name == 'ai:approved' && + github.event.sender.login != 'alexbelgium' + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Remove unauthorised approval + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + ISSUE: ${{ github.event.issue.number }} + run: | + set -euo pipefail + gh issue edit "$ISSUE" --repo "$REPO" --remove-label ai:approved || true + gh issue comment "$ISSUE" --repo "$REPO" --body \ + "The \`ai:approved\` label only takes effect when applied by @alexbelgium; removing it." + + execute: + if: >- + vars.AI_DISABLED != 'true' && + ( github.event_name == 'workflow_dispatch' || + ( github.event.label.name == 'ai:approved' && + github.event.sender.login == 'alexbelgium' ) ) + runs-on: ubuntu-latest + timeout-minutes: 60 + environment: CR_PAT + + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + fetch-depth: 0 + token: ${{ secrets.AI_PR_TOKEN }} + + - name: Configure git + run: | + git config user.name "claude-ai-fix[bot]" + git config user.email "claude-ai-fix[bot]@users.noreply.github.com" + + - name: Ensure control labels exist + env: + GH_TOKEN: ${{ secrets.AI_PR_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + for l in ai:fixed ai:plan-pending ai:approved ai:needs-human ai:blocked; do + gh label create "$l" --repo "$REPO" --color ededed --force >/dev/null 2>&1 || true + done + + - name: Bundle the approved plan + id: bundle + env: + GH_TOKEN: ${{ secrets.AI_PR_TOKEN }} + REPO: ${{ github.repository }} + ISSUE: ${{ github.event.issue.number || inputs.issue }} + run: | + set -euo pipefail + mkdir -p /tmp/ai-exec + gh issue view "$ISSUE" --repo "$REPO" \ + --json number,title,body,author,labels,comments > /tmp/ai-exec/issue.json + # The plan is the most recent comment carrying the ai-plan marker. + jq -r '[.comments[] | select(.body | contains(""))] + | last | .body // ""' /tmp/ai-exec/issue.json > /tmp/ai-exec/plan.md + if [ ! -s /tmp/ai-exec/plan.md ]; then + echo "has_plan=false" >> "$GITHUB_OUTPUT" + echo "::warning::no comment on issue #$ISSUE; nothing to execute" + gh issue comment "$ISSUE" --repo "$REPO" --body \ + "No AI plan (\`\`) was found on this issue, so \`ai:approved\` has nothing to execute. Run the tier-2 sweep on it first (\`AI fix sweep\` → issue $ISSUE)." + else + echo "has_plan=true" >> "$GITHUB_OUTPUT" + echo "plan: $(wc -c < /tmp/ai-exec/plan.md) bytes" + fi + + - name: Execute the plan + if: steps.bundle.outputs.has_plan == 'true' + uses: anthropics/claude-code-action@44423bdec74b97d67543eb16c110546762c110b2 # v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + track_progress: true + prompt: | + The approved plan is /tmp/ai-exec/plan.md and the issue it belongs + to is /tmp/ai-exec/issue.json. Follow .github/prompts/issue-execute-plan.md + exactly. Do not deviate from the path restrictions under any + circumstances. + claude_args: | + --model claude-opus-4-8 + --effort high + --max-turns 200 + --allowedTools "Read,Write,Edit,Glob,Grep,Bash(git:*),Bash(gh:*),Bash(shellcheck:*),Bash(yamllint:*),Bash(docker build:*)" + env: + GH_TOKEN: ${{ secrets.AI_PR_TOKEN }} + + # Consume the approval no matter how the run ended, so a stuck label can + # never re-fire this workflow, and flag a human if nothing shipped. + - name: Guard against repeat processing + if: always() && steps.bundle.outputs.has_plan == 'true' + env: + GH_TOKEN: ${{ secrets.AI_PR_TOKEN }} + REPO: ${{ github.repository }} + ISSUE: ${{ github.event.issue.number || inputs.issue }} + run: | + set -euo pipefail + gh issue edit "$ISSUE" --repo "$REPO" \ + --remove-label ai:approved --remove-label ai:plan-pending >/dev/null 2>&1 || true + NOW=$(gh issue view "$ISSUE" --repo "$REPO" --json labels --jq '[.labels[].name]') + if ! echo "$NOW" | jq -e 'index("ai:fixed")!=null' >/dev/null \ + && ! echo "$NOW" | jq -e 'index("ai:upstream")!=null' >/dev/null; then + echo "::warning::issue #$ISSUE produced no PR/upstream outcome, flagging for a human" + gh issue edit "$ISSUE" --repo "$REPO" --add-label ai:needs-human >/dev/null 2>&1 || true + fi + + # The plan should never touch shared infra, but enforce it on the PR just + # as the tier-2 sweep does — same script, single source of truth. + - name: Guard forbidden paths + if: always() && steps.bundle.outputs.has_plan == 'true' + env: + GH_TOKEN: ${{ secrets.AI_PR_TOKEN }} + REPO: ${{ github.repository }} + run: bash .github/scripts/ai_guard_paths.sh diff --git a/.github/workflows/on_issues_ai_triage.yaml b/.github/workflows/on_issues_ai_triage.yaml index e68d919084..e3af1cbf10 100644 --- a/.github/workflows/on_issues_ai_triage.yaml +++ b/.github/workflows/on_issues_ai_triage.yaml @@ -1,9 +1,23 @@ --- # 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. +# 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-xhigh daily fix/plan sweep +# Tier 3 on_issue_approved.yaml Opus-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`). @@ -13,6 +27,11 @@ 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: @@ -25,7 +44,7 @@ permissions: 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 }} + group: ai-triage-${{ github.event.issue.number || inputs.issue || github.run_id }} cancel-in-progress: false env: @@ -33,18 +52,45 @@ env: 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. + # 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: >- - github.event_name == 'workflow_dispatch' || - (github.event.issue.user.login != 'alexbelgium' && - !contains(github.event.issue.labels.*.name, 'no-ai')) + 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: + # The reply that re-triggered this run consumes the needs-info flag, so a + # later comment on the same thread cannot re-run tier 1 again. apply-verdict + # re-adds it only if the issue still lacks the info (one more round). + - name: Consume needs-info flag + if: github.event_name == 'issue_comment' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE: ${{ github.event.issue.number }} + REPO: ${{ github.repository }} + run: gh issue edit "$ISSUE" --repo "$REPO" --remove-label ai:needs-info || true + # 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 @@ -76,7 +122,7 @@ jobs: - name: Classify id: classify continue-on-error: true - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@44423bdec74b97d67543eb16c110546762c110b2 # v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} show_full_output: true @@ -126,7 +172,8 @@ jobs: 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-triage --remove-label=ai:classified \ + --remove-label=ai:needs-human --remove-label=ai:needs-info \ >/dev/null 2>&1 || true exit 0 fi @@ -145,6 +192,14 @@ jobs: 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 @@ -166,7 +221,7 @@ jobs: declare -A FRESH=() for l in "${LABELS[@]}"; do FRESH["$l"]=1; done STALE=() - for l in ai-triage ai:classified ai:needs-human; do + 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 @@ -183,3 +238,44 @@ jobs: } > /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) + gh issue list --repo "$REPO" --state open --limit 100 \ + --json number,createdAt,author,labels > /tmp/catchup.json + # Untriaged = no ai:* / ai-triage / no-ai label, 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)) + | select([.labels[].name] + | any(. == "no-ai" or . == "ai-triage" or startswith("ai:")) | not) + | .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 diff --git a/.github/workflows/on_pr_coderabbit.yml b/.github/workflows/on_pr_coderabbit.yml new file mode 100644 index 0000000000..ded9c9c372 --- /dev/null +++ b/.github/workflows/on_pr_coderabbit.yml @@ -0,0 +1,106 @@ +--- +# Destination: .github/workflows/on_pr_coderabbit.yml +# +# One-shot CodeRabbit follow-up. CodeRabbit already reviews every PR, so instead +# of a second AI self-review, this reacts to CodeRabbit's review of an AI-opened +# PR (branch `ai-fix/*`): once, on Sonnet-low, Claude reads the review and either +# fixes each actionable comment (commit + push to the PR branch) or replies +# saying why it doesn't apply. +# +# Fires on the `pull_request_review` submitted event from coderabbitai[bot] — +# i.e. exactly when the review lands, which is inherently "after the PR is in +# review". Runs a single time per PR: the `ai:cr-addressed` label is claimed +# before any work, so CodeRabbit's re-review of the pushed fix does not loop. +# +# Auth: CR_PAT environment (CLAUDE_CODE_OAUTH_TOKEN) + AI_PR_TOKEN so pushes +# re-trigger CI. Kill switch: repo variable AI_DISABLED=true pauses it. + +name: AI CodeRabbit follow-up + +on: + pull_request_review: + types: [submitted] + +permissions: + contents: write + pull-requests: write + issues: write + id-token: write # claude-code-action fetches a GitHub OIDC token to auth the OAuth flow + +concurrency: + group: ai-coderabbit-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + address: + if: >- + vars.AI_DISABLED != 'true' && + github.event.review.user.login == 'coderabbitai[bot]' && + startsWith(github.event.pull_request.head.ref, 'ai-fix/') && + !contains(github.event.pull_request.labels.*.name, 'ai:cr-addressed') + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: CR_PAT + + steps: + # Claim the run against the LIVE label set, not the (possibly stale) event + # payload: concurrency serializes duplicate review events, so a queued + # second run sees the label the first one set and bails here. + - name: Claim once + id: claim + env: + GH_TOKEN: ${{ secrets.AI_PR_TOKEN }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '[.labels[].name]') + if echo "$LABELS" | jq -e 'index("ai:cr-addressed")!=null' >/dev/null; then + echo "PR #$PR already has ai:cr-addressed, skipping" + echo "go=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + gh label create "ai:cr-addressed" --repo "$REPO" --color ededed >/dev/null 2>&1 || true + gh pr edit "$PR" --repo "$REPO" --add-label "ai:cr-addressed" + echo "go=true" >> "$GITHUB_OUTPUT" + + - name: Checkout PR branch + if: steps.claim.outputs.go == 'true' + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + token: ${{ secrets.AI_PR_TOKEN }} + + - name: Configure git + if: steps.claim.outputs.go == 'true' + run: | + git config user.name "claude-ai-fix[bot]" + git config user.email "claude-ai-fix[bot]@users.noreply.github.com" + + - name: Address CodeRabbit comments + if: steps.claim.outputs.go == 'true' + uses: anthropics/claude-code-action@44423bdec74b97d67543eb16c110546762c110b2 # v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + prompt: | + CodeRabbit has reviewed pull request #${{ github.event.pull_request.number }} + on ${{ github.repository }}. You are on that PR's branch. Follow + .github/prompts/pr-coderabbit.md exactly. Do not deviate from the + path restrictions under any circumstances. + claude_args: | + --model claude-sonnet-5 + --effort low + --max-turns 40 + --allowedTools "Read,Edit,Write,Glob,Grep,Bash(git:*),Bash(gh:*),Bash(shellcheck:*)" + env: + GH_TOKEN: ${{ secrets.AI_PR_TOKEN }} + + # A CodeRabbit "fix" must not smuggle in a protected-path edit either. + - name: Guard forbidden paths + if: always() && steps.claim.outputs.go == 'true' + env: + GH_TOKEN: ${{ secrets.AI_PR_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: bash .github/scripts/ai_guard_paths.sh diff --git a/CLAUDE.md b/CLAUDE.md index f390e1629c..1dcbbc0133 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,6 +139,30 @@ Other automation workflows: Adding `[nobuild]` anywhere in a commit message skips the builder workflow. +### AI issue triage + +A tiered, Claude-powered pipeline triages and fixes add-on issues. It escalates +from cheap classification to a maintainer-approved automated fix, always leaving +manual actions with precedence. Prompts live in `.github/prompts/`, shared shell +in `.github/scripts/`. + +| Workflow | Model | Trigger | Role | +|---|---|---|---| +| `on_issues_ai_triage.yaml` | Sonnet-low | issue opened (+ author reply, daily catch-up) | Tier 1: classify, dedupe, answer, ask for info; label `ai-triage` for real add-on bugs | +| `daily_ai_fix.yaml` | Opus-xhigh | daily 03:00 | Tier 2: diagnose the `ai-triage` batch; small+confident → ready PR (`ai:fixed`); else write a plan (`ai:plan-pending`) | +| `on_issue_approved.yaml` | Opus-high | maintainer adds `ai:approved` | Tier 3: execute the approved plan → ready PR | +| `on_claude_mention.yml` | Sonnet-low | `@claude` by @alexbelgium | Manual interactive override on any issue/PR | +| `on_pr_coderabbit.yml` | Sonnet-low | CodeRabbit reviews an `ai-fix/*` PR | Once: fix or reply to review comments | + +Control labels (`ai:*`) are workflow-owned. Key ones: `ai-triage` (queued for +the sweep), `ai:plan-pending` (plan posted, awaiting `ai:approved`), `ai:fixed`, +`ai:upstream`, `ai:needs-info` (a reporter reply re-runs tier 1 once), +`ai:needs-human`, `ai:blocked` (touched protected paths). `no-ai` opts an issue +out of the automated tiers but not the manual ones. **Kill switch:** set the +repo variable `AI_DISABLED=true` to pause every AI workflow with no file edits. +AI fixes must never touch `.github/` or `.templates/` (enforced by +`ai_guard_paths.sh`) or the `version`/`upstream` fields in `config.yaml`. + ## Linting Rules | Tool | Config | Key ignores |