Skip to content

/sdlc:pr-check

Generated from solutions/ontological/skills/pr-check/SKILL.md.

Classify the state of a single open or closed pull request into one of a fixed verdict enum — CLEAN, NEEDS-RESPONSE, CONFLICTS, CI-FAILED, 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:

  • MERGEDstate == "MERGED". The PR shipped.
  • CLOSEDstate == "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.
  • 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).
  • 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 must mirror the shape of the gh pr view --json selection above — same keys, same value types — so the classifier (Section 3) is fed an indistinguishable payload. 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 → emit CI-FAILED reason="<failing check name>". If multiple checks failed, the first failure in the array wins.

  5. reviewDecision == "CHANGES_REQUESTED" OR new comments since the cursor (see Section 4) → emit NEEDS-RESPONSE reason="<who, comment count>".

  6. 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.

The fixture is a JSON object whose keys match the gh pr view --json selection from Step 2:

{
"state": "OPEN | CLOSED | MERGED",
"mergeable": "MERGEABLE | CONFLICTING | UNKNOWN",
"mergeStateStatus": "CLEAN | DIRTY | BLOCKED | UNSTABLE | BEHIND | HAS_HOOKS | UNKNOWN",
"reviewDecision": "APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED | \"\"",
"headRefName": "task/example",
"baseRefName": "main",
"statusCheckRollup": [
{"name": "ci/build", "conclusion": "SUCCESS | FAILURE | TIMED_OUT | CANCELLED | STARTUP_FAILURE | NEUTRAL | SKIPPED"}
],
"mergedAt": "<ISO 8601 UTC | null>",
"comments": [
{"author": {"login": "user"}, "createdAt": "<ISO 8601 UTC>", "body": "..."}
],
"reviews": [
{"author": {"login": "user"}, "submittedAt": "<ISO 8601 UTC>", "state": "COMMENTED | APPROVED | CHANGES_REQUESTED"}
]
}

Missing arrays are treated as []; missing scalars as the appropriate empty value. The fixture intentionally mirrors the live gh shape so real and mock payloads are interchangeable.

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

  • 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.jsonreviewDecision: CHANGES_REQUESTED plus a third-party comment → NEEDS-RESPONSE.
  • conflicts.jsonmergeable: CONFLICTINGCONFLICTS.
  • ci-failed.json — a statusCheckRollup entry with conclusion: FAILURECI-FAILED.
  • merged.jsonstate: MERGED with a mergedAt timestamp → MERGED.
  • closed.jsonstate: CLOSED with no merge → CLOSED.

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/fixtures/.