Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions .github/workflows/ai-triage.yml
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +51 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the workflow around the cited lines and any nearby triage/job config.
git ls-files '.github/workflows/ai-triage.yml' '.github/**' | sed -n '1,200p'
echo '--- ai-triage.yml ---'
cat -n .github/workflows/ai-triage.yml | sed -n '1,220p'

echo '--- search for gh api / tool config / permissions in github workflows ---'
rg -n --hidden --glob '.github/**' -e 'gh api' -e 'permissions:' -e 'id-token: write' -e 'pull-requests: write' -e 'issues: write' -e 'claude-code' -e 'tools?' .github

Repository: allxsmith/bestax

Length of output: 23452


Restrict the triage agent’s write surface. Bash(gh api:*) plus pull-requests: write / issues: write lets untrusted PR or issue content steer the model into arbitrary mutating REST calls. Keep the agent on read-only tools and move comment/label writes into a separate deterministic step, or narrow gh api to only the exact read and label-delete endpoints.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ai-triage.yml around lines 48 - 52, Restrict the AI triage
workflow’s write capabilities: remove pull-requests: write and issues: write
from the permissions used by the agent, and prevent its Bash gh api access from
issuing arbitrary mutations. Move comment and label-removal operations into a
separate deterministic workflow step, or explicitly limit gh api to the required
read and label-delete endpoints while preserving only the minimum permissions
needed.

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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 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 — <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.
- 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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
21 changes: 11 additions & 10 deletions docs/docs/guides/getting-started/ai-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 →
Expand Down
Loading