--- # Destination: .github/workflows/daily_ai_fix.yaml # # Tier 2 of the AI triage system. One Opus 5 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 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`). # 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 concurrency: group: ai-fix-sweep cancel-in-progress: false 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 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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: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 - name: Analyse and fix if: steps.batch.outputs.count != '0' uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # Skip the OIDC -> Claude App token exchange. The scheduled path # happens to pass it (github.actor is the maintainer), but # workflow_dispatch by anyone else would 401. AI_PR_TOKEN, not # GITHUB_TOKEN, so a PR Claude opens triggers CI. github_token: ${{ secrets.AI_PR_TOKEN }} # No track_progress here. It needs an issue or PR to hang its sticky # comment on, and the action hard-fails validation without one; this # workflow only ever runs on schedule/workflow_dispatch. Per-issue # progress still gets reported — issue-fix.md has Claude comment on # each issue directly via gh. 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-5 --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 .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 # Run the guard from the trusted default-branch copy, never the in-tree # copy the model could have modified in this same job. DEFAULT=$(gh api "repos/${REPO}" --jq '.default_branch' 2>/dev/null || echo master) if git fetch --depth=1 origin "$DEFAULT" >/dev/null 2>&1 \ && git cat-file -e "FETCH_HEAD:.github/scripts/ai_guard_paths.sh" 2>/dev/null; then git show "FETCH_HEAD:.github/scripts/ai_guard_paths.sh" | bash else echo "::warning::trusted guard copy unavailable, using in-tree copy" bash .github/scripts/ai_guard_paths.sh fi