Skip to content

/sdlc:pr-check

Generated from solutions/ontological/plugin/plugins/sdlc/skills/pr-check/pr-check.md.

Classify the state of a single open or closed pull request into one of a fixed verdict enum — CLEAN, BEHIND, NEEDS-RESPONSE, CONFLICTS, CI-FAILED, CI-BLOCKED, MERGED, CLOSED, or ERROR — plus a one-line reason. Non-interactive and read-only on both the repo and GitHub; the only file it writes is a per-PR cursor under .sdlc/pr-cursors/ (gitignored) used to detect new review comments between calls. Designed to be called per open PR from /sdlc:orchestrate; the parent dispatches on the verdict and never has to parse free-form PR state itself. Supports a —mock-state flag that swaps the live gh payload for a JSON fixture so the classifier and cursor-update path can be exercised deterministically under the eval harness.

  • Bash
  • Read
  • Write

Usage:

  • /sdlc:pr-check <pr-number> — classify the given PR. The number is a bare integer (38), not the #38 form.
  • /sdlc:pr-check <pr-number> --mock-state <path> — classify against a JSON fixture at <path> instead of calling GitHub. No network calls are made; the cursor file is still written. The reason string is suffixed with (mock) so harness assertions can distinguish mock runs from real ones. See “Mock mode” below for the fixture shape and use cases.
  • /sdlc:pr-check <pr-number> --cursor-dir <path> — override the cursor directory (default .sdlc/pr-cursors/). Useful in mock mode so harness runs don’t write to the real cursor location. Composes with --mock-state.

Read-only on the repo and on GitHub apart from this skill’s own cursor file under .sdlc/pr-cursors/ (or the override passed via --cursor-dir): no GitHub state mutation, no commits. Classify the PR and emit the verdict; the orchestrator acts on it.

The classification is performed by the pr classify op (the gh fetch, the priority-ordered classifier, the cursor read/write, and the verdict emission). Invoke that op as shown below; do not re-implement the classifier inline in a sub-agent. The prose below documents the contract the op honours.

Shell out to the op with the parsed arguments. The op writes the one-line verdict to stdout (see “Output contract” below) and exits 0 for every defined verdict, including ERROR:

Terminal window
${CLAUDE_PLUGIN_ROOT}/cli/sdlc pr classify <pr-number> \
[--mock-state <path>] [--cursor-dir <path>]

Forward the op’s stdout verbatim to the caller. Non-zero exit is reserved for catastrophic failures (e.g. cursor write blocked by filesystem permissions); the stderr describes the cause in that case.

Exactly one line on stdout, of the form:

<VERDICT> reason="<one-line description>"

Where VERDICT is one of:

  • MERGED — state == "MERGED". The PR shipped.
  • CLOSED — state == "CLOSED" with no merge. The PR was abandoned.
  • CONFLICTS — the head branch can’t merge cleanly into base.
  • CI-FAILED — at least one required check is failing or has failed, and that failure is not fully explained by a billing/spending-limit block (see CI-BLOCKED, which outranks nothing below it).
  • NEEDS-RESPONSE — review changes are requested, OR new review/issue comments have arrived since the last /sdlc:pr-check invocation for this PR (tracked via the cursor file). Outranks BEHIND: the person who would rebase the branch is the one who would respond to the feedback, so surface the feedback first — they can rebase in the same pass.
  • BEHIND — the head branch trails its base. Measured locally from origin/<base> and origin/<head>, never read off gh: GitHub only reports mergeStateStatus: BEHIND when branch protection requires an up-to-date branch, so without it the field stays silent however far a branch has drifted. The count is only as fresh as the last fetch — this skill does not fetch. Reached only when there is no new feedback to report (see NEEDS-RESPONSE above), so it advances the comment cursor like every other verdict.
  • CI-BLOCKED — every currently-failing check failed because GitHub never started the job (an account-level billing/spending-limit block, detected from the check run’s own annotations), not because of anything in the PR’s diff, AND nothing above this rule matched either (no conflicts, no real failure, no new feedback, not behind). A blocked-only CI state never hides real feedback: NEEDS-RESPONSE and BEHIND both outrank it. There is nothing for a review/respond round to fix, so the orchestrator does not dispatch pr-respond for this verdict — except a first-seen PR still gets its initial pr-review, the same as CLEAN/BEHIND would.
  • CLEAN — none of the above. The PR is waiting on review or merge.
  • ERROR — the PR could not be resolved (number not found, gh call failed, etc.). The reason carries the diagnostic.

