Use when addressing PR review threads on a GitHub PR — replying to review comments, resolving threads after fixes, looping through reviewer feedback, applying or counter-proposing suggestion blocks, sweeping outdated threads, checking PR mergeability, or discovering which PRs are open on a repository. Applies when the `spn` CLI is on PATH (`command -v spn`). Prefer spn over hand-rolled `gh api graphql` for review-thread work.
76
95%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Low
Low-risk findings worth noting
spn for PR Review Threadsspn is an agent-shaped CLI for GitHub PR review threads. Bare JSON on stdout, structured error envelopes on stderr, deterministic exit codes. Built for jq pipelines and shell loops.
Reach for spn when:
Do NOT use for: GitLab MRs (unsupported), issue comments, PR descriptions, file reviews. spn is GitHub PR review threads only.
First check: command -v spn — fall back to gh api graphql only if absent. If spn IS available, do not hand-roll a GraphQL script.
PR="owner/repo#42"
while true; do
thread=$(spn threads next "$PR")
[ "$thread" = "null" ] && break
id=$(jq -r .id <<<"$thread")
requires_body=$(jq -r .requiresBody <<<"$thread")
path=$(jq -r .path <<<"$thread")
line=$(jq -r .line <<<"$thread")
body=$(jq -r .comments[0].body <<<"$thread")
# ... address the feedback in code ...
if [ "$requires_body" = "true" ]; then
spn threads resolve "$PR" "$id" --body "Addressed in $(git rev-parse --short HEAD)" || break
else
spn threads resolve "$PR" "$id" || break
fi
donespn threads next returns the oldest unresolved thread, sorted by (firstCommentCreatedAt, threadID) for determinism, or null when none remain.
For richer per-iteration context, add --show-code N and --verbose to the spn threads next call (see Code context and verbose mode).
After you've fixed all the issues in subsequent commits and just need to clear the noise:
spn threads resolve-all owner/repo#42
# → {"succeeded": [...], "failed": [...], "skipped": [...]}skipped carries threads spn refused to bulk-resolve because they have a human commenter — those need individual spn threads resolve <pr-ref> <thread-id> --body "..." calls with real per-thread explanations.
| Case | Where | Shape |
|---|---|---|
| Success — single | stdout | one JSON object (or null for threads next empty) |
| Success — collection | stdout | JSON array |
| Failure | stderr | {"error": {"code", "message", "remediation", "retryable", "details"}} |
Stdout is exclusively success data. A failing command writes nothing to stdout.
spn threads resolve returns the resolved thread object (same shape as threads next), not a bulk envelope; only resolve-all / unresolve-all return {succeeded, failed, skipped}.
Threads may carry these optional fields when the corresponding flag is set:
dryRun — true when the mutation was previewed rather than issuedisOutdated (always present after PR #7) — true when the anchored code has shiftedsuggestions — non-empty when the thread contains suggestion fenced blockscodeContext — populated by --show-code NcreatedAt, updatedAt, authorUrl — populated by --verboseExit codes:
0 — success2 — user / policy error (bad_input, auth_required, auth_scope_missing, policy_violation) — do not retry1 — everything else (upstream_error, rate_limited, not_found, internal) — check error.retryable before retryingWhen GitHub rate-limits a request, the error envelope uses code: "rate_limited", populates retry_after_seconds, and carries details.reset_at (RFC3339 UTC) plus details.remaining:
{"error": {
"code": "rate_limited",
"message": "rate limit exceeded",
"remediation": "Rate limit exceeded. Wait until <details.reset_at> ...",
"retryable": true,
"retry_after_seconds": 1234,
"details": {"reset_at": "2026-05-11T14:30:00Z", "remaining": 0}
}}Retry pattern:
out=$(spn pr status "$PR" 2>/tmp/err.json) || {
code=$(jq -r .error.code /tmp/err.json)
if [ "$code" = "rate_limited" ]; then
wait=$(jq -r .error.retry_after_seconds /tmp/err.json)
sleep "$wait"
out=$(spn pr status "$PR")
fi
}Note: detection works on REST API paths. GitHub's GraphQL endpoint (used internally by spn threads list/next/reply/resolve and the forks-list GraphQL fast path) returns rate-limit hits as the generic upstream_error code instead. The error remains retryable: true in both cases; the difference is whether retry_after_seconds is populated.
spn forks list <repo> --csv collects all enriched forks and emits a single CSV blob on stdout with a fixed 24-column header covering identity, T1, T2, T3, cluster, and shortlist rank fields — the six rank columns (expected_rank,p_score,p_top_k,p_first,rank_lo,rank_hi) are empty without --shortlist. Per-fork enrichment errors still go to stderr as compact JSON. Use this when downstream tooling expects tabular data; use the default NDJSON when streaming or jq pipelines fit better.
Fork work is a different use case — see the using-spn-forks skill for the full flag surface.
spn threads list <pr> --filter <mode> selects which threads to surface.
| Mode | Includes |
|---|---|
all | every thread, resolved + unresolved, active + outdated |
unresolved (default) | every unresolved thread |
current-unresolved | unresolved AND not outdated — the most urgent set |
resolved-active | resolved threads whose anchored code is still active |
unresolved-outdated | unresolved threads whose anchored code has shifted (sweep candidates) |
Rule of thumb: unresolved (default) for active review work; current-unresolved to exclude stale threads; unresolved-outdated to find sweep candidates before bulk-resolving.
--all is shorthand for --filter all. Passing it with a different --filter value is bad_input exit 2 (--all conflicts with --filter <mode> (drop one)); --all --filter all agrees and is accepted.
The thread JSON has a requiresBody boolean. It is true unless every comment on the thread is authored by a Bot (an empty thread also counts as true), and spn threads resolve then refuses with policy_violation exit 2 unless --body is supplied. Bot-only threads (requiresBody: false) resolve without a body.
The rule the agent should internalize: a thread raised by a human gets an explanation when resolved. Either what was fixed, or why no change was needed.
--body-file PATH is accepted anywhere --body is (on reply and resolve) and reads the body from disk — use it for multi-line explanations rather than fighting shell quoting. --body wins when both are given. On reply, mixing either with --suggest / --suggest-file is bad_input exit 2 — a reply is prose or a suggestion block, not both.
spn threads resolve-all <pr> --outdated resolves only threads where isOutdated: true. Combined with the body-required policy:
succeededskipped with reason: "not_outdated"skipped with reason: "requires_body"Precedence matters: the outdated filter runs first, so a human-raised thread whose anchor is still active reports not_outdated, not requires_body. Don't branch on the reason to decide whether a human is involved — read requiresBody on the thread itself.
Two-step pattern: preview, then act.
spn threads list "$PR" --filter unresolved-outdated # what would be swept
spn threads resolve-all "$PR" --outdated # do itThe thread JSON has an isOutdated boolean. An outdated thread anchors to code that has since shifted; the comment may be moot. Agents reviewing a thread should check isOutdated before deciding whether a fix is still relevant.
BulkSkip.Reason values| Reason | Meaning |
|---|---|
requires_body | Bulk-resolve refused because the thread has a human commenter — resolve individually with --body |
not_outdated | Bulk-resolve with --outdated refused because the anchor is still active (checked first, so it masks requires_body) |
--outdated exists on resolve-all only; unresolve-all rejects it.
Two opt-in flags add detail to the thread JSON without changing the default shape. They are accepted on list, next, and resolve only — reply, resolve-all, unresolve-all, apply-suggestion, and list-prs reject them with bad_input exit 2.
--show-code N adds a codeContext block per thread, containing N lines on either side of the anchor (--show-code=N is equivalent):
{
"id": "PRRT_...",
"path": "internal/foo.go",
"line": 42,
"codeContext": {
"ref": "deadbeef",
"startLine": 36,
"endLine": 48,
"lines": ["...", "...", "..."]
}
}Cost: one extra FetchFileContent call per unique (path, ref) pair — the caching wrapper collapses duplicates across threads anchored to the same file.
--verbose (alias -v) adds createdAt, updatedAt, and authorUrl to each thread. Useful when grounding a reply in commit history or building per-author dashboards.
-v means two different things depending on position. Before a noun it is the global version flag (spn -v prints spn 0.1.0-dev and exits 0); after a verb it is --verbose. The other global, --no-color, must likewise precede the noun — after the noun (or --) it is passed to the command unchanged and will be rejected as an unknown flag.
Combined call for a grounded review loop:
spn threads next "$PR" --show-code 6 --verboseReturns enough context to write a referenced reply without a separate gh api call.
GitHub review threads can embed suggestion fenced blocks (suggestion … ) that propose replacement code. spn parses them into a per-thread suggestions array:
{
"suggestions": [
{"commentId": "PRC_...", "body": "newCode()", "applicable": true}
]
}applicable is true when the thread has a path + line range so apply-suggestion can write to the file. Suggestions appear in document order within a comment; pick by position via --suggestion-index N.
spn threads apply-suggestion "$PR" "$id"Writes the replacement to the local file at the thread's anchor. Flags:
| Flag | Effect |
|---|---|
--suggestion-index N | Pick the N-th suggestion when the thread has multiple (default 0) |
--dry-run | Print what would change as JSON; do not write the file |
--force | Apply even when the target file has uncommitted changes (default refuses) |
--repo-root PATH | Anchor for relative path resolution (default pwd) |
The verb refuses if the path traverses outside the repo root.
spn threads reply --suggest BODY (or --suggest-file PATH) wraps the body in a suggestion fenced block before posting:
spn threads reply "$PR" "$id" \
--intro "Narrower scope here:" \
--suggest "return ctx.Err()"--intro TEXT prefixes a leading line of prose before the suggestion block.
When working interactively in the spoon TUI, press [c] to open $EDITOR for a counter-proposal. The wrapper applies WrapSuggestionBody and posts via the same path.
spoon --apply-suggestion <id> (flag) calls into the same threadsops.ApplySuggestion that spn threads apply-suggestion (verb) uses. The flag/verb difference is the human/agent bifurcation; both produce identical results.
--dry-run is available on resolve, resolve-all, unresolve-all, and apply-suggestion. Output shape matches the non-dry-run path with an added dryRun: true field:
{"succeeded": ["PRRT_..."], "failed": [], "skipped": [], "dryRun": true}On resolve the preview is the thread object with isResolved flipped to true alongside dryRun: true. Check dryRun before believing isResolved — nothing was mutated.
Resolving an already-resolved thread is idempotent: exit 0, the thread comes back with isResolved: true, no mutation is issued, and no dryRun marker appears even under --dry-run (the outcome is the same either way). Safe to re-run a loop over a partially-processed PR.
Gating pattern:
preview=$(spn threads resolve-all "$PR" --outdated --dry-run)
count=$(jq -r '.succeeded | length' <<<"$preview")
if [ "$count" -gt 0 ]; then
spn threads resolve-all "$PR" --outdated
fispn threads resolve <pr-ref> <thread-id> --body T is internally two GraphQL mutations: post the comment, then resolve. If the reply succeeds but the resolve mutation fails:
{"error": {
"code": "upstream_error",
"retryable": true,
"details": {"comment_posted": true, "comment_id": "PRC_..."}
}}The retry must omit --body, otherwise the comment double-posts. Capture the error and check comment_posted:
if ! spn threads resolve "$PR" "$id" --body "$msg" 2>/tmp/err.json; then
comment_posted=$(jq -r '.error.details.comment_posted // false' /tmp/err.json)
if [ "$comment_posted" = "true" ]; then
spn threads resolve "$PR" "$id" # no --body — comment is already on the thread
else
spn threads resolve "$PR" "$id" --body "$msg" # transient; retry with body
fi
fispn recognizes when the most recent comment is the agent's own resolution comment and skips the body-required gate on the retry.
Resolving threads doesn't merge a PR. Check the gate:
spn pr status owner/repo#42Returns {title, isDraft, merged, mergeable, mergeStateStatus, reviewDecision, checksState, unresolvedThreads, outdatedThreads, headSHA}. If mergeStateStatus is DIRTY / BLOCKED or checksState is FAILURE, threads aren't the blocker.
outdatedThreads counts threads whose anchors have shifted across the whole PR — a cheap pre-check for whether a resolve-all --outdated sweep has anything to do. headSHA is the PR head commit.
All commands accept any of:
owner/repo#42https://github.com/owner/repo/pull/42#42 (resolves owner/repo from the origin remote of the current git checkout)(list-prs takes <owner/repo> without the #N suffix, since the whole point is discovering PR numbers.)
Each list-prs record is {number, title, author, headBranch, state, url, createdAt, updatedAt}. --limit N caps the result count; --state accepts only open today — any other value is bad_input exit 2 rather than a silent fallback.
| Mistake | What to do instead |
|---|---|
Hand-rolling gh api graphql for review threads | Use spn — it handles partial-failure dedup, the body-required gate, and deterministic ordering you'd otherwise have to recreate. |
spn threads reply <pr-ref> <id> --body T && spn threads resolve <pr-ref> <id> (two calls) | spn threads resolve <pr-ref> <id> --body T (one atomic call with dedup-safe retry). |
| Bulk-resolving when human reviewers commented | spn threads resolve-all skips them into skipped; resolve each individually with a real per-thread explanation. |
Retrying resolve --body T with the same body after a partial failure | Check error.details.comment_posted; if true, retry with no --body. |
Reading PR thread state via gh pr view --comments (HTML/scraped) | spn threads list is JSON with stable IDs and the policy flag baked in. |
Trusting unresolvedThreads: 0 as "ready to merge" | spn pr status also returns mergeStateStatus and checksState — both must be green. |
Treating rate_limited as upstream_error | Check code explicitly — rate_limited has a known retry_after_seconds. Sleeping that long is reliable; blind retry on upstream_error may keep hitting the limit. |
| Resolving outdated threads individually | Use spn threads resolve-all <pr> --outdated to sweep them in one call. Preview with --dry-run first. |
Ignoring isOutdated when deciding to reply | Outdated threads anchor to code that has since shifted; the comment may no longer be relevant. Check the field. |
Calling gh pr list then spn | Use spn threads list-prs <owner/repo> for one round trip in the agent's preferred JSON shape. |
Assuming a flag is cross-cutting (--dry-run on list, --show-code on reply) | The flag surfaces are per-verb; an unsupported flag is bad_input exit 2, not a no-op. See the Quick Reference modifier rows. |
| Command / modifier | Use |
|---|---|
spn threads list <pr> | All unresolved threads as a JSON array (or --all for resolved too) |
spn threads next <pr> | Oldest unresolved thread, or null |
spn threads reply <pr> <id> --body T | Post a comment without resolving |
spn threads resolve <pr> <id> [--body T] | Resolve (atomically post body if any). Body required for human threads. |
spn threads resolve-all <pr> | Bulk-resolve bot threads; human threads land in skipped |
spn threads unresolve-all <pr> | Re-open every resolved thread |
spn pr status <pr> | Mergeability snapshot |
spn threads list-prs <owner/repo> | JSON array of open PRs on a repo. Programmatic alternative to spoon's --interactive picker. |
spn threads list <pr> --filter MODE | Filter modes: all, unresolved, current-unresolved, resolved-active, unresolved-outdated. |
spn threads apply-suggestion <pr> <id> | Apply a thread's suggestion block to the local file. Use --dry-run first. |
spn threads reply <pr> <id> --suggest T | Reply that wraps T in a suggestion fenced block. Optional --intro for context. |
--dry-run | On resolve, resolve-all, unresolve-all, apply-suggestion only. Previews the mutation; output gains dryRun: true. |
--show-code N / --verbose (-v) | On list, next, resolve only. Code context around the anchor / extra thread metadata. |
spn --no-color <noun> … / spn -v | Globals, and they must precede the noun. -v before a noun is version, after a verb it is verbose. |
spn forks list <repo> | NDJSON fork enrichment (separate use case, not for PR review) |
spn forks list <repo> --csv | Batched CSV with fixed header; switches off NDJSON streaming. Use for spreadsheet/tabular consumers. |
a302f23
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.