triage-pr
GitHub将处于草稿且CI失败的PR推进至可合并状态。分两阶段:草稿期修复范围内的CI失败;转为Review后验证并处理AI审查反馈,直至CI全绿。支持自动提升状态及配置管理。
触发场景
安装
npx skills add NeverSight/learn-skills.dev --skill triage-pr -g -y
SKILL.md
Frontmatter
{
"name": "triage-pr",
"license": "MIT",
"metadata": {
"author": "Rob Easthope",
"version": "0.6.0"
},
"description": "Drive a pull request from draft with failing CI to merge-ready. While the PR is a draft, inspect and fix in-scope CI failures (lint, manifest-lint, build, tests) using the gh CLI and GitHub Actions logs — never weakening CI config to greenwash. After the PR is marked ready-for-review, fetch the unresolved AI review threads (Claude Code Review, Bugbot), validate each finding against the codebase before changing anything, fix the valid ones, decline the invalid ones with technical reasoning, then re-watch CI until green. Use when asked to triage a PR, fix failing CI or red checks on a PR, address or respond to PR review comments, action Bugbot or Claude review feedback, get a PR green, or take a draft PR to merge-ready. Handles base-branch drift and in-scope merge conflicts; escalates ambiguous ones.",
"allowed-tools": "Read, Edit, Write, Glob, Grep, Bash(gh:*), Bash(git:*), Bash(node:*), Bash(pnpm:*), Bash(npx:*), mcp__linear-server__save_issue, mcp__linear-server__list_issue_statuses, mcp__linear-server__list_projects",
"compatibility": "Requires the `gh` CLI (authenticated — `gh auth status` must pass) and `git`. The bundled review-thread fetcher needs Node.js >=22 (ES modules). Designed for repositories whose AI review runs only on ready-for-review PRs (draft-gated), so Phase A and Phase B do not overlap."
}
triage-pr
Take a pull request from draft + failing CI to merge-ready, in two phases, choosing the phase from the PR's draft state:
- Phase A — while the PR is a draft: inspect failing checks, pull GitHub Actions logs, and fix failures in PR scope only. Loop until CI is green or report blockers.
- Phase B — after the PR is ready-for-review: AI review is gated on
draft == false, so once the PR is flipped to ready — bypromoteOnGreenor a human — reviewers (Claude Code Review, Bugbot) post feedback. Fetch the unresolved findings, validate each against the codebase before changing anything, fix the valid ones, decline the invalid ones with technical reasoning, then loop back through Phase A.
This skill complements /send-it (which opens the draft PR). The draft→ready
flip is governed by a single control — promoteOnGreen in config.json
— and an enabled config is the authorisation for it: when promoteOnGreen is
true (the default), human authorisation for the flip is already acquired via the
repo config, so after a cleanly-green Phase A the skill flips the PR to ready and
continues into Phase B without stopping to seek a separate sign-off (the ready-flip is
the gate that turns AI review on; see Step 6). The flip stays guarded — gated on
proven-green CI, no unresolved human review threads, and no unresolved base drift.
Set promoteOnGreen: false (or pass --no-promote) to opt out and stop at green; an
explicit user prompt — or the --promote / --no-promote flags — overrides the config
for that run. Merge to main is never automated; that stays a human action. See
references/review-discipline.md for the full
review-reception and verification rules folded into Phase B.
Configuration
The knobs live in config.json beside this file. Read it at the
start of a run and use its values throughout. Edit your copied config.json to
match the consuming repo's review bots and (optionally) its Linear workspace.
The first four govern the CI + review loop:
| Key | Meaning | Default |
|---|---|---|
reviewBots |
GitHub login names whose comments and threads are treated as first-class AI review feedback. Matched against author.login; the [bot] suffix is normalised, so claude and claude[bot] both match (the GraphQL API returns the bare form). Edit to match your install — review-bot logins vary per repo. github-actions is deliberately excluded by default: it posts CI status and release-PR comments, not code review, so Phase B would otherwise action them as findings; add it only if your install genuinely posts review-type comments via the Actions bot. |
["claude", "cursor", "coderabbitai"] |
maxCiRounds |
Maximum Phase-A re-watch iterations before stopping and reporting blockers. Bounds the fix-and-watch loop so it can't spin forever. | 5 |
replyOnAccept |
Whether an accepted finding gets a factual thread reply referencing the fixing commit before the thread is resolved (the audit trail). false resolves accepted threads silently for maintainers who dislike bot-reply noise — declines always reply with reasoning regardless. |
true |
promoteOnGreen |
The single control for the draft→ready flip. When true, after Phase A finishes with every required check genuinely green on a draft PR, run gh pr ready <pr> to flip it to ready-for-review (the gate that turns AI review on), then continue into Phase B — instead of stopping at green. Default-on, and an enabled config is the human authorisation for the flip: proceed on proven green without seeking a separate sign-off. Set false (or pass --no-promote) to opt out and stop at green. Promotion is suppressed unless the green is proven (Step 6's watched rollup, never "no failures yet"), there are no unresolved human review threads, and mergeStateStatus shows no unresolved base drift (BEHIND / DIRTY). An explicit user prompt — or --promote / --no-promote — overrides this per run; --ci-only and --dry-run never promote. |
true |
The remaining five configure the follow-up capture step (Step 10) — turning a
valid-but-out-of-scope finding into a tracked Linear issue. They are opt-in:
when linearTeamName is empty, capture is disabled and the step is skipped
silently (no Linear MCP calls). Capture also needs the Linear MCP server; skip it
silently when it is unavailable.
| Key | Meaning | Default |
|---|---|---|
linearTeamName |
Linear team name (not the key — the key is renamed over time, the name is stable) the follow-up issues are created under. Empty disables capture entirely. | "" |
issueKeys |
Team-key prefixes that may appear in branch names, used to recognise issue ids the same way linear-sync does. Mirrors the established issueKeys convention. |
[] |
followUpLabel |
Optional label applied to each created follow-up issue (e.g. follow-up). Empty = no label. |
"" |
followUpProject |
Optional Linear project (name, id, or slug) the follow-up issues are filed under. Empty = no project. | "" |
followUpState |
Optional initial workflow state (type, name, or id — e.g. Backlog) for created issues. Empty = the team's default state. |
"Backlog" |
Only the configured reviewBots are actioned in Phase B. Human review comments
are surfaced in the final report but never auto-actioned, replied to, or
resolved — leave those for the human.
Usage modes
Auto — detect the current branch's PR and its phase, then run:
triage-pr
Explicit PR — operate on a specific PR by number or URL:
triage-pr 123
CI only — run Phase A and stop, even if the PR is ready:
triage-pr --ci-only
Dry run — report failing checks and unresolved findings and propose fixes, but change nothing (no commits, no pushes, no thread replies):
triage-pr --dry-run
Promote on green — opt in to flipping the draft to ready once Phase A is cleanly
green (then continue into Phase B). Overrides promoteOnGreen for this run;
--no-promote forces the default stop-at-green:
triage-pr --promote
Process
Step 1 — Locate the PR and detect the phase
gh pr view <pr> --json number,isDraft,state,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup
- Resolve the PR from the argument, or from the current branch when none is
given. If
gh pr viewfinds no PR, stop and tell the user to open one with/send-itfirst. isDraft == true→ Phase A. When CI is green, promotion (promoteOnGreen, default on) flips the cleanly-green draft to ready at Step 6 and the run continues into Phase B. With promotion disabled (--no-promote/promoteOnGreen: false), report and stop instead — AI review has not run yet, and the skill leaves the flip to the human.isDraft == false→ Phase A (confirm/clear CI), then Phase B.- Record
baseRefNamefor the drift checks andmergeStateStatusfor conflict detection.
Step 2 — Phase A: inspect failing checks
gh pr checks <pr>
For each failed Actions check, resolve its run ID from the check's detailsUrl
(in statusCheckRollup) and read the failing step's logs:
gh run view <run-id> --log-failed
Capture the actual failing command and error lines, not just the check name. You are diagnosing a root cause, not pattern-matching a label.
Step 3 — Phase A: classify each failure (in-scope vs upstream)
git fetch origin <base>
git diff --name-only origin/<base>...HEAD # files this PR actually touches
- In-scope — the failure names files in this PR's diff, or is a lint / test / build failure reproducible on the branch head. Fix it (Step 4).
- Upstream / base drift — the job also fails on
origin/<base>independent of this diff, ormergeStateStatus == BEHIND, or the error names files the PR never touched. Remedy is to rebase/merge the base (Step 5), not to edit the failing code. - A failure that can only be "fixed" by weakening a gate is never in-scope — see Important rules.
Step 4 — Phase A: fix in-scope failures, one at a time
- Apply the smallest fix that addresses the root cause within the PR's scope.
- Re-run the specific failing command locally and read its exit code before
claiming it fixed (e.g.
pnpm lint,npx --yes skills-ref@0.1.5 validate ./skills/<name>, the failing test). Pin the version so the local check matches CI exactly and can't be rug-pulled. Evidence before claims — never assert a fix on "should" or "probably". - Commit with a Conventional Commit subject, then push. One fix → one verification → next fix.
Step 5 — Phase A: handle base-branch drift
Only when Step 3 classified the failure as upstream/behind:
git fetch origin <base>
git merge origin/<base> # or rebase, per the repo's convention
- Clean merge → push and re-watch (Step 6).
- Conflict → go to Merge conflicts below.
Step 6 — Phase A: re-watch CI until green or budget exhausted
gh pr checks <pr> --watch
-
After each push, watch the rollup to completion. Still red → loop back to Step 2.
-
Bound the loop by
maxCiRounds. When exhausted, stop and report the remaining failures as blockers rather than looping forever. -
Green and ready → continue to Phase B.
-
Green and draft, promotion disabled (
--no-promote/promoteOnGreen: false) → report green and stop. -
Green and draft, promotion enabled (default, or
--promote) → run the promotion gate before flipping. All three must hold:- Proven green — the green is this step's watched-rollup green (not pending / "no failures yet"); apply the same exit-code discipline Phase A already enforces, never greenwash to reach the flip.
- No unresolved human threads — run
node scripts/review-threads.mjs <pr> --bots "<config.reviewBots joined by commas>"and requirehumanThreadsempty. (On a draft,unresolvedThreadsis empty anyway — AI review hasn't run — so this gate is specifically about humans who reviewed the draft.) - No unresolved base drift — re-fetch
mergeStateStatusfresh right before the flip (gh pr view <pr> --json mergeStateStatus), not the Step 1 snapshot: an intervening Phase A push can have changed it. Require it notBEHIND/DIRTY(Phase A's Step 5 resolves in-scope drift; if it persists, do not promote — report it as a blocker).
All three pass →
gh pr ready <pr>, report the flip, then continue to Phase B (Step 7). The ready-flip and Phase B's pushes re-fire CI + AI review, and the whole loop stays bounded bymaxCiRounds. Any gate fails → do not flip; report green plus the specific reason it wasn't promoted, and stop. Under--dry-run, report that it would promote (or why not) and flip nothing. Under--ci-only, never promote — stop at green regardless of the knob.
Step 7 — Phase B: fetch unresolved review feedback
Run the bundled fetcher. Its path is relative to this skill's own directory
(the one holding this SKILL.md and config.json) — resolve it from there, not
from the consuming repo's root, or the run fails with ENOENT. The --bots
value is config.reviewBots joined by commas:
node scripts/review-threads.mjs <pr> --bots "claude,cursor,coderabbitai"
This fetcher is read-only (it only fetches and prints), so it has no
--dry-run flag — running it never changes anything. The write side is
respond-threads.mjs (Step 8), which is where --dry-run lives.
It prints minimal JSON with four groups:
unresolvedThreads— inline review threads (isResolved == false) raised by a configuredreviewBot, trimmed to{threadId, path, line, isOutdated, author, comments}. This is the actionable set.deferredThreads— the same shape, for bot threads already carrying our non-resolving defer marker (recorded at Step 8, not yet ticketed/resolved at Step 10). They are bucketed apart so a still-pending defer is not re-emitted as a fresh finding on the next pass, and so a fresh invocation — which holds no in-memory candidate list — can rediscover them at Step 10. Do not re-triage these in Step 8.humanThreads— the same shape, for unresolved threads not raised by a review bot. Surface these in the report for the human; do not auto-action them.aiSummaryComments— the sticky issue-level summary the review action posts viatrack_progress/use_sticky_comment. At most one per review bot is kept: the bot's first issue comment, upgraded to a later one carrying a sticky marker (walkthrough /use_sticky_comment/track_progress/ "Summary by …") if the first had none — so an "I'll review" ack, command acknowledgements, and chatter don't masquerade as the headline review. Surface it separately: it is an issue comment, not a review thread, so it has noisResolvedand never appears inunresolvedThreads. Missing it would mean missing the headline review.
Resolved threads are filtered out so the context stays small. Empty
unresolvedThreads, no AI summary, and no deferredThreads → report "no
actionable AI review feedback" and skip to Step 12. If only deferredThreads
remain, there is nothing to triage but their follow-ups still need minting — go
straight to Step 10.
Step 8 — Phase B: validate each finding before touching code
Apply the six-step reception (full rules in
references/review-discipline.md):
-
READ the finding in full — body plus the cited file and line.
-
UNDERSTAND what it claims and why; restate it for yourself.
-
VERIFY it against the actual codebase. Open the cited lines and confirm the issue is real and not already handled. Never trust the bot's framing.
-
EVALUATE — is it correct, in-scope, and not a YAGNI or architecture violation?
-
RESPOND symmetrically — every actioned thread ends replied-to and resolved, so nothing is resolved silently:
- Decline → reply with concise technical reasoning, then resolve.
- Accept → resolve only after the fix is pushed and its proving
command passes (and, when the PR is ready, that fix's CI round is green —
see Step 9), with a factual reply referencing the fixing commit
(
Addressed in <sha>.). WhenreplyOnAcceptisfalse, resolve without the reply. - Outdated (cited code is gone) → resolve without a reply.
- Defer (the finding is valid but out of scope for this PR — a
worthwhile follow-up, not a change to make here) → do not resolve it now.
Set it aside as a follow-up candidate, recording
{title, rationale, threadId, path, line}, and immediately mark the thread durably with the non-resolvingdefer-pendingdecision (below) — a reply carrying a hidden marker, but no resolve. That marker is what stops the thread being re-emitted as a fresh finding on the next pass (the fetcher buckets it intodeferredThreads) and lets a fresh invocation rediscover it. Candidates are captured as tracked Linear issues — only on explicit human approval — at Step 10, which then posts the final defer reply and resolves the thread. Never create an issue here.
No sycophancy ("You're absolutely right!", "Great point!") — state facts.
-
IMPLEMENT accepted findings one at a time (Step 9), then reply+resolve.
The bundled respond-threads.mjs is the write side (its path is relative to
this skill's directory, like review-threads.mjs). It builds the reply body
(carrying a hidden idempotency marker), honours replyOnAccept, and skips any
thread already bearing our marker, then runs the reply + resolve mutations. The
defer-pending decision is the exception: it posts a reply carrying a distinct,
non-resolving marker and does not resolve — leaving the thread open for
Step 10 while making the defer durable — and is idempotent against its own marker,
so recording the same candidate twice never double-posts. Pass --bots (the same
config.reviewBots list) so it classifies the thread's author and refuses to
action a human thread even if its id is passed by mistake. Add --dry-run to
preview without writing:
# accepted finding, after its fix is pushed and proven/green:
node scripts/respond-threads.mjs thread --thread <PRRT_id> --decision accept --sha <sha> --bots "claude,cursor,coderabbitai"
# declined finding:
node scripts/respond-threads.mjs thread --thread <PRRT_id> --decision decline --reason "<technical reasoning>" --bots "claude,cursor,coderabbitai"
# deferred finding, recorded at Step 8 — durably mark it, but do NOT resolve yet:
node scripts/respond-threads.mjs thread --thread <PRRT_id> --decision defer-pending --bots "claude,cursor,coderabbitai"
# deferred finding, after Step 10 mints its follow-up ticket — final reply + resolve:
node scripts/respond-threads.mjs thread --thread <PRRT_id> --decision defer --reference <issue-id> --bots "claude,cursor,coderabbitai"
respond-threads.mjs --help prints the full subcommand/flag usage, and
respond-threads.mjs --self-test runs the bundled offline assertions (no
network, no gh) — a quick way to confirm the script is healthy after install.
Resolving uses GitHub's GraphQL resolveReviewThread — the only per-thread
programmatic resolve, idempotent on an already-resolved thread. Do not use the
bulk @coderabbitai resolve: it resolves every CodeRabbit thread at once,
including declined or not-yet-handled ones (see
references/review-discipline.md).
Step 9 — Phase B: apply accepted fixes, then re-run Phase A
- Implement each accepted finding on its own; after each, freshly run the proving command and read its output and exit code before claiming it works.
- Commit and push, then return to Step 2 — a new push re-fires CI, and AI review re-fires too (the PR is ready), producing fresh threads and an updated sticky comment.
- Resolve an accepted thread only once that fix's CI round is green (Step 6), not optimistically on push — a fix that regresses in CI must not leave a resolved thread behind. Decline/outdated threads resolve immediately (no code rides on them).
- Convergence. Loop Phase B ↔ Phase A until CI is green and every bot
thread is handled — resolved-by-us (accept, post-CI-green), declined+resolved,
a human thread (never auto-actioned), or flagged as a follow-up candidate
(marked non-resolving with the
defer-pendingmarker, still open on purpose, settled at Step 10) — with no accepted fix still awaiting CI-green. Because each push re-triggers review, hidden markers are what make this terminate: accept/decline threads carry the resolving thread-ack marker and a deferred thread carries the non-resolvingdefer-pendingmarker from the moment it is recorded, so the fetcher skips both (accept/decline resolve out; deferred ones bucket intodeferredThreads). Only genuinely new findings are actioned. This closes the old gap where a deferred thread sat unresolved and unmarked between Step 8 and Step 10 and was re-triaged every pass. The whole loop stays bounded bymaxCiRounds.
Step 10 — Phase B: capture out-of-scope findings as follow-up issues
Once the thread loop has converged, gather every follow-up candidate — both
per-thread defers and issue-level findings judged valid-but-out-of-scope. Take
the union of two sources, deduplicated by threadId: the candidates flagged in
memory during Step 8, and the fetcher's deferredThreads bucket (threads
already bearing the defer-pending marker). The second source is what lets a fresh
invocation — one that started mid-loop with no in-memory list — still finish the
defers a previous run only got as far as marking. Reconstruct a marked thread's
{title, rationale, path, line} from its bucketed content. If there are none, skip
to Step 11.
Capture is opt-in and gated on explicit human approval — nothing is created
otherwise. It is disabled when config.linearTeamName is empty or the Linear MCP
server is unavailable; in that case (and whenever the human declines below), fall
back without creating anything: decline each per-thread candidate with concise
technical reasoning (out of scope; not tracked) and resolve it, and map each
issue-level candidate as out-of-scope with no ticket in the Step 11 summary.
When capture is enabled, present all candidates as a single batch and ask once
for explicit approval (default no — mirrors cleanup-repo's two-pass gate):
Proposed follow-up issues (none created yet):
1. Refactor fetch layer — thread on src/api.ts:42
2. Add retry backoff — CodeRabbit summary
Create these 2 issues in Linear? [y/N]
On a single explicit yes, create one Linear issue per candidate with
mcp__linear-server__save_issue — resolve the team by name (config.linearTeamName)
and the state by type (e.g. Todo/Backlog), never a hard-coded team key or
state id. Team keys get renamed and state ids differ per workspace, so a stale
literal silently targets the wrong team or fails; name/type always resolve:
team=config.linearTeamName;titlederived from the finding.description= the bot's rationale, a back-link to the PR and the specific thread/comment URL, and the originatingpath:line(literal newlines, British English).links=[{ url: <PR url>, title: "Source PR" }].labels=[config.followUpLabel]when set;project=config.followUpProjectwhen set;state=config.followUpStatewhen set (else the team default). Uselist_issue_statuses/list_projectsto resolve a configured state/project and fail loudly on a typo rather than filing in the wrong place.
Then write each created issue's id/URL back:
-
Per-thread candidate → post the final defer reply and resolve via the
deferdecision. This reply carries the resolving thread-ack marker and resolves the thread, superseding the earlier non-resolvingdefer-pendingmarker (the fallbackdeclinepath above does the same):node scripts/respond-threads.mjs thread --thread <PRRT_id> --decision defer --reference <issue-id> --bots "claude,cursor,coderabbitai" -
Issue-level candidate → carry it into Step 11 as
{ "title": "…", "status": "out-of-scope", "reference": "<issue-id>" }.
Under --dry-run, list the candidates that would be proposed and create nothing.
Step 11 — Phase B: acknowledge issue-level review comments
Findings that arrive as issue-level comments — Claude's whole-review comment,
CodeRabbit's sticky summary (aiSummaryComments from Step 7) — have no resolvable
per-finding thread, so the thread machinery above never touches them. Once the
thread loop has converged (and Step 10's capture has minted any follow-up tickets),
acknowledge them on the PR with one consolidated comment mapping each finding →
accepted (<sha>) / declined (<reason>) / out-of-scope (<ticket>):
node scripts/respond-threads.mjs summary --pr <pr> --findings '[{"title":"…","status":"accepted","reference":"<sha>"}]'
It carries a hidden marker and is upserted in place — a re-run edits the same comment rather than posting a duplicate. Acknowledge each issue-level finding only once here (not per sub-point of a checklist review — that is noise). Skip this step entirely when there were no issue-level findings to map.
Step 12 — Report
Summarise:
- Checks fixed, each with the failing command it addressed.
- Findings accepted and fixed (with the resolving commit).
- Findings declined, each with the technical reasoning given.
- Follow-up issues created (each with its Linear id/URL), or candidates that were proposed and declined (so nothing was created).
- Issue-level findings acknowledged in the consolidated comment.
- Base merges/rebases performed.
- Remaining blockers (if
maxCiRoundswas exhausted). - Final CI state, with the proving command's output.
- Any human review comments, surfaced for the human to handle.
- The PR's draft/ready state: when promotion fired, report the flip (draft → ready) and that Phase B then ran; otherwise a reminder that the state is unchanged — the human flips it (and, if promotion was enabled but a gate blocked it, the specific reason).
Merge conflicts
- Resolve only when the resolution is unambiguous and within the PR's scope (e.g. both sides touched disjoint hunks, or this branch's intent clearly supersedes).
- Abort and ask the human when intent is ambiguous: both sides changed the
same logical thing, the conflict reaches files outside the PR's scope, or
resolving needs a product decision. Run
git merge --abortand report the conflicting files. - Never resolve a conflict by deleting the other side's work just to make it compile.
Important rules
- Never greenwash. Never edit
.github/workflows/*, disable or loosen a lint rule, delete or skip a test, or relax a CI threshold to make a check pass. Fix the code, or report the failure as a blocker. - In-scope only. Fix what this PR's diff is responsible for; don't fix unrelated repo problems.
- Validate before implementing. Never apply a review suggestion without first verifying it against the codebase.
- AI bots only. Action only the configured
reviewBots; surface human comments but leave them for the human. - No sycophancy. Decline with technical reasoning, not flattery.
- Evidence before claims. Never say CI is green or a fix works without freshly running the proving command and reading its exit code.
- Draft → ready is guarded, and on by default.
promoteOnGreenis the single control for the flip, and an enabled config is the authorisation: with it on (the default) the skill flips the PR — only after a proven-green Phase A, with no unresolved human threads and no unresolved base drift — then continues into Phase B, without seeking a separate human sign-off. SetpromoteOnGreen: false/ pass--no-promoteto stop at green; an explicit user prompt or--promote/--no-promoteoverrides the config per run. Never greenwash to reach the flip;--ci-onlynever promotes. Merge stays a human action. - Bounded loops. Stop after
maxCiRoundsand escalate.
Error handling
gh auth statusfails → stop and tell the user to rungh auth login.- No PR for the branch → stop with "open one with
/send-itfirst". gh run view --log-failedunavailable (logs expired or run purged) → report the failing check by name without guessing its cause; do not fabricate a fix.- The review-thread fetcher exits non-zero (rate limit, permissions, GraphQL
error) → report it and fall back to
gh pr view <pr> --json reviews,comments. Never treat "couldn't fetch" as "no findings". - A finding cites a file or line that no longer exists (outdated thread) → note it
as outdated and resolve it without a code change (
--decision outdated). respond-threads.mjsexits non-zero (reply or resolve mutation fails on permissions) → fall back to a manualgh api graphqlreply with the reasoning rather than aborting; the marker convention still applies so a later run skips it.- The consolidated
summaryupsert can't find prior comments (REST page cap, ~100) → it posts a fresh comment; harmless, just avoid hand-deleting the marker so the next run can find and edit it.
Arguments
$ARGUMENTS
版本历史
- c3c0a1e 当前 2026-07-23 10:21