The orchestrator keys off VERDICT only; the reason is for the human reading the digest log.

References:

  • ${CLAUDE_PLUGIN_ROOT}/conventions/commit-messages.md — not used by this skill (it never commits), kept for cross-reference only.

The remainder of this document defines the contract the pr classify op honours. The tests under tests/classify_pr.test.ts pin every documented verdict against the bundled fixtures.

The script accepts:

  • <pr-number> (required, bare integer — 38, not #38).
  • --mock-state <path> (optional). When present, the classifier reads PR state from the JSON file at <path> instead of calling gh. No gh calls are made on the mock path — not even the existence probe.
  • --cursor-dir <path> (optional). Overrides the cursor directory. Defaults to .sdlc/pr-cursors/. Composes with --mock-state but is also valid in real mode (e.g. tests that want to assert cursor writes without polluting the real location).

User-facing failure modes the script emits as ERROR lines (still exit 0 — the verdict is the contract, not the exit code):

  • Missing or non-numeric <pr-number> → ERROR reason="usage: /sdlc:pr-check <pr-number> [--mock-state <path>] [--cursor-dir <path>]".
  • --mock-state <path> not found or unparseable → ERROR reason="--mock-state <path>: <not found | malformed JSON>".
  • gh pr view <N> --json ... exits non-zero (real mode only) → ERROR reason="gh pr view <N> --json: <captured stderr>".

Real mode (no --mock-state). Exactly one JSON fetch so the rest of the classifier is fed by a single snapshot of PR state:

gh pr view <N> --json state,mergeable,mergeStateStatus,reviewDecision,headRefName,baseRefName,statusCheckRollup,mergedAt,comments,reviews,author

The author field carries {"login": "..."} and is used by the author-comment filter (see Section 4) when self-notes mode is configured.

Mock mode (--mock-state <path>). Read and parse <path> as JSON. The blob is shaped like PrFacts (lib/services/pr/forge/types.ts) — the SAME normalized, forge-agnostic shape every real-mode call is assembled into internally before it ever reaches the classifier (Section 3), not the raw gh pr view --json payload above. See “Mock mode” below for the canonical schema and the bundled fixtures.

Mock mode does NOT make any network calls. Missing arrays are treated as []; missing scalars are treated as the appropriate empty value (null, ""); do not error.

3. Classification — priority-ordered, first match wins

Section titled “3. Classification — priority-ordered, first match wins”

Apply these rules in order, stopping at the first match; lower-priority rules do not override an earlier match. A MERGED PR with a brand-new review comment is therefore still MERGED, not NEEDS-RESPONSE.

  1. state == "MERGED" → emit MERGED reason="merged at <mergedAt>; task=<headRefName>".

  2. state == "CLOSED" (unmerged) → emit CLOSED reason="closed without merge".

  3. mergeable == "CONFLICTING" OR mergeStateStatus == "DIRTY" → emit CONFLICTS reason="head branch <headRefName> conflicts with base <baseRefName>".

  4. Any entry in statusCheckRollup whose conclusion is FAILURE, TIMED_OUT, CANCELLED, or STARTUP_FAILURE, AND is NOT explained by a GitHub Actions “job was not started” annotation (an account-level billing/spending-limit block, not anything in the diff) → emit CI-FAILED reason="<failing check name>" (first such REAL failure in the array wins). A blocked-only CI state (every failing check is explained by that annotation) does NOT match this rule — it falls through to Rule 7 below, so it can never hide a stale branch or new feedback.

  5. reviewDecision == "CHANGES_REQUESTED" OR new comments since the cursor (see Section 4) → emit NEEDS-RESPONSE reason="<who, comment count>". Also outranks a blocked-only CI state, and outranks Rule 6 (BEHIND) below: the person who would rebase the branch is the one who would respond to the feedback, so surface the feedback first — they can rebase in the same pass.

  6. The head branch trails its base — origin/<headRefName> is behind origin/<baseRefName> by one or more commits → emit BEHIND reason="<n> commit(s) behind <baseRefName>". The count comes from git rev-list --left-right --count, not from gh; a count that cannot be measured (a ref this checkout has never fetched) is unknown and skips the rule rather than reading as zero. Outranks a blocked-only CI state. Reached only when Rule 5 above did not match (no new feedback), so it always advances the comment cursor.

  7. Otherwise:

    • If every failing check (there is at least one) is blocked → emit CI-BLOCKED reason="<blocked check name(s)>: check(s) never started — account/billing issue, not code". Reached only when nothing above claimed the verdict — no conflicts, no real failure, no new feedback, not behind.

    • Otherwise → emit CLEAN with a reason composed of two clauses separated by ; :

      • Rollup clause: no checks configured when statusCheckRollup is empty (length 0); checks passing when it has at least one entry and none failed.
      • Review clause: awaiting review when reviewDecision != "APPROVED"; approved, awaiting merge when reviewDecision == "APPROVED".

      For example: CLEAN reason="no checks configured; awaiting review", CLEAN reason="checks passing; approved, awaiting merge", etc. The verdict token remains CLEAN in every case — distinguishing “no checks” from “checks passing” is a reason-string refinement, not an enum expansion.

In mock mode, the script appends (mock) to the reason string so harness assertions can distinguish mock-generated verdicts from real ones. For example:

NEEDS-RESPONSE reason="reviewer-one, 1 new comment (mock)"

The verdict token itself is unchanged — only the trailing reason is suffixed.

The script maintains a per-PR cursor at <cursor-dir>/<pr-number>.json (where <cursor-dir> is .sdlc/pr-cursors/ by default, or the path passed via --cursor-dir) with this shape:

{
"last_seen_comment_at": "<ISO 8601 UTC>",
"last_invoked_at": "<ISO 8601 UTC>",
"self_posted_at": ["<ISO 8601 UTC>", "..."]
}

The default directory is auto-ignored by .sdlc/* in .gitignore; the script creates the parent dir on first write.

self_posted_at carries the createdAt timestamps of comments the orchestrator itself posted on this PR (via the wrapper documented in “Self-posted comment filter” below). The classifier filters comments and reviews whose timestamps match any entry in this list, so the orchestrator’s own replies do not trigger NEEDS-RESPONSE on the next tick. pr-check itself never appends to self_posted_at — it only reads, filters with, and preserves the field across writes. Forward compatibility: a pre-extension cursor file without self_posted_at is read as self_posted_at: [] (no migration needed).

The cursor write is unconditional. Mock mode does NOT skip it — exercising the cursor-update path is the whole point of having a mock mode. A --cursor-dir override in tests lets the harness isolate writes from the real .sdlc/pr-cursors/ location.

On every invocation, the script:

  1. Reads the cursor if present; treats absence as last_seen_comment_at: null (any existing comment counts as “new” on the first call).
  2. Computes the “new comments” set as every entry in comments[] plus reviews[] whose createdAt (or submittedAt for reviews) is later than last_seen_comment_at. Entries authored by an ignored automation bot are always excluded (see the normalized bot filter below). PR-author entries are excluded conditionally per the author-comment filter below.
  3. When the verdict is NEEDS-RESPONSE driven by new entries, the reason cites the newest such author and the count.
  4. Always updates last_seen_comment_at and last_invoked_at and writes the cursor back, regardless of the verdict. self_posted_at is preserved across the write unchanged — pr-check never extends it. The cursor’s job is to track what’s been seen, not what triggered a response.

Comments and reviews authored by automation are always dropped before the new-entries computation, independent of the author-comment filter below. The matcher is shape-normalized: it strips a trailing [bot] suffix off the author login before comparing, so it catches both the suffix-less login gh pr view --json (GraphQL) returns (github-actions) and the suffixed login gh api (REST) returns (github-actions[bot]). The built-in ignored set is github-actions and cloudflare-workers-and-pages; a project extends it via pr_check.ignored_authors in sdlc.yaml (entries may be written in either shape — both match).

Historical note: before T-7AR0 the filter compared the suffixed literal github-actions[bot] against the suffix-less GraphQL login, so it never matched in production and bot chatter (github-actions and cloudflare deploy-preview noise) flipped PRs to NEEDS-RESPONSE. The normalized matcher closes that gap and is shared verbatim with the orchestrate watch pending-work gate so both surfaces filter identically.

The script reads pr_check.author_comments from <project-root>/sdlc.yaml. Accepted values: actionable (default) and self-notes.

  • actionable (default) — keep PR-author entries; they count as actionable feedback.
  • self-notes — exclude entries whose author.login matches the PR’s author.login (carried on the top-level author field from the gh fetch).

Missing file, missing block, or missing key all default to actionable. Any other value: the script logs a one-line warning to stderr and falls back to actionable rather than erroring — a misconfigured project should still get a verdict.

See ${CLAUDE_PLUGIN_ROOT}/conventions/sdlc-yaml.md for the canonical documentation of the pr_check: block.

Orchestrator-authored replies (posted from /sdlc:orchestrate’s NEEDS-RESPONSE dispatch sub-agents) authenticate as the user’s GitHub identity, so they are indistinguishable from human comments at the JSON layer. The cursor’s self_posted_at: list[str] field — see the schema above — filters them out: comment / review entries whose createdAt (or submittedAt for reviews) matches any timestamp in this list are dropped before the new-entries computation. A pre-extension cursor without the field reads as [], so the filter is safe over existing cursor files without a migration step.

Orchestrator sub-agents MUST post via the co-located wrapper, not the raw GitHub-CLI comment command — a raw post records no timestamp, so the next tick re-fires NEEDS-RESPONSE on the orchestrator’s own reply:

Terminal window
${CLAUDE_PLUGIN_ROOT}/skills/pr-check/post_self_comment.sh <pr-number> <body>

The wrapper posts the comment via gh, captures the resulting createdAt, and appends that timestamp to the PR’s cursor’s self_posted_at array (creating the cursor file with a null last_seen_comment_at sentinel if absent). It uses jq for the cursor manipulation and exits non-zero on any failure (missing deps, post failure, timestamp-capture failure, cursor write failure) so the dispatching sub-agent can surface the error rather than silently posting without recording.

The script prints the single verdict line on stdout (no leading whitespace, no trailing newline beyond the natural one). Exit 0 for every defined verdict — including ERROR. Non-zero exit is reserved for catastrophic failures (e.g. cursor write blocked by filesystem permissions), in which case stderr describes the cause.

--mock-state <path> swaps the live gh payload for a JSON fixture so the classifier and cursor-update path can be exercised deterministically. Use it under the eval harness, in CI tests, and anywhere a real PR isn’t available.

behind is the one key with no gh counterpart: a live run measures drift with git, and a mock run must not shell out, so the fixture carries the count itself. Omitting it means “unknown”, which skips the BEHIND rule.

The fixture is a JSON object shaped like PrFacts (lib/services/pr/forge/types.ts) — the normalized shape assemblePrFacts produces from EITHER forge adapter, plus behind/ahead:

{
"number": 1,
"url": "https://github.com/example/example/pull/1",
"title": "example",
"state": "open | closed | merged",
"isDraft": false,
"author": {"login": "user", "isBot": false},
"baseRef": "main",
"headRef": "task/example",
"headSha": "<sha | null>",
"mergedAt": "<ISO 8601 UTC | null>",
"mergeable": "mergeable | conflicting | unknown",
"checks": {
"rollup": "none | pending | success | failure",
"failing": ["ci/build"],
"failingUrls": [{"name": "ci/build", "url": "<log URL>"}],
"blocked": ["<subset of failing that never actually started — a billing/spending-limit block, not a real failure>"]
},
"reviewDecision": "approved | changes_requested | review_required | null",
"activity": [
{"id": "...", "kind": "comment | review | review-comment", "author": {"login": "user", "isBot": false}, "at": "<ISO 8601 UTC | null>", "editedAt": "<ISO 8601 UTC | null>", "body": "...", "reviewState": "<review state | null>", "location": null}
],
"behind": "<integer | null>",
"ahead": "<integer | null>"
}

Missing arrays are treated as []; missing scalars as the appropriate empty value. The fixture is fed to the classifier exactly as a real forge call’s assembled PrFacts would be — there is no separate mock-vs-real code path past this point.

One fixture per documented verdict lives at ${CLAUDE_PLUGIN_ROOT}/skills/pr-check/mock-states/:

  • clean.json — open, mergeable, statusCheckRollup has one passing check → CLEAN reason="checks passing; awaiting review".
  • clean-no-checks.json — open, mergeable, statusCheckRollup is empty → CLEAN reason="no checks configured; awaiting review". Distinguishes “no CI configured” from “CI passed” without a new verdict token.
  • needs-response.json — reviewDecision: CHANGES_REQUESTED plus a third-party comment → NEEDS-RESPONSE.
  • conflicts.json — mergeable: CONFLICTING → CONFLICTS.
  • ci-failed.json — a statusCheckRollup entry with conclusion: FAILURE → CI-FAILED.
  • ci-blocked.json — one failing check (ci/test), also listed in checks.blocked (every failing check is a billing/spending-limit block, not a real failure) → CI-BLOCKED.
  • merged.json — state: MERGED with a mergedAt timestamp → MERGED.
  • closed.json — state: CLOSED with no merge → CLOSED.
  • behind.json — clean.json plus behind: 12. Every gh field says the PR is fine; only the local count says otherwise → BEHIND reason="12 commits behind main".

The eval harness asserts each fixture produces its expected verdict via tests/classify_pr.test.ts, which exercises both the in-process classifier and the CLI surface for every bundled fixture.

If gh is unavailable, the user is unauthenticated, or the PR doesn’t exist, the skill emits ERROR reason="<reason>" and exits 0. The orchestrator treats ERROR like CLEAN (no action) and surfaces the PR for human attention in its digest.

If the cursor file is malformed JSON, treat it as missing (the worst case is one spurious NEEDS-RESPONSE while it’s repaired). Log to stderr and continue.

  • Read-only on the repo and on GitHub except for .sdlc/pr-cursors/ writes. No commits to the repo, no GitHub state mutation.
  • One PR per invocation. The orchestrator iterates; this skill does not fan out.
  • The cursor lives outside version control, so a fresh clone has no history of “what I already saw”. On a new machine the first tick surfaces every PR as NEEDS-RESPONSE once if it has any third-party comments; subsequent ticks settle.
  • Flaky CI is not distinguished from real failures — any failing check name in statusCheckRollup flips the verdict to CI-FAILED. Retrying flakes is the orchestrator’s call.
  • Both MERGED and CLOSED flow to /sdlc:task-close-out in the orchestrator; this skill does not collapse them, so the close-out skill can record the resolution kind correctly.
  • --mock-state swaps the gh fetch for a JSON fixture; absent the flag, the default gh-backed path runs. Mock mode is detectable from the (mock) suffix on the emitted reason string. See “Mock mode” above for the fixture schema and the bundled fixtures under ${CLAUDE_PLUGIN_ROOT}/skills/pr-check/mock-states/.