From d47dca1a274afbdd93cf469476932f7a80465a63 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 23:26:44 +0000 Subject: [PATCH 1/3] ci: opt-in AI triage via ai-triage label (#274) Add .github/workflows/ai-triage.yml: labeling a PR or issue `ai-triage` (triage+ only, live-verified) runs one sonnet session that comments with related issues and likely duplicates, then removes the label so it acts as a re-pressable button. Document the label in the AI development docs table and CLAUDE.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pohc8xLkdx4gwXkW3xd7up --- .github/workflows/ai-triage.yml | 155 ++++++++++++++++++ CLAUDE.md | 2 + .../guides/getting-started/ai-development.md | 15 +- 3 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/ai-triage.yml diff --git a/.github/workflows/ai-triage.yml b/.github/workflows/ai-triage.yml new file mode 100644 index 000000000..987f58b2a --- /dev/null +++ b/.github/workflows/ai-triage.yml @@ -0,0 +1,155 @@ +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. +# +# 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. +# +# Usage posture: one sonnet session per label application, always human-gated. +# Kill switch: repo variable AI_LOOP_ENABLED=false. +# +# Label to create at rollout: `ai-triage`, color #0e8a16, description +# "Run one-shot AI triage: related issues + duplicates (sonnet; triage+ only)". + +on: + pull_request_target: + types: [labeled] + issues: + types: [labeled] + +# No workflow-level grants; the single job takes exactly what it needs. +permissions: {} + +concurrency: + group: ai-triage-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: false + +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. + if: | + github.event.label.name == 'ai-triage' && + github.event.sender.type == 'User' && + vars.AI_LOOP_ENABLED == 'true' + 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 + 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 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SENDER: ${{ github.event.sender.login }} + 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" ;; + esac + + # 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 the gh API instead. + # No pnpm/node setup and no dependency install: this job never runs + # project code, it only gives the Claude action a workspace. + - name: Checkout base default branch + if: steps.perm.outputs.allowed == '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' + 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 + # review threads. + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + claude_args: | + --max-turns 30 + --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 api:*),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` (or the + files endpoint via `gh api`) 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 issue list`, + `gh pr list`, and `gh api` as needed. 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 — ` 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 — + # so the label stays a re-pressable button and never wedges "on". + - name: Remove ai-triage label + if: always() && steps.perm.outputs.allowed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + run: | + # The issues labels API covers both issues and PRs. + gh api -X DELETE "repos/$REPO/issues/$NUMBER/labels/ai-triage" || true diff --git a/CLAUDE.md b/CLAUDE.md index 29ee78905..8c4572ee6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,3 +92,5 @@ deep review on it (once ever per PR). Kill switches: remove `ai-loop` (per PR) o 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. diff --git a/docs/docs/guides/getting-started/ai-development.md b/docs/docs/guides/getting-started/ai-development.md index 04930a671..2971f8f45 100644 --- a/docs/docs/guides/getting-started/ai-development.md +++ b/docs/docs/guides/getting-started/ai-development.md @@ -47,13 +47,14 @@ the review-time requirements (Storybook story for UI changes, docs page for API PRs authored by the loop move through a small label lifecycle: -| Label | Where | Meaning | -| -------------------- | ------ | ----------------------------------------------------------------------------------------------------- | -| `claude-fix` | issues | Maintainer-approved: Claude implements this issue and opens a PR | -| `ai-loop` | PRs | The PR is inside the autonomous review/fix loop | -| `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 one-shot Claude deep review on any PR (once ever per PR) | +| Label | Where | Meaning | +| -------------------- | ------------ | ----------------------------------------------------------------------------------------------------- | +| `claude-fix` | issues | Maintainer-approved: Claude implements this issue and opens a PR | +| `ai-loop` | PRs | The PR is inside the autonomous review/fix loop | +| `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 one-shot Claude deep review on any PR (once ever per PR) | +| `ai-triage` | PRs & issues | Opt-in: one-shot AI triage (related issues + duplicates); the label auto-removes after the run | 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 → From cc499207bc0e3bb5777e081a9e55b9a0f9193f27 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 10 Jul 2026 23:40:01 +0000 Subject: [PATCH 2/3] ci: restrict ai-triage tool allowlist to read-only gh commands (#274) Drop Bash(gh api:*) from --allowedTools: the prefix also matches POST/PATCH/DELETE and the session ingests untrusted issue/PR text, so an injected agent could mutate labels or machine-managed comments. Point the prompt's gh api hints at gh search / gh pr diff instead and note the GET-only posture in the header. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pohc8xLkdx4gwXkW3xd7up --- .github/workflows/ai-triage.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ai-triage.yml b/.github/workflows/ai-triage.yml index 987f58b2a..4c769ea44 100644 --- a/.github/workflows/ai-triage.yml +++ b/.github/workflows/ai-triage.yml @@ -13,7 +13,10 @@ name: AI Triage # 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. +# 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. # # Usage posture: one sonnet session per label application, always human-gated. # Kill switch: repo variable AI_LOOP_ENABLED=false. @@ -76,7 +79,7 @@ jobs: # 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 the gh API instead. + # 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. - name: Checkout base default branch @@ -97,7 +100,7 @@ jobs: claude_args: | --max-turns 30 --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 api:*),Bash(gh pr comment:*),Bash(gh issue comment:*)" + --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:*)" prompt: | REPO: ${{ github.repository }} NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} @@ -109,15 +112,15 @@ jobs: 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` (or the - files endpoint via `gh api`) for a summary of what it - changes. Do NOT check out or execute any of its code. + 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 issue list`, - `gh pr list`, and `gh api` as needed. Look for: + 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 From c8c480753f036e9b0900828b2afa5b719e7fb1aa Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 11 Jul 2026 01:41:43 +0000 Subject: [PATCH 3/3] ci: always remove ai-triage label; note triage+ gate in docs table (#274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the CodeRabbit review on PR #277: the cleanup step's permission gate contradicted its own "never wedges on" comment — a transient collaborators-API failure would leave the label stuck with no way to re-fire via a labeled event. Removal uses only GITHUB_TOKEN, so dropping the gate spends no Claude usage. Also states the triage+ requirement in the docs label table, matching the deep-review row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0142miDJt2AX9QUw5fNJt2xu --- .github/workflows/ai-triage.yml | 8 +++++--- .../guides/getting-started/ai-development.md | 16 ++++++++-------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ai-triage.yml b/.github/workflows/ai-triage.yml index 4c769ea44..619c6edde 100644 --- a/.github/workflows/ai-triage.yml +++ b/.github/workflows/ai-triage.yml @@ -145,10 +145,12 @@ jobs: "@coderabbitai" in any text; post one comment and nothing else. - # Always remove the trigger label — even when the Claude step failed — - # so the label stays a re-pressable button and never wedges "on". + # 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. - name: Remove ai-triage label - if: always() && steps.perm.outputs.allowed == 'true' + if: always() env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} diff --git a/docs/docs/guides/getting-started/ai-development.md b/docs/docs/guides/getting-started/ai-development.md index 2971f8f45..80170ef2c 100644 --- a/docs/docs/guides/getting-started/ai-development.md +++ b/docs/docs/guides/getting-started/ai-development.md @@ -47,14 +47,14 @@ the review-time requirements (Storybook story for UI changes, docs page for API PRs authored by the loop move through a small label lifecycle: -| Label | Where | Meaning | -| -------------------- | ------------ | ----------------------------------------------------------------------------------------------------- | -| `claude-fix` | issues | Maintainer-approved: Claude implements this issue and opens a PR | -| `ai-loop` | PRs | The PR is inside the autonomous review/fix loop | -| `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 one-shot Claude deep review on any PR (once ever per PR) | -| `ai-triage` | PRs & issues | Opt-in: one-shot AI triage (related issues + duplicates); the label auto-removes after the run | +| Label | Where | Meaning | +| -------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| `claude-fix` | issues | Maintainer-approved: Claude implements this issue and opens a PR | +| `ai-loop` | PRs | The PR is inside the autonomous review/fix loop | +| `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 one-shot Claude deep review on any PR (once ever per PR) | +| `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 | 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 →