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
5 changes: 3 additions & 2 deletions .claude/commands/triage-dedupe.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ run locally), and `AUTOCLOSE` (`active` or `off`; assume `off` locally). If

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
`<!-- ai-triage:dedupe -->`?
2. Marker check — does any existing bot-authored comment contain
`<!-- ai-triage:dedupe -->`? (Match the marker + a bot author, not a
specific login — the workflow posts as github-actions[bot].)
- `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
Expand Down
5 changes: 3 additions & 2 deletions .claude/commands/triage-find-duplicate-prs.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ assume `labeled` locally). If `NUMBER` is missing, ask.

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 -->`:
2. Marker check — a bot-authored comment containing
`<!-- ai-triage:find-duplicate-prs -->` (match marker + bot author, not
a specific login — the workflow posts as github-actions[bot]):
- `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 ...`
Expand Down
5 changes: 3 additions & 2 deletions .claude/commands/triage-find-issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ EXACTLY ONE comment (or nothing). Context from the caller: `REPO`, `NUMBER`

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 -->`:
2. Marker check — a bot-authored comment containing
`<!-- ai-triage:find-issues -->` (match marker + bot author, not a
specific login — the workflow posts as github-actions[bot]):
- `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 ...`
Expand Down
64 changes: 48 additions & 16 deletions .github/workflows/ai-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,26 @@ name: AI Triage
# - 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.
# - SAME-REPO PRs ONLY (both the auto and label paths). This uses plain
# `pull_request`, NOT `pull_request_target`, and the job `if` skips
# fork heads explicitly. Fork PRs therefore never get triage — the
# same trade-off oven-sh/bun makes. Deliberate, two reasons:
# 1. `pull_request_target` hands fork-triggered runs our secrets and
# a write token; the no-PR-checkout + allowlist hardening below
# made that survivable, but same-repo-only removes the risk class
# instead of mitigating it.
# 2. The Claude action's OIDC→app-token exchange 401s under
# `pull_request_target` anyway (#312) — it was never exercisable.
# Do not switch this back to `pull_request_target` without reading
# #312. Fork runs would also lack secrets under `pull_request`; the
# explicit guard just turns that failure into a clean, silent skip.
# - 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.
# branch (defense in depth, and it keeps the job honest if the fork
# guard is ever loosened). 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).
Expand All @@ -56,7 +70,7 @@ name: AI Triage
# "Run one-shot AI triage: related issues + duplicates (sonnet; triage+ only)".

on:
pull_request_target:
pull_request:
types: [opened, labeled]
issues:
types: [opened, labeled]
Expand Down Expand Up @@ -92,9 +106,14 @@ jobs:
# - '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.
# The head-repo guard kills fork PRs (auto AND label paths) before any
# step runs — see SECURITY above; issue events have no pull_request and
# pass the null arm.
if: |
vars.AI_LOOP_ENABLED == 'true' &&
vars.AI_TRIAGE_MODE != 'off' &&
(github.event.pull_request == null ||
github.event.pull_request.head.repo.full_name == github.repository) &&
Comment thread
allxsmith marked this conversation as resolved.
(
(github.event.action == 'labeled' &&
github.event.label.name == 'ai-triage' &&
Expand All @@ -108,7 +127,8 @@ jobs:
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; budget marker; remove the label
id-token: write # OIDC exchange for the Claude OAuth token
# No id-token: the Claude step gets github_token explicitly, which
# short-circuits the action's OIDC→app-token exchange (#312).
steps:
# Layer 2 (authoritative, zero Claude usage). Two shapes:
# labeled → GitHub already restricts labeling to triage+; re-verify
Expand Down Expand Up @@ -238,12 +258,12 @@ jobs:
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. 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.
# Check out ONLY the base repo's default branch — never the PR head
# (untrusted PR code stays out of the 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.gate.outputs.run == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
Expand All @@ -255,10 +275,14 @@ jobs:
if: steps.gate.outputs.run == 'true'
uses: anthropics/claude-code-action@51c47629174b5dbca955cf93690fd39c8a41dadf # v1
with:
# 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 pays for the session. github_token is
# passed explicitly (Bun-style) so the action skips its OIDC→app
# token exchange (#312); triage comments therefore post as
# github-actions[bot] — anything probing for them must match the
# marker + a Bot-type author, never a specific login. Bonus:
# GITHUB_TOKEN-authored comments can't re-trigger workflows.
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ secrets.GITHUB_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.
Expand Down Expand Up @@ -309,6 +333,14 @@ jobs:
# 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.
#
# Known residual: labeling a FORK PR wedges the label (the job-level
# fork guard skips this step too), and it is not fixable under plain
# `pull_request` — fork-triggered runs cap GITHUB_TOKEN at read-only,
# so even a standalone unlabel job's DELETE would 403. Accepted:
# fork PRs are never triaged anyway, the stuck label is inert, and a
# maintainer removes it by hand. Unwedging would need
# `pull_request_target`, which this workflow deliberately dropped.
- name: Remove ai-triage label
if: always() && github.event.action == 'labeled'
env:
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ Separately, `ai-triage` runs a one-shot sonnet triage session that comments with
issues/duplicates: automatic on new issues/PRs when `AI_TRIAGE_MODE=auto` (outside authors
capped at `AI_TRIAGE_DAILY_LIMIT`/day via a counter comment on issue #290; items opened by
triage+ collaborators are uncapped), or on demand via the label
(triage+ only, budget-exempt; auto-removed after the run). Flagged duplicates may be
(triage+ only, budget-exempt; auto-removed after the run). Fork PRs are never triaged
(same-repo `pull_request` only — never `pull_request_target`; see #312). 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
Expand Down
6 changes: 5 additions & 1 deletion docs/docs/guides/getting-started/ai-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ PRs authored by the loop move through a small label lifecycle:
### 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:
issues a PR probably resolves, and overlapping PRs. Triage comments are posted by
`github-actions[bot]`. **Only same-repo PRs are triaged** — PRs opened from forks are always
skipped, automatic and label alike (the workflow deliberately avoids GitHub's
`pull_request_target` trigger, so fork-originated events can never run with repository
secrets); issues have no such restriction. 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
Expand Down
14 changes: 8 additions & 6 deletions scripts/auto-close-duplicates.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Ported from oven-sh/bun's scripts/auto-close-duplicates.ts, with the
* objection window widened from 3 to 14 days (deliberate owner choice).
*
* An issue is a close candidate when its LATEST claude[bot] comment carries
* An issue is a close candidate when its LATEST bot-authored comment carries
* the `<!-- ai-triage:dedupe -->` idempotency marker AND a machine-parseable
* `Duplicate of #N` line (posted by the ai-triage workflow, PR A of #289).
* The candidate is closed only if ALL of these hold:
Expand Down Expand Up @@ -81,15 +81,17 @@ export function isBot(user) {
}

/**
* Find the LATEST comment authored by claude[bot] that carries the dedupe
* marker and a parseable `Duplicate of #N`. Returns
* { comment, target } or null. `comments` must be in ascending created order
* (the REST API default for issue comments).
* Find the LATEST bot-authored comment that carries the dedupe marker and a
* parseable `Duplicate of #N`. Matched by marker + Bot author, never a
* specific login (Bun's convention): the triage workflow posts via
* GITHUB_TOKEN as github-actions[bot] since #312, while pre-#312 comments
* are claude[bot]. Returns { comment, target } or null. `comments` must be
* in ascending created order (the REST API default for issue comments).
*/
export function findMarkerComment(comments) {
for (let i = comments.length - 1; i >= 0; i--) {
const c = comments[i];
if (c.user?.login !== 'claude[bot]' || c.user?.type !== 'Bot') continue;
if (!isBot(c.user)) continue;
if (!c.body?.includes(MARKER)) continue;
const m = c.body.match(DUPLICATE_RE);
if (!m) continue;
Expand Down
Loading