diff --git a/.github/workflows/ai-triage.yml b/.github/workflows/ai-triage.yml new file mode 100644 index 000000000..619c6edde --- /dev/null +++ b/.github/workflows/ai-triage.yml @@ -0,0 +1,160 @@ +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. 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. +# +# 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 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 + 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 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 — ` 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. + - name: Remove ai-triage label + if: always() + 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 69279922d..2f86e4d56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,8 @@ 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. 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 7666c31b8..67e8c0f89 100644 --- a/docs/docs/guides/getting-started/ai-development.md +++ b/docs/docs/guides/getting-started/ai-development.md @@ -47,16 +47,17 @@ 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 Claude deep review on any PR (re-apply to re-run) | -| `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) | +| 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 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 | +| `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) | 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 →