pr-shepherd 0.56.1 → 0.56.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +7 -4
  3. package/bin/api.d.mts +11 -1
  4. package/bin/api.mjs +25 -0
  5. package/bin/checks/classify.d.mts +5 -6
  6. package/bin/checks/classify.mjs +5 -7
  7. package/bin/checks/superseded.d.mts +4 -17
  8. package/bin/checks/superseded.mjs +67 -43
  9. package/bin/cli/args.mjs +3 -0
  10. package/bin/cli/fix-formatter.mjs +3 -0
  11. package/bin/cli/help-command-pages.d.mts +17 -2
  12. package/bin/cli/help-command-pages.mjs +16 -1
  13. package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
  14. package/bin/cli/help-iterate-poll-pages.mjs +1 -1
  15. package/bin/cli/help-top-page.d.mts +1 -1
  16. package/bin/cli/help-top-page.mjs +2 -0
  17. package/bin/cli/help.d.mts +18 -3
  18. package/bin/cli/help.mjs +2 -0
  19. package/bin/cli/iterate-instructions.mjs +4 -1
  20. package/bin/cli/iterate-lean.mjs +3 -0
  21. package/bin/cli/iterate-merge-formatter.mjs +2 -0
  22. package/bin/cli/queue-removal-handler.d.mts +2 -0
  23. package/bin/cli/queue-removal-handler.mjs +86 -0
  24. package/bin/cli-parser.mjs +4 -0
  25. package/bin/commands/apply-queue-removal.d.mts +18 -0
  26. package/bin/commands/apply-queue-removal.mjs +44 -0
  27. package/bin/commands/check-fingerprint.mjs +2 -0
  28. package/bin/commands/check.mjs +13 -1
  29. package/bin/commands/iterate/check-evidence.d.mts +4 -0
  30. package/bin/commands/iterate/check-evidence.mjs +8 -0
  31. package/bin/commands/iterate/fix-code.mjs +20 -4
  32. package/bin/commands/iterate/index.mjs +15 -1
  33. package/bin/commands/iterate/merge-state.mjs +4 -1
  34. package/bin/commands/iterate/merge.d.mts +7 -1
  35. package/bin/commands/iterate/merge.mjs +59 -1
  36. package/bin/github/gql/poll-summary-check-contexts.gql +2 -0
  37. package/bin/github/poll-summary-checks.mjs +22 -18
  38. package/bin/github/poll-summary-fingerprint.mjs +7 -0
  39. package/bin/github/poll-summary-raw.d.mts +2 -0
  40. package/bin/github/poll-summary-readiness.mjs +11 -1
  41. package/bin/mcp/server.mjs +18 -0
  42. package/bin/state/queue-removal-ack.d.mts +19 -0
  43. package/bin/state/queue-removal-ack.mjs +66 -0
  44. package/bin/types/iterate.d.mts +4 -0
  45. package/bin/types/merge-queue.d.mts +2 -0
  46. package/package.json +2 -2
  47. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  48. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  49. package/plugins/pr-shepherd/.mcp.json +1 -1
  50. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -2
  51. package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +1 -0
@@ -1,10 +1,9 @@
1
1
  import { classifyChecks } from "../checks/classify.mjs";
