diff --git a/.claude/commands/pre-pr.md b/.claude/commands/pre-pr.md new file mode 100644 index 000000000..42b769dc9 --- /dev/null +++ b/.claude/commands/pre-pr.md @@ -0,0 +1,24 @@ +--- +description: Run the full pre-PR gate (pnpm all) and self-review the diff against the component checklist +allowed-tools: Read, Bash(pnpm all), Bash(pnpm run:*), Bash(pnpm exec:*), Bash(git status:*), Bash(git diff:*), Bash(git log:*) +--- + +# /pre-pr — gate + self-review before opening a PR + +1. Run `pnpm all` (build, typecheck, test+coverage, bundle:stats, lint, + format:check, storybook build). If it fails, report the failing task and + stop — fix before reviewing. +2. Read the working diff (`git status`, `git diff main...HEAD` plus any + uncommitted changes). If the diff touches `bulma-ui/src` components, + stories, SCSS, or `skills/`, review it against the checklist in + `CONTRIBUTING-COMPONENTS.md` (repo root — Read it), focusing on what CI + can NOT catch: + - prop-level changes without matching story/docs/skills updates + - missing listing surfaces (guide page section, homepage card — step 2) + - skills sync for themeable or shared-helper changes (step 3) + For diffs that don't touch components (docs-only, tooling, workflows), + SKIP the component checklist and just review the diff generally. + Always check commit message type/scope (`feat|fix|perf|refactor|style` + need scope `bulma-ui|docs|create-bestax`). +3. Report a short summary: gate result, then each checklist gap with the + file to touch. Do not fix anything — this command only reviews. diff --git a/.claude/commands/triage-dedupe.md b/.claude/commands/triage-dedupe.md new file mode 100644 index 000000000..47ffac7d4 --- /dev/null +++ b/.claude/commands/triage-dedupe.md @@ -0,0 +1,93 @@ +--- +description: Find likely duplicates of an issue with 5 parallel search agents and post one triage comment +allowed-tools: Task, Bash(gh issue view:*), Bash(gh issue list:*), Bash(gh pr list:*), Bash(gh search:*), Bash(gh issue comment:*) +--- + +# /triage-dedupe — duplicate search for an issue + +Find likely duplicates of the target issue and post EXACTLY ONE comment (or +nothing — see pre-checks). Context comes from the caller (the ai-triage +workflow prompt, or the user when run locally): `REPO` (owner/repo), `NUMBER` +(the target issue), `TRIGGER` (`opened` or `labeled`; assume `labeled` when +run locally), and `AUTOCLOSE` (`active` or `off`; assume `off` locally). If +`NUMBER` is missing, ask — never guess. + +## Pre-checks (in order; each exit is SILENT — post nothing) + +1. `gh issue view NUMBER --repo REPO --json state,title,body,comments` — if + the issue is not open, stop. +2. Marker check — does any existing comment by a claude bot account contain + ``? + - `TRIGGER=opened` and marker present → stop (already triaged). + - `TRIGGER=labeled` and marker present → continue; at the end REFRESH + that comment instead of posting a new one: if it is your most recent + comment on the issue, use + `gh issue comment NUMBER --repo REPO --edit-last --body ...`; + otherwise post a fresh comment (the old one stays as history). +3. If the issue is too vague to search meaningfully (no error text, no + component or file name, no concrete behavior), stop. + +## Search — 5 parallel agents + +Summarize the issue first: symptoms, component/file names, API/prop names, +exact error strings, and the general area. Then fan out FIVE Task agents in +a single message, each pursuing ONE distinct strategy: + +1. **Error strings** — exact quoted messages, stack-trace lines, warning text. +2. **Component + file names** — `Button`, `Modal`, `useConfig`, source paths. +3. **API + prop names** — prop names, exported symbols, CSS class names. +4. **Title keywords** — the strongest 2–4 words of the title, plus synonyms. +5. **Broad area terms** — the general feature area (theming, SSR, a11y, docs). + +Rules for every agent: + +- EVERY search is scoped to this repository: `repo:REPO` in the query (or + the `--repo REPO` flag). Never search outside it. +- Use `gh search issues`, `gh issue list --search`; include closed issues + (a duplicate of a closed issue is still a duplicate). +- Return candidate numbers with title and a one-line justification each. + +## Filter pass (you, not the agents) + +Judge every candidate against the target: same root cause or clearly the +same feature request = duplicate; same area or a blocking relationship = +related. DROP weak keyword-only matches — an empty result beats a wrong one. +Read the top candidates with `gh issue view` when unsure. + +## Post ONE comment + +Via `gh issue comment NUMBER --repo REPO --body ...` (or the refresh path +from pre-check 2), formatted exactly like this: + +```markdown +### AI triage + +**Likely duplicates** + +- #N — : <one-line reason> +- (at most 3 entries, best match first) + +Duplicate of #N + +**Related** (optional, at most 3) + +- #M — <title>: <one-line reason> + +<!-- ai-triage:dedupe --> +``` + +- The line `Duplicate of #N` — with N the single best duplicate — must use + THIS EXACT FORMAT on its own line: the auto-close cron machine-parses it. + Include it only when you found at least one credible duplicate. +- If `AUTOCLOSE=active`, add this sentence directly after the + `Duplicate of #N` line: "This issue may be auto-closed in 14 days unless + someone objects (comment or 👎)." If `AUTOCLOSE=off`, omit it. +- The marker `<!-- ai-triage:dedupe -->` goes on its own line at the end. +- No credible duplicates → post "No duplicates found." under the heading, + plus the marker, WITHOUT any `Duplicate of #N` line or auto-close notice. + +## Hard rules + +Never apply or remove labels; never close, merge, push, or resolve +anything; never write "@claude" or "@coderabbitai" in any text; post at +most one comment and nothing else. diff --git a/.claude/commands/triage-find-duplicate-prs.md b/.claude/commands/triage-find-duplicate-prs.md new file mode 100644 index 000000000..5965beb5e --- /dev/null +++ b/.claude/commands/triage-find-duplicate-prs.md @@ -0,0 +1,66 @@ +--- +description: Find open PRs that duplicate a PR with 3 parallel search agents; silent when none found +allowed-tools: Task, Bash(gh pr view:*), Bash(gh pr diff:*), Bash(gh pr list:*), Bash(gh search:*), Bash(gh pr comment:*) +--- + +# /triage-find-duplicate-prs — overlapping-work check for a PR + +Find OPEN, unmerged PRs that duplicate or overlap the target PR. SILENT by +default: when nothing credible turns up, post NOTHING. Context from the +caller: `REPO`, `NUMBER` (the target PR), `TRIGGER` (`opened`/`labeled`; +assume `labeled` locally). If `NUMBER` is missing, ask. + +## Pre-checks (each exit is SILENT) + +1. `gh pr view NUMBER --repo REPO --json state,title,body,files,comments` — + if the PR is not open, stop. +2. Marker check — a claude-bot comment containing + `<!-- ai-triage:find-duplicate-prs -->`: + - `TRIGGER=opened` and marker present → stop. + - `TRIGGER=labeled` and marker present → continue; at the end refresh + that comment (`gh pr comment NUMBER --repo REPO --edit-last --body ...` + if it is your most recent comment on the PR; otherwise post fresh). + +## Search — 3 parallel agents + +Read the PR title, body, and changed file list, then fan out THREE Task +agents in a single message, one strategy each: + +1. **Changed files** — other PRs touching the same components or paths. +2. **Title + body keywords** — the strongest words plus synonyms. +3. **Feature/fix intent** — what the PR accomplishes, phrased differently. + +Rules for every agent: EVERY search scoped +`repo:REPO is:pr is:open is:unmerged` (via `gh search prs` or +`gh pr list --search`); exclude the target PR itself and draft PRs; return +numbers + titles + a one-line justification. + +## Filter pass + +A duplicate solves the same problem or implements the same feature — same +files alone is NOT enough. Compare diffs (`gh pr diff`) when unsure. Drop +weak matches. Zero credible duplicates: on `TRIGGER=opened`, stop SILENTLY +— no comment, no marker; on `TRIGGER=labeled` (an explicit human request), +post/refresh the comment with the single line "No duplicate PRs found." +plus the marker (matches the pre-check refresh path). + +## Post ONE comment (when duplicates exist, or on a labeled rerun) + +Via `gh pr comment NUMBER --repo REPO --body ...` (or the refresh path): + +```markdown +### AI triage — possible duplicate PRs + +- #N — <title>: <one-line reason> (at most 3, best match first) + +<!-- ai-triage:find-duplicate-prs --> +``` + +The marker `<!-- ai-triage:find-duplicate-prs -->` goes on its own line at +the end. + +## Hard rules + +Never apply or remove labels; never close, merge, push, or resolve +anything; never write "@claude" or "@coderabbitai" in any text; post at +most one comment, and none at all when nothing was found. diff --git a/.claude/commands/triage-find-issues.md b/.claude/commands/triage-find-issues.md new file mode 100644 index 000000000..2e8b2b4aa --- /dev/null +++ b/.claude/commands/triage-find-issues.md @@ -0,0 +1,78 @@ +--- +description: Find open issues a PR likely resolves with 5 parallel search agents and post one triage comment +allowed-tools: Task, Bash(gh pr view:*), Bash(gh pr diff:*), Bash(gh issue view:*), Bash(gh issue list:*), Bash(gh search:*), Bash(gh pr comment:*) +--- + +# /triage-find-issues — link a PR to the issues it resolves + +Find OPEN issues the target PR likely resolves or relates to, and post +EXACTLY ONE comment (or nothing). Context from the caller: `REPO`, `NUMBER` +(the target PR), `TRIGGER` (`opened`/`labeled`; assume `labeled` locally), +`AUTOCLOSE` (unused here). If `NUMBER` is missing, ask. + +## Pre-checks (each exit is SILENT) + +1. `gh pr view NUMBER --repo REPO --json state,title,body,comments` — if the + PR is not open, stop. +2. Marker check — a claude-bot comment containing + `<!-- ai-triage:find-issues -->`: + - `TRIGGER=opened` and marker present → stop. + - `TRIGGER=labeled` and marker present → continue; at the end refresh + that comment (`gh pr comment NUMBER --repo REPO --edit-last --body ...` + if it is your most recent comment on the PR; otherwise post fresh). +3. Note every issue already referenced in the PR body (`#N`, `Fixes #N`, + `Closes #N`, full URLs) — those are EXCLUDED from the results. + +## Search — 5 parallel agents + +Read the PR title, body, and `gh pr diff NUMBER --repo REPO` (never check +out or execute its code). Extract signals, then fan out FIVE Task agents in +a single message, one strategy each: + +1. **Error strings** — messages or test names the diff fixes or touches. +2. **Component + file names** — components and paths the diff changes. +3. **API + prop names** — props, exports, and CSS classes the diff touches. +4. **Title keywords** — the strongest words of the PR title, plus synonyms. +5. **Broad area terms** — the feature area of the change. + +Rules for every agent: scope EVERY search to this repository +(`repo:REPO` or `--repo REPO`); OPEN issues only (`is:open` / +`--state open`); return numbers + titles + a one-line justification. + +## Filter pass + +Keep only issues this PR plausibly resolves (the diff addresses the issue's +root cause) or directly relates to. Exclude everything already linked in +the PR body (pre-check 3). Drop weak keyword-only matches. If nothing +credible remains: on `TRIGGER=opened`, stop SILENTLY — no comment, no +marker; on `TRIGGER=labeled` (an explicit human request — silence would +read as a malfunction), post/refresh the comment with the single line +"No open issues found that this PR resolves." plus the marker (matches +pre-check 2's refresh path). + +## Post ONE comment + +Via `gh pr comment NUMBER --repo REPO --body ...` (or the refresh path): + +````markdown +### AI triage — issues this PR may resolve + +- #N — <title>: <one-line reason> (best match first, at most 5) + +If this PR resolves one of these, add the line below to the PR description +so the issue closes on merge: + +``` +Fixes #N +``` + +<!-- ai-triage:find-issues --> +```` + +The marker `<!-- ai-triage:find-issues -->` goes on its own line at the end. + +## Hard rules + +Never apply or remove labels; never close, merge, push, or resolve +anything; never edit the PR body yourself — only suggest; never write +"@claude" or "@coderabbitai" in any text; post at most one comment. diff --git a/.github/workflows/ai-triage.yml b/.github/workflows/ai-triage.yml index 619c6edde..e12ccc04e 100644 --- a/.github/workflows/ai-triage.yml +++ b/.github/workflows/ai-triage.yml @@ -1,156 +1,298 @@ name: AI Triage -# Opt-in, label-triggered triage — an adaptation of oven-sh/bun's always-on -# claude-find-issues-for-pr.yml + claude-dedupe-issues.yml. A maintainer -# applies the `ai-triage` label to a PR or issue; one cheap sonnet session -# searches for related issues / likely duplicates and posts a single summary -# comment; the label is then auto-removed so it works like a re-pressable -# button. +# AI triage v2 — fan-out duplicate/related search on issues and PRs. An +# adaptation of oven-sh/bun's claude-dedupe-issues.yml + +# claude-find-issues-for-pr.yml, budget-capped for a metered Claude plan. +# One sonnet session per run drives the repo's triage slash commands +# (.claude/commands/triage-*.md): issues get /triage-dedupe; PRs get +# /triage-find-issues + /triage-find-duplicate-prs. Each command fans out +# parallel read-only search sub-agents and posts at most ONE marker-tagged +# comment, so re-runs are idempotent. # -# SECURITY — two layers gate every run: -# 1. Job `if:` requires the `ai-triage` label from a real user -# (GitHub already restricts labeling to triage+ collaborators). -# 2. A live permission re-check step requires admin/maintain/write/triage, -# so an automated or stale-association label can never spend usage. -# This never checks out PR head code — only the base repo's default branch — -# so `pull_request_target` is safe here. The Claude tool allowlist is -# deliberately GET-only gh commands (view/diff/list/search) plus the two -# comment commands — no `gh api`, which could also POST/PATCH/DELETE — -# because the session ingests untrusted issue/PR text. +# MODES (repo variable AI_TRIAGE_MODE; string-compared, unset ⇒ `label`): +# auto | `opened` events triage automatically (budget-capped below); +# | the ai-triage label works too +# label | opt-in only — the label is the sole trigger (today's behavior) +# off | everything dead, label included # -# Usage posture: one sonnet session per label application, always human-gated. -# Kill switch: repo variable AI_LOOP_ENABLED=false. +# BUDGET (auto runs only; label runs are exempt — already human-metered by +# the click): a repo-wide daily counter kept as ONE marker comment on +# tracking issue #290 (`<!-- ai-triage-budget date=YYYY-MM-DD count=N -->`), +# edited in place. Same fail-closed pattern as claude-pr-loop.yml's +# iteration cap: the probe is author-filtered to github-actions[bot] so +# commenters cannot forge or reset the count; a new UTC date resets it; a +# present-but-unparsable marker counts as AT-CAP; the count is incremented +# BEFORE the Claude session starts, so crashed runs still spend budget. +# Limit from AI_TRIAGE_DAILY_LIMIT (unset ⇒ 10). At/over limit ⇒ silent +# skip, zero usage. The counter is read-modify-write, so its correctness +# DEPENDS on the global single-flight concurrency group below — do not +# re-scope the group per item. # -# Label to create at rollout: `ai-triage`, color #0e8a16, description +# AI_TRIAGE_AUTOCLOSE (`on` / `dry-run` / unset ⇒ `off`) is passed through +# to the session: when active, dedupe comments that name a duplicate include +# the 14-day auto-close notice consumed by the auto-close cron (PR B of +# #289), which parses the `Duplicate of #N` line those comments carry. +# +# SECURITY: +# - labeled runs re-verify the labeler's LIVE role (triage+) via the +# collaborators API, so an automated or stale-association label can +# never spend usage. +# - auto runs skip bot authors (bestaxbot, *[bot]), claude/* heads, and +# PRs whose body already links an issue — AI-loop PRs self-link by +# construction, so auto-triage would only duplicate the loop. +# - This never checks out PR head code — only the base repo's default +# branch — so `pull_request_target` is safe here. The Claude tool +# allowlist is GET-only gh commands + the two comment commands + Task +# (the fan-out sub-agents) — no `gh api`, which could also +# POST/PATCH/DELETE, and no file-editing tools, because the session +# ingests untrusted issue/PR text. +# +# KILL SWITCHES: AI_TRIAGE_MODE=off (this workflow only) or +# AI_LOOP_ENABLED=false (all Claude spend, repo-wide). +# +# Label (exists since v1): `ai-triage`, color #0e8a16, description # "Run one-shot AI triage: related issues + duplicates (sonnet; triage+ only)". on: pull_request_target: - types: [labeled] + types: [opened, labeled] issues: - types: [labeled] + types: [opened, labeled] # No workflow-level grants; the single job takes exactly what it needs. permissions: {} +# GLOBAL single-flight — every run (any item, any event) shares ONE group, +# so runs queue instead of overlapping. This is load-bearing for the daily +# BUDGET above: the counter on the tracking issue is read-modify-write, and +# a per-item group would let concurrent `opened` runs race it (two runs read +# the same count and both charge it to the same value, or the first run of a +# UTC day is duplicated into two marker comments). Queuing a burst is exactly +# what a budget wants; label runs queue behind auto runs too — acceptable, +# they're short. cancel-in-progress stays false so queued work isn't lost. concurrency: - group: ai-triage-${{ github.event.pull_request.number || github.event.issue.number }} + group: ai-triage cancel-in-progress: false +env: + # The pinned budget-counter issue. The gate step PATCHes one + # github-actions[bot] marker comment on it per UTC day. + TRACKING_ISSUE: '290' + jobs: triage: - # Layer 1 (cheap pre-gate): only the ai-triage label, applied by a real - # user, with the system enabled. Anything else is a silent no-op that - # spends no usage. + # Layer 1 (cheap pre-gate, event shapes only). GitHub renders an UNSET + # variable as the empty string, so the expression is designed around '': + # - unset AI_TRIAGE_MODE ⇒ '' != 'off' keeps the labeled path live, + # while '' == 'auto' is false and kills the opened path ⇒ the + # default is label-only. + # - 'off' fails the != 'off' term ⇒ both paths dead. + # - 'auto' ⇒ both paths live. + # labeled events additionally require the ai-triage label from a real + # user; anything else is a silent no-op that spends no usage. if: | - github.event.label.name == 'ai-triage' && - github.event.sender.type == 'User' && - vars.AI_LOOP_ENABLED == 'true' + vars.AI_LOOP_ENABLED == 'true' && + vars.AI_TRIAGE_MODE != 'off' && + ( + (github.event.action == 'labeled' && + github.event.label.name == 'ai-triage' && + github.event.sender.type == 'User') || + (github.event.action == 'opened' && + vars.AI_TRIAGE_MODE == 'auto') + ) runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read # checkout of the base default branch pull-requests: write # post the triage comment on PRs; remove the label - issues: write # post the triage comment on issues; remove the label + issues: write # post the triage comment on issues; budget marker; remove the label id-token: write # OIDC exchange for the Claude OAuth token steps: - # Layer 2 (authoritative): GitHub already restricts labeling to triage+; - # re-verify the labeler's LIVE role defensively so an automated or - # template-applied label can never trigger a session. All later steps - # gate on this output. - - name: Check labeler permission - id: perm + # Layer 2 (authoritative, zero Claude usage). Two shapes: + # labeled → GitHub already restricts labeling to triage+; re-verify + # the labeler's LIVE role defensively so an automated or + # template-applied label can never trigger a session. + # Budget is BYPASSED — each run is human-metered. + # opened → skip bot authors, AI-loop PRs (claude/* heads or bodies + # that already link an issue), then charge the daily + # budget BEFORE any Claude spend. + # All later steps gate on `run`. + - name: Gate (permissions / budget) + id: gate env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} + ACTION: ${{ github.event.action }} SENDER: ${{ github.event.sender.login }} + IS_PR: ${{ github.event.pull_request != null }} + NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + LIMIT: ${{ vars.AI_TRIAGE_DAILY_LIMIT }} run: | set -euo pipefail - ROLE=$(gh api "repos/$REPO/collaborators/$SENDER/permission" \ - --jq '.role_name' || echo none) - case "$ROLE" in - admin|maintain|write|triage) - echo "allowed=true" >> "$GITHUB_OUTPUT" - echo "$SENDER has $ROLE access — running triage" ;; - *) - echo "allowed=false" >> "$GITHUB_OUTPUT" - echo "$SENDER lacks triage access ($ROLE) — ignoring" ;; + out() { echo "$1=$2" >> "$GITHUB_OUTPUT"; } + out is_pr "$IS_PR" + out number "$NUMBER" + + if [ "$ACTION" = "labeled" ]; then + ROLE=$(gh api "repos/$REPO/collaborators/$SENDER/permission" \ + --jq '.role_name' || echo none) + case "$ROLE" in + admin|maintain|write|triage) + out run true + echo "$SENDER has $ROLE access — running triage (budget exempt)" ;; + *) + out run false + echo "$SENDER lacks triage access ($ROLE) — ignoring" ;; + esac + exit 0 + fi + + # ---- opened event ---- + # Bot authors never trigger auto-triage: the AI loop's own PRs and + # any app-created items would otherwise burn budget on themselves. + case "$SENDER" in + bestaxbot|*\[bot\]) + out run false + echo "bot author ($SENDER) — skipping auto-triage" + exit 0 ;; esac + if [ "$IS_PR" = "true" ]; then + PR_JSON=$(gh pr view "$NUMBER" --repo "$REPO" --json headRefName,body) + HEAD_REF=$(jq -r .headRefName <<<"$PR_JSON") + case "$HEAD_REF" in + claude/*) + out run false + echo "AI-loop head branch ($HEAD_REF) — skipping" + exit 0 ;; + esac + if jq -r '.body // ""' <<<"$PR_JSON" | grep -qE '(Fixes|Closes) #'; then + out run false + echo "PR body already links an issue (Fixes/Closes) — skipping" + exit 0 + fi + fi + + # ---- Daily budget (single marker comment on the tracking issue, + # edited in place). Author-filtered probe over ALL comment + # pages (--paginate applies --jq per page, so a second + # `jq -s` slurp picks the overall LAST match) — the marker + # can never paginate off page 1 and silently reset the cap. + # A present-but-unparsable marker fails CLOSED (counts as + # at-cap) rather than resetting to 0. ---- + TODAY=$(date -u +%F) + LIMIT="${LIMIT:-10}" + STATE=$(gh api --paginate "repos/$REPO/issues/$TRACKING_ISSUE/comments?per_page=100" --jq \ + '.[] | select((.user.login == "github-actions[bot]") + and (.body | startswith("<!-- ai-triage-budget")))' \ + | jq -s 'last // empty') + COMMENT_ID=""; COUNT=0 + if [ -n "$STATE" ]; then + COMMENT_ID=$(jq -r .id <<<"$STATE") + MDATE=$(jq -r .body <<<"$STATE" \ + | sed -n 's/^<!-- ai-triage-budget date=\([0-9-]*\) count=\([0-9]*\) -->.*/\1/p') + MCOUNT=$(jq -r .body <<<"$STATE" \ + | sed -n 's/^<!-- ai-triage-budget date=\([0-9-]*\) count=\([0-9]*\) -->.*/\2/p') + if [ -z "$MDATE" ] || [ -z "$MCOUNT" ]; then + out run false + echo "budget marker present but unparsable — failing closed (treated as at-cap)" + exit 0 + fi + # A marker from a previous UTC day resets the count to 0. + if [ "$MDATE" = "$TODAY" ]; then COUNT="$MCOUNT"; fi + fi + if [ "$COUNT" -ge "$LIMIT" ]; then + out run false + echo "daily auto-triage budget spent ($COUNT/$LIMIT for $TODAY) — skipping; label runs still work" + exit 0 + fi + + # ---- Charge the budget BEFORE Claude runs (crashes still count) ---- + NEXT=$((COUNT + 1)) + BODY="<!-- ai-triage-budget date=$TODAY count=$NEXT --> + **AI triage budget**: $NEXT/$LIMIT auto-triggered session(s) on $TODAY (UTC). Label-triggered runs are exempt. + _Managed by ai-triage.yml — never reformat the first line; a new UTC day resets the count._" + if [ -n "$COMMENT_ID" ]; then + gh api "repos/$REPO/issues/comments/$COMMENT_ID" -X PATCH -f body="$BODY" >/dev/null + else + gh api "repos/$REPO/issues/$TRACKING_ISSUE/comments" -f body="$BODY" >/dev/null + fi + echo "budget charged: $NEXT/$LIMIT for $TODAY — running triage" + out run true + # Check out ONLY the base repo's default branch — never the PR head. # Under pull_request_target that keeps untrusted PR code out of the - # workspace; the triage agent reads PR content via read-only gh commands. - # No pnpm/node setup and no dependency install: this job never runs - # project code, it only gives the Claude action a workspace. + # workspace; the triage agent reads PR content via read-only gh + # commands. The checkout also provides the .claude/commands/ triage + # commands the session runs. No pnpm/node setup and no dependency + # install: this job never runs project code. - name: Checkout base default branch - if: steps.perm.outputs.allowed == 'true' + if: steps.gate.outputs.run == 'true' uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - name: Run Claude (triage) - if: steps.perm.outputs.allowed == 'true' + if: steps.gate.outputs.run == 'true' uses: anthropics/claude-code-action@51c47629174b5dbca955cf93690fd39c8a41dadf # v1 with: - # OAuth token only — posts as claude[bot]. Safe: this is a plain - # top-level comment; the loop's state machine only classifies + # OAuth token only — posts as claude[bot]. Safe: these are plain + # top-level comments; the loop's state machine only classifies # review threads. claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # Task is required for the commands' fan-out search sub-agents + # (they inherit this same allowlist). Still no `gh api` and no + # Edit/Write — the session ingests untrusted issue/PR text. claude_args: | - --max-turns 30 + --max-turns 50 --model claude-sonnet-5 - --allowedTools "Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh pr list:*),Bash(gh search:*),Bash(gh pr comment:*),Bash(gh issue comment:*)" + --allowedTools "Task,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh pr list:*),Bash(gh search:*),Bash(gh pr comment:*),Bash(gh issue comment:*)" prompt: | REPO: ${{ github.repository }} - NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} - IS_PR: ${{ github.event.pull_request != null }} - - You are a one-shot TRIAGE agent. A maintainer applied the - `ai-triage` label to the PR or issue above; your entire job is to - find related and duplicate items and post EXACTLY ONE comment. - - 1. Read the target: - - If IS_PR is true: `gh pr view NUMBER --repo REPO` for the - title/body, then `gh pr diff NUMBER --repo REPO` for a - summary of what it changes. Do NOT check out or execute - any of its code. - - If IS_PR is false: `gh issue view NUMBER --repo REPO` for - the title/body and existing comments. - 2. Search THIS repo for connections, using several keyword - variations from the title, body, and (for PRs) the changed - file paths — `gh search issues`, `gh search prs`, - `gh issue list`, and `gh pr list`. Look for: - - If a PR: (a) existing issues this PR likely resolves or - relates to (especially ones it should say "Closes #N" for), - and (b) other PRs — open or recently closed — that look like - duplicates or overlapping work. - - If an issue: likely duplicate issues (open or closed) and - closely related issues or PRs. - 3. Judge each candidate before including it: same root cause or - clearly the same feature = duplicate; same area or blocking - relationship = related. Drop weak keyword-only matches. - 4. Post EXACTLY ONE comment — `gh pr comment` if IS_PR is true, - `gh issue comment` otherwise — formatted as scannable - Markdown: - - A short heading, e.g. `### AI triage`. - - A `**Likely duplicates**` list and/or a `**Related**` list, - each entry as `#N — <title>` plus a one-line reason why it - qualifies. - - If nothing credible turns up, post the single line - "No related issues or duplicates found." under the heading - instead. - 5. HARD RULES: never apply or remove labels; never close, merge, - push, or resolve anything; never write "@claude" or - "@coderabbitai" in any text; post one comment and nothing - else. - - # Always remove the trigger label — even when the Claude step failed or - # the permission re-check errored — so the label stays a re-pressable - # button and never wedges "on". Removal uses only GITHUB_TOKEN, so an - # unauthorized labeling still spends zero Claude usage. + NUMBER: ${{ steps.gate.outputs.number }} + IS_PR: ${{ steps.gate.outputs.is_pr }} + TRIGGER: ${{ github.event.action }} + AUTOCLOSE: ${{ (vars.AI_TRIAGE_AUTOCLOSE == 'on' || vars.AI_TRIAGE_AUTOCLOSE == 'dry-run') && 'active' || 'off' }} + + You are the TRIAGE agent for this repository. Your entire job is + to run this repo's triage slash commands against the item above: + + - If IS_PR is false: run the /triage-dedupe command. + - If IS_PR is true: run /triage-find-issues, then + /triage-find-duplicate-prs. + + The command definitions live in .claude/commands/ in this + checkout (triage-dedupe.md, triage-find-issues.md, + triage-find-duplicate-prs.md). Read the matching file(s) and + follow them exactly — they consume the REPO / NUMBER / TRIGGER / + AUTOCLOSE context lines above. + + Idempotency (the commands restate this; it is binding): + - TRIGGER is opened: if a command's marker comment already + exists on the item, that command exits silently — post + nothing for it. + - TRIGGER is labeled: a maintainer explicitly re-ran triage — + refresh your existing marker comment in place + (`gh issue comment`/`gh pr comment` with `--edit-last`) + instead of posting a duplicate, per the command's rules. + + AUTOCLOSE is active: a dedupe comment that names a duplicate + MUST include the 14-day auto-close notice (exact wording in the + command). AUTOCLOSE is off: omit that notice entirely. + + HARD RULES: never apply or remove labels; never close, merge, + push, or resolve anything; never write "@claude" or + "@coderabbitai" in any text; keep every search scoped to REPO; + post nothing beyond what the commands specify. + + # Always remove the trigger label on labeled runs — even when the + # Claude step failed or the gate said no — so the label stays a + # re-pressable button and never wedges "on". Removal uses only + # GITHUB_TOKEN, so an unauthorized labeling still spends zero Claude + # usage. Opened runs have no label to remove. - name: Remove ai-triage label - if: always() + if: always() && github.event.action == 'labeled' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} diff --git a/CLAUDE.md b/CLAUDE.md index 2f86e4d56..f9d078d2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,8 +96,11 @@ label. Kill switches: remove `ai-loop` (per PR) or set repo variable `AI_LOOP_ENABLED=false` (whole system). The `<!-- ai-loop-state … -->` PR comment is machine-managed — never reformat its first line. The loop refuses PRs that touch `.github/**` or the jest/commitlint/release/pnpm-workspace configs. -Separately, labeling any PR or issue `ai-triage` (triage+ only) runs a one-shot sonnet triage -session that comments with related issues/duplicates and then removes the label. +Separately, `ai-triage` runs a one-shot sonnet triage session that comments with related +issues/duplicates: automatic on new issues/PRs when `AI_TRIAGE_MODE=auto` (capped at +`AI_TRIAGE_DAILY_LIMIT`/day via a counter comment on issue #290), or on demand via the label +(triage+ only, budget-exempt; auto-removed after the run). Flagged duplicates may be +auto-closed after 14 days per `AI_TRIAGE_AUTOCLOSE` (see the ai-development docs guide). Stale automation: PRs go `stale` at 30 days and close 14 days later — except Claude-assisted PRs (`claude-assisted` label or bestaxbot author), which skip that sweep and instead close after 90 days of inactivity; `neverstale` exempts a PR from both layers. diff --git a/docs/docs/guides/getting-started/ai-development.md b/docs/docs/guides/getting-started/ai-development.md index 67e8c0f89..e53f07d57 100644 --- a/docs/docs/guides/getting-started/ai-development.md +++ b/docs/docs/guides/getting-started/ai-development.md @@ -54,11 +54,26 @@ PRs authored by the loop move through a small label lifecycle: | `needs-human-review` | PRs | The loop converged (or hit a disagreement) — awaiting maintainer review/merge | | `ai-loop-paused` | PRs | The loop hit its iteration cap or a guardrail — a maintainer must intervene | | `deep-review` | PRs | Opt-in: a triage+ user applies it to run the Claude deep review on any PR (re-apply to re-run) | -| `ai-triage` | PRs & issues | Opt-in: a triage+ user applies it to run one-shot AI triage (related issues + duplicates); the label auto-removes after the run | +| `ai-triage` | PRs & issues | Runs one-shot AI triage (related issues + duplicates): automatic on new issues/PRs in auto mode (daily budget), or applied by a triage+ user (budget-exempt; label auto-removes) | | `claude-assisted` | PRs | Auto-applied provenance for AI-assisted PRs (bestaxbot or the Claude footer) | | `stale` | PRs | Auto-applied after 30 days of inactivity; closes 14 days later unless activity resumes. Claude-assisted PRs skip this sweep — a separate closer sweeps them after 90 days instead | | `neverstale` | PRs | Exempts a PR from all stale automation (both the 30/14-day sweep and the 90-day Claude-assisted closer) | +### AI triage + +New issues and PRs can get an automatic triage comment: likely duplicates (issues), the open +issues a PR probably resolves, and overlapping PRs. Three repository variables control it: + +- **`AI_TRIAGE_MODE`** — `auto` (new issues/PRs are triaged automatically and the label still + works), `label` (opt-in only; the default when unset), or `off` (disables both automatic + triage **and** the label). +- **`AI_TRIAGE_DAILY_LIMIT`** — maximum auto-triggered triage sessions per UTC day (default + 10). Label-triggered runs are exempt — each one is already human-metered by the click. +- **`AI_TRIAGE_AUTOCLOSE`** — `on`, `dry-run`, or `off` (the default). When active, an issue + whose triage comment names a `Duplicate of #N` is auto-closed after **14 days** — unless + anyone objects: a human comment after the triage comment, a 👎 reaction on it, or reopening + the issue each veto the close. + The loop itself: Claude implements the issue and opens the PR → CodeRabbit reviews it and a second, independent Claude review (a stronger model than the implementer) does a deep pass → Claude addresses every finding, fixing what's right and refuting what's wrong → both reviewers