2
+ import { parseCreatedAt } from "./batch-parser-helpers.mjs";
2
3
  export function summarizePollSummaryChecks(raw) {
3
4
  const rollups = summaryRollups(raw);
4
5
  const counts = {};
5
- for (const check of classifyChecks(checkRuns(rollups), {
6
- additionalRelevantEvents: ["merge_group"],
7
- })) {
6
+ for (const check of classifiedSummaryChecks(raw)) {
8
7
  const key = {
9
8
  passed: "passing",
10
9
  failing: "failing",
@@ -17,33 +16,36 @@ export function summarizePollSummaryChecks(raw) {
17
16
  counts[key] = (counts[key] ?? 0) + 1;
18
17
  }
19
18
  const summary = counts;
20
- if (rollups.some((rollup) => rollup.contexts.pageInfo.hasPreviousPage)) {
19
+ if (rollups.some(({ rollup }) => rollup.contexts.pageInfo.hasPreviousPage)) {
21
20
  summary.incomplete = true;
22
21
  }
23
22
  return summary;
24
23
  }
25
24
  /** Names of failing checks in the loaded summary rollup. */
26
25
  export function failingSummaryCheckNames(raw) {
27
- return classifyChecks(checkRuns(summaryRollups(raw)), {
28
- additionalRelevantEvents: ["merge_group"],
29
- })
26
+ return classifiedSummaryChecks(raw)
30
27
  .filter((check) => check.category === "failing")
31
28
  .map((check) => check.name);
32
29
  }
33
30
  function summaryRollups(raw) {
34
31
  return [
35
- raw.commits.nodes[0]?.commit.statusCheckRollup,
36
- raw.mergeQueueEntry?.headCommit?.statusCheckRollup,
37
- ].filter((rollup) => rollup !== null && rollup !== undefined);
32
+ { rollup: raw.commits.nodes[0]?.commit.statusCheckRollup, scope: undefined },
33
+ {
34
+ rollup: raw.mergeQueueEntry?.headCommit?.statusCheckRollup,
35
+ scope: "merge_group",
36
+ },
37
+ ].flatMap(({ rollup, scope }) => (rollup ? [{ rollup, scope }] : []));
38
38
  }
39
- function checkRuns(rollups) {
40
- return rollups.flatMap((rollup, rollupIndex) => rollup.contexts.nodes.map((context) => {
39
+ function classifiedSummaryChecks(raw) {
40
+ // A successful check on the queue commit cannot cover a PR-head cancellation, or vice versa.
41
+ return summaryRollups(raw).flatMap(({ rollup, scope }) => classifyChecks(checkRuns(rollup, scope), scope === "merge_group" ? { additionalRelevantEvents: ["merge_group"] } : {}));
42
+ }
43
+ function checkRuns(rollup, scope) {
44
+ return rollup.contexts.nodes.map((context) => {
41
45
  if (context.__typename === "StatusContext") {
42
46
  return {
43
47
  name: context.context,
44
- status: context.state === "PENDING" || context.state === "EXPECTED"
45
- ? "IN_PROGRESS"
46
- : "COMPLETED",
48
+ status: context.state === "PENDING" || context.state === "EXPECTED" ? "IN_PROGRESS" : "COMPLETED",
47
49
  conclusion: context.state === "SUCCESS"
48
50
  ? "SUCCESS"
49
51
  : context.state === "FAILURE" || context.state === "ERROR"
@@ -53,7 +55,7 @@ function checkRuns(rollups) {
53
55
  detailsUrl: "",
54
56
  event: null,
55
57
  runId: null,
56
- ...(rollupIndex === 1 && { scope: "merge_group" }),
58
+ ...(scope && { scope }),
57
59
  };
58
60
  }
59
61
  const run = context.checkSuite?.workflowRun;
@@ -70,7 +72,9 @@ function checkRuns(rollups) {
70
72
  ...(run?.workflow?.databaseId != null && {
71
73
  workflowId: String(run.workflow.databaseId),
72
74
  }),
73
- ...(rollupIndex === 1 && { scope: "merge_group" }),
75
+ ...(context.startedAt && { startedAtUnix: parseCreatedAt(context.startedAt) }),
76
+ ...(context.completedAt && { completedAtUnix: parseCreatedAt(context.completedAt) }),
77
+ ...(scope && { scope }),
74
78
  };
75
- }));
79
+ });
76
80
  }
@@ -8,6 +8,10 @@ import { createHash } from "node:crypto";
8
8
  export function fingerprintRawSummaryPr(raw) {
9
9
  if (!raw.updatedAt || !raw.headRefOid || !raw.baseRefOid)
10
10
  return null;
11
+ const reviewTruncated = raw.comments.pageInfo.hasPreviousPage ||
12
+ raw.reviews.pageInfo.hasPreviousPage ||
13
+ raw.reviewThreads.pageInfo.hasPreviousPage ||
14
+ raw.reviewThreads.nodes.some((thread) => thread.comments.pageInfo.hasPreviousPage);
11
15
  const evidence = {
12
16
  number: raw.number,
13
17
  state: raw.state,
@@ -19,6 +23,9 @@ export function fingerprintRawSummaryPr(raw) {
19
23
  reviewDecision: raw.reviewDecision,
20
24
  reviewRequests: raw.reviewRequests,
21
25
  latestReviews: raw.latestReviews,
26
+ // Omitted bodies cannot participate in the hash. Bind truncated evidence
27
+ // to the PR revision instead, so updates require a fresh full review poll.
28
+ ...(reviewTruncated && { reviewUpdatedAt: raw.updatedAt }),
22
29
  comments: hideBodies(raw.comments),
23
30
  reviews: hideBodies(raw.reviews),
24
31
  reviewThreads: {
@@ -26,6 +26,8 @@ type RawCheckContext = {
26
26
  status: string;
27
27
  conclusion: string | null;
28
28
  detailsUrl?: string;
29
+ startedAt?: string | null;
30
+ completedAt?: string | null;
29
31
  annotations?: {
30
32
  totalCount: number;
31
33
  };
@@ -1,4 +1,6 @@
1
1
  import { summarizePollSummaryChecks } from "./poll-summary-checks.mjs";
2
+ import { parseBranchRules } from "./batch-parsers-rules.mjs";
3
+ import { rulesComplete } from "./fingerprint-fields.mjs";
2
4
  /** Fresh compact evidence required before a READY receipt can be used. */
3
5
  export function isCurrentSummaryReady(raw, checks, review, options = {}) {
4
6
  const queued = options.allowQueuedProgress === true && raw.isInMergeQueue;
@@ -8,9 +10,17 @@ export function isCurrentSummaryReady(raw, checks, review, options = {}) {
8
10
  const sourceChecks = queued
9
11
  ? summarizePollSummaryChecks({ ...raw, mergeQueueEntry: null })
10
12
  : checks;
13
+ // The full one-PR check already surfaces review feedback. Certification
14
+ // need not reread historical conversations unless their resolution is a
15
+ // merge requirement. GitHub's CLEAN state proves that requirement is
16
+ // satisfied. BLOCKED is not conversation-specific, and queue progress
17
+ // alone is not that proof.
18
+ const requiresConversationResolution = parseBranchRules(raw.baseRef).requiresConversationResolution;
11
19
  return (checks.incomplete !== true &&
12
20
  sourceChecks.incomplete !== true &&
13
- review.incomplete !== true &&
21
+ (review.incomplete !== true ||
22
+ raw.mergeStateStatus === "CLEAN" ||
23
+ (rulesComplete(raw.baseRef) && !requiresConversationResolution)) &&
14
24
  raw.state === "OPEN" &&
15
25
  !raw.isDraft &&
16
26
  raw.mergeable !== "CONFLICTING" &&
@@ -60,6 +60,12 @@ const appendJournalOperationSchema = z.object({
60
60
  item: z.string(),
61
61
  dryRun: z.boolean().optional(),
62
62
  });
63
+ const acknowledgeQueueRemovalOperationSchema = z.object({
64
+ type: z.literal("acknowledge_queue_removal"),
65
+ requireSha: z.string().regex(/^[0-9a-f]{40}$/),
66
+ queueCommitOid: z.string().regex(/^[0-9a-f]{40}$/),
67
+ removedAtUnix: z.number().int().positive().safe(),
68
+ });
63
69
  const applyInputSchema = z.object({
64
70
  pr,
65
71
  operations: z
@@ -67,6 +73,7 @@ const applyInputSchema = z.object({
67
73
  reviewMutationsOperationSchema,
68
74
  markFilesViewedOperationSchema,
69
75
  appendJournalOperationSchema,
76
+ acknowledgeQueueRemovalOperationSchema,
70
77
  ]))
71
78
  .min(1),
72
79
  });
@@ -234,6 +241,17 @@ function formatApplyResult(result) {
234
241
  return `${heading}\n\n${formatMarkFilesAsViewedResult(operation.result)}`;
235
242
  case "append_journal":
236
243
  return `${heading}\n\n${formatJournalResult(operation.result)}`;
244
+ case "acknowledge_queue_removal": {
245
+ const result = operation.result;
246
+ return [
247
+ heading,
248
+ "",
249
+ `PR: ${result.repo}#${result.pr}`,
250
+ `headSha: ${result.acknowledgment.headSha}`,
251
+ `queueCommitOid: ${result.acknowledgment.queueCommitOid}`,
252
+ `removedAtUnix: ${result.acknowledgment.removedAtUnix}`,
253
+ ].join("\n");
254
+ }
237
255
  }
238
256
  })
239
257
  .join("\n\n");
@@ -0,0 +1,19 @@
1
+ /** A caller's acknowledgment of one observed CI-driven native-stack queue removal. */
2
+ export interface QueueRemovalAcknowledgment {
3
+ headSha: string;
4
+ queueCommitOid: string;
5
+ removedAtUnix: number;
6
+ }
7
+ type StateKey = {
8
+ owner: string;
9
+ repo: string;
10
+ pr: number;
11
+ };
12
+ /** The only removal reasons that indicate a CI-driven queue ejection. */
13
+ export declare function isCiQueueRemovalReason(reason: string | null | undefined): boolean;
14
+ /** Missing, unreadable, or malformed acknowledgment state is treated as absent. */
15
+ export declare function readQueueRemovalAcknowledgment(key: StateKey): Promise<QueueRemovalAcknowledgment | null>;
16
+ /** Atomically store one acknowledgment. */
17
+ export declare function writeQueueRemovalAcknowledgment(key: StateKey, acknowledgment: QueueRemovalAcknowledgment): Promise<boolean>;
18
+ export declare function matchesQueueRemovalAcknowledgment(acknowledgment: QueueRemovalAcknowledgment | null, expected: Pick<QueueRemovalAcknowledgment, "headSha" | "queueCommitOid" | "removedAtUnix">): acknowledgment is QueueRemovalAcknowledgment;
19
+ export {};
@@ -0,0 +1,66 @@
1
+ /** A caller's acknowledgment of one observed CI-driven native-stack queue removal. */
2
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
4
+ import { dirname } from "node:path";
5
+ import { resolvePrStatePath } from "./base.mjs";
6
+ const FILE = "queue-removal-ack.json";
7
+ /** The only removal reasons that indicate a CI-driven queue ejection. */
8
+ export function isCiQueueRemovalReason(reason) {
9
+ return reason === "CI_FAILURE" || reason === "MERGE_QUEUE_POLICY_CHECK_FAILURE";
10
+ }
11
+ /** Missing, unreadable, or malformed acknowledgment state is treated as absent. */
12
+ export async function readQueueRemovalAcknowledgment(key) {
13
+ try {
14
+ const parsed = JSON.parse(await readFile(resolvePrStatePath(key, FILE), "utf8"));
15
+ return isAcknowledgment(parsed) ? parsed : null;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ /** Atomically store one acknowledgment. */
22
+ export async function writeQueueRemovalAcknowledgment(key, acknowledgment) {
23
+ if (!isAcknowledgment(acknowledgment))
24
+ return false;
25
+ let tmp;
26
+ try {
27
+ const path = resolvePrStatePath(key, FILE);
28
+ tmp = `${path}.${randomUUID()}.tmp`;
29
+ await mkdir(dirname(path), { recursive: true });
30
+ await writeFile(tmp, `${JSON.stringify(acknowledgment)}\n`, "utf8");
31
+ await rename(tmp, path);
32
+ tmp = undefined;
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ finally {
39
+ if (tmp !== undefined) {
40
+ try {
41
+ await unlink(tmp);
42
+ }
43
+ catch {
44
+ // A failed write may not have created the temporary file.
45
+ }
46
+ }
47
+ }
48
+ }
49
+ export function matchesQueueRemovalAcknowledgment(acknowledgment, expected) {
50
+ return (acknowledgment !== null &&
51
+ acknowledgment.headSha === expected.headSha &&
52
+ acknowledgment.queueCommitOid === expected.queueCommitOid &&
53
+ acknowledgment.removedAtUnix === expected.removedAtUnix);
54
+ }
55
+ function isAcknowledgment(value) {
56
+ if (value === null || typeof value !== "object")
57
+ return false;
58
+ const candidate = value;
59
+ return (typeof candidate.headSha === "string" &&
60
+ /^[0-9a-f]{40}$/.test(candidate.headSha) &&
61
+ typeof candidate.queueCommitOid === "string" &&
62
+ /^[0-9a-f]{40}$/.test(candidate.queueCommitOid) &&
63
+ typeof candidate.removedAtUnix === "number" &&
64
+ Number.isSafeInteger(candidate.removedAtUnix) &&
65
+ candidate.removedAtUnix > 0);
66
+ }
@@ -124,6 +124,10 @@ interface FixRebaseAndPush {
124
124
  protectedRuns: ProtectedRun[];
125
125
  /** Requeue command emitted after merge-group remediation. */
126
126
  requeue?: MergeCommandPlan;
127
+ /** Acknowledge an unrelated native-stack queue failure before fresh READY validation. */
128
+ queueRemovalAcknowledgment?: {
129
+ argv: string[];
130
+ };
127
131
  /** First-look threads — previously hidden, surfaced for acknowledgment only. */
128
132
  firstLookThreads: FirstLookThread[];
129
133
  /** First-look comments — previously hidden, surfaced for acknowledgment only. */
@@ -10,6 +10,8 @@ export interface MergeQueueReport {
10
10
  checksIncomplete?: true;
11
11
  /** The current PR head is not a parent of the removed synthetic queue commit. */
12
12
  headUpdatedAfterRemoval?: true;
13
+ /** The caller acknowledged this exact native-stack removal on the current head. */
14
+ removalAcknowledged?: true;
13
15
  }
14
16
  /**
15
17
  * Raw counts of actionable work held back while the PR sits in the merge queue
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.56.1",
3
+ "version": "0.56.3",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "keywords": [
6
6
  "automation",
@@ -89,7 +89,7 @@
89
89
  "husky": "^9.1.7",
90
90
  "knip": "^6.14.1",
91
91
  "marked": "^18.0.11",
92
- "oxfmt": "^0.68.0",
92
+ "oxfmt": "^0.70.0",
93
93
  "oxlint": "^1.60.0",
94
94
  "typescript": "^7.0.2",
95
95
  "vitest": "^5.0.0"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.56.1",
3
+ "version": "0.56.3",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "pr-shepherd": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "pr-shepherd@0.56.1", "pr-shepherd-mcp"]
5
+ "args": ["--yes", "--package", "pr-shepherd@0.56.3", "pr-shepherd-mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pr-shepherd": {
3
3
  "command": "npx",
4
- "args": ["--yes", "--package", "pr-shepherd@0.56.1", "pr-shepherd-mcp"]
4
+ "args": ["--yes", "--package", "pr-shepherd@0.56.3", "pr-shepherd-mcp"]
5
5
  }
6
6
  }
@@ -8,7 +8,7 @@ allowed-tools: ["MCP", "Bash", "Read", "Grep", "Glob", "Edit", "Write"]
8
8
 
9
9
  # pr-shepherd
10
10
 
11
- Poll with the CLI. Use MCP `iterate` only when the CLI is unavailable. Stop at `[CANCEL]` or `[ESCALATE]`.
11
+ Poll with the CLI. Use MCP `iterate` only when the CLI is unavailable. Stop polling the selected pull request at `[CANCEL]` or `[ESCALATE]`.
12
12
 
13
13
  ## Create a PR
14
14
 
@@ -21,7 +21,8 @@ Poll with the CLI. Use MCP `iterate` only when the CLI is unavailable. Stop at `
21
21
  ## Dispatch
22
22
 
23
23
  - Parse `$ARGUMENTS` for PR numbers, `owner/repo#N`, GitHub PR URLs, one `--stack PR`, and an optional `--merge`. Reject any other argument.
24
- - A request to merge, land, or enqueue the selected PR or stack sets `--merge`. Creating or opening a PR does not.
24
+ - A user-supplied `--merge` explicitly authorizes merging or enqueueing the selected PR or stack. Run the emitted merge/enqueue commands without asking for another conversational confirmation; request runtime escalation when the host requires it.
25
+ - A request to merge, land, or enqueue the selected PR or stack also sets `--merge`. Creating or opening a PR without merge intent leaves merge mode off.
25
26
  - A request to shepherd or merge a native stack, with an anchor PR and no literal `--stack`, uses that PR as the `--stack` selector. Otherwise infer the current branch PR.
26
27
  - Follow the target repository's `AGENTS.md` while editing.
27
28
  - CLI: turn `owner/repo#N` into `https://github.com/owner/repo/pull/N`. Pass other URLs and bare numbers through.
@@ -6,6 +6,7 @@ Apply when a step says `Playbook: "CI failure triage"`. For a GitHub Actions row
6
6
  - `[rerun authorized]` plus a `rerun:` command means the viewer can rerun Actions (WRITE+) and this is the original attempt. Shepherd checked `repositoryPermission` and `run_attempt`.
7
7
  - Run that printed command at most once. An `[attempt: N]` check never gets another rerun. A log excerpt on a later attempt is still investigation work. A later attempt with no usable evidence escalates when nothing else remains.
8
8
  - A run in progress, `[conclusion: ACTION_REQUIRED]`, a check whose run id is not a GitHub Actions workflow, or a run with no attempt metadata never gets `[rerun authorized]`.
9
+ - A check with `scope: merge_group` never gets a rerun command. Rerunning cannot restore a removed queue entry and overwrites the failure evidence. If the failure belongs to this PR, fix the PR head. If it does not, follow the printed requeue instruction when a plan is present. Without `--merge`, report the failure without enqueueing. For native stacks, child sessions omit `--merge` and may still record the printed local acknowledgment after inspecting logs or an external provider URL. Complete fresh one-PR READY validation before returning to the aggregate selector with its original options; never enqueue a stack layer directly.
9
10
  - Do not invent a handoff from `[FIX_CODE]`. Shepherd returns `[ESCALATE]` when no autonomous follow-up remains.
10
11
  - Several bullets can share one run id (matrix jobs). The `rerun:` command is printed once, on the first bullet. Run it once.
11
12