pr-shepherd 0.54.0 → 0.55.0

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 (126) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -3
  3. package/bin/checks/unreported-required.d.mts +29 -0
  4. package/bin/checks/unreported-required.mjs +52 -0
  5. package/bin/classify/apply.d.mts +2 -0
  6. package/bin/classify/apply.mjs +31 -31
  7. package/bin/classify/rule-action.d.mts +8 -0
  8. package/bin/classify/rule-action.mjs +29 -0
  9. package/bin/classify/types.d.mts +1 -1
  10. package/bin/cli/help-command-pages.d.mts +2 -2
  11. package/bin/cli/help-iterate-poll-pages.d.mts +2 -2
  12. package/bin/cli/help-iterate-poll-pages.mjs +6 -5
  13. package/bin/cli/help-top-page.d.mts +1 -1
  14. package/bin/cli/help-top-page.mjs +2 -2
  15. package/bin/cli/help.d.mts +3 -3
  16. package/bin/cli/iterate-branch-segment.d.mts +3 -0
  17. package/bin/cli/iterate-branch-segment.mjs +13 -0
  18. package/bin/cli/iterate-formatter.mjs +34 -22
  19. package/bin/cli/iterate-instructions.mjs +6 -0
  20. package/bin/cli/iterate-lean.mjs +16 -0
  21. package/bin/cli/poll-summary-emitter.mjs +4 -1
  22. package/bin/cli/poll-summary-formatter.mjs +10 -3
  23. package/bin/cli/stack-overview.d.mts +48 -0
  24. package/bin/cli/stack-overview.mjs +138 -0
  25. package/bin/commands/check-fingerprint.mjs +2 -1
  26. package/bin/commands/check-status.d.mts +1 -1
  27. package/bin/commands/check-status.mjs +8 -3
  28. package/bin/commands/check-unreported.d.mts +26 -0
  29. package/bin/commands/check-unreported.mjs +73 -0
  30. package/bin/commands/check.mjs +45 -38
  31. package/bin/commands/iterate/base.mjs +9 -0
  32. package/bin/commands/iterate/conflicting-head-ci.d.mts +18 -0
  33. package/bin/commands/iterate/conflicting-head-ci.mjs +32 -0
  34. package/bin/commands/iterate/escalate.mjs +4 -0
  35. package/bin/commands/iterate/fix-code.mjs +30 -1
  36. package/bin/commands/iterate/index.mjs +69 -3
  37. package/bin/commands/iterate/merge-state.mjs +12 -12
  38. package/bin/commands/iterate/merge.d.mts +12 -2
  39. package/bin/commands/iterate/merge.mjs +32 -2
  40. package/bin/commands/iterate/native-stack-rebase.d.mts +8 -2
  41. package/bin/commands/iterate/native-stack-rebase.mjs +14 -4
  42. package/bin/commands/iterate/stack-trunk-conflict.d.mts +13 -0
  43. package/bin/commands/iterate/stack-trunk-conflict.mjs +51 -0
  44. package/bin/commands/iterate/unreported-required.d.mts +18 -0
  45. package/bin/commands/iterate/unreported-required.mjs +122 -0
  46. package/bin/commands/poll-summary.mjs +1 -0
  47. package/bin/commands/poll.mjs +9 -2
  48. package/bin/commands/ready-mergeability.d.mts +1 -1
  49. package/bin/commands/ready-mergeability.mjs +2 -2
  50. package/bin/commands/rule-auto-resolve-format.d.mts +22 -0
  51. package/bin/commands/rule-auto-resolve-format.mjs +151 -0
  52. package/bin/commands/rule-auto-resolve.d.mts +21 -0
  53. package/bin/commands/rule-auto-resolve.mjs +113 -0
  54. package/bin/commands/stack-drain.d.mts +0 -5
  55. package/bin/commands/stack-drain.mjs +26 -16
  56. package/bin/commands/stack-merge-flag.d.mts +7 -0
  57. package/bin/commands/stack-merge-flag.mjs +6 -0
  58. package/bin/config/load.d.mts +3 -0
  59. package/bin/config/load.mjs +9 -2
  60. package/bin/config/merge-method.d.mts +27 -0
  61. package/bin/config/merge-method.mjs +56 -0
  62. package/bin/exit-codes.d.mts +1 -1
  63. package/bin/exit-codes.mjs +2 -1
  64. package/bin/github/batch-parse-suites.d.mts +5 -0
  65. package/bin/github/batch-parse-suites.mjs +20 -0
  66. package/bin/github/batch-parsers.d.mts +1 -1
  67. package/bin/github/batch-parsers.mjs +7 -0
  68. package/bin/github/batch-raw-types.d.mts +5 -0
  69. package/bin/github/batch.d.mts +5 -0
  70. package/bin/github/batch.mjs +10 -1
  71. package/bin/github/errors.d.mts +6 -0
  72. package/bin/github/errors.mjs +23 -5
  73. package/bin/github/fingerprint-fields.d.mts +1 -0
  74. package/bin/github/fingerprint-fields.mjs +1 -1
  75. package/bin/github/gql/batch-pr-page.gql +2 -0
  76. package/bin/github/gql/batch-pr.gql +5 -0
  77. package/bin/github/gql/commit-check-suites.gql +1 -0
  78. package/bin/github/gql/poll-stack-summary.gql +6 -0
  79. package/bin/github/gql/poll-summary-annotation-probe.gql +32 -0
  80. package/bin/github/gql/poll-summary-check-contexts.gql +2 -3
  81. package/bin/github/gql/poll-summary-fragment.gql +16 -0
  82. package/bin/github/gql/pr-merge-policy.gql +1 -44
  83. package/bin/github/gql/ref-rules-query.gql +18 -0
  84. package/bin/github/gql/ref-rules.gql +46 -0
  85. package/bin/github/gql/upper-layer-conflict-target.gql +36 -0
  86. package/bin/github/graphql-internal-retry.d.mts +1 -1
  87. package/bin/github/graphql-internal-retry.mjs +4 -3
  88. package/bin/github/merge-queue-checks.mjs +7 -3
  89. package/bin/github/merge-target-rules.d.mts +20 -0
  90. package/bin/github/merge-target-rules.mjs +54 -0
  91. package/bin/github/poll-summary-annotation-probe.d.mts +19 -0
  92. package/bin/github/poll-summary-annotation-probe.mjs +88 -0
  93. package/bin/github/poll-summary-fingerprint.mjs +1 -1
  94. package/bin/github/poll-summary-projector.d.mts +1 -1
  95. package/bin/github/poll-summary-projector.mjs +19 -12
  96. package/bin/github/poll-summary-queue-removal.mjs +7 -3
  97. package/bin/github/poll-summary-raw.d.mts +16 -0
  98. package/bin/github/poll-summary-readiness.mjs +1 -0
  99. package/bin/github/poll-summary-route.mjs +5 -0
  100. package/bin/github/poll-summary-unreported.d.mts +6 -0
  101. package/bin/github/poll-summary-unreported.mjs +51 -0
  102. package/bin/github/poll-summary.d.mts +1 -0
  103. package/bin/github/poll-summary.mjs +28 -3
  104. package/bin/github/queries.d.mts +12 -0
  105. package/bin/github/queries.mjs +14 -2
  106. package/bin/github/queue-removal-freshness.d.mts +20 -5
  107. package/bin/github/queue-removal-freshness.mjs +37 -10
  108. package/bin/github/stack-read.d.mts +4 -0
  109. package/bin/github/stack-read.mjs +13 -1
  110. package/bin/mcp/server.mjs +6 -1
  111. package/bin/state/ci-retrigger.d.mts +25 -0
  112. package/bin/state/ci-retrigger.mjs +48 -0
  113. package/bin/state/conflicting-head-seen.d.mts +13 -0
  114. package/bin/state/conflicting-head-seen.mjs +44 -0
  115. package/bin/types/escalate.d.mts +1 -1
  116. package/bin/types/github.d.mts +11 -0
  117. package/bin/types/iterate.d.mts +19 -3
  118. package/bin/types/poll-summary.d.mts +13 -0
  119. package/bin/types/report.d.mts +34 -1
  120. package/package.json +1 -1
  121. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  122. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  123. package/plugins/pr-shepherd/.mcp.json +1 -1
  124. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +2 -2
  125. package/plugins/pr-shepherd/skills/reduce-pr-noise/references/classifiers.md +1 -1
  126. package/src/classify/types.mts +1 -1
@@ -6,6 +6,8 @@ import { formatMergeRequirementLines } from "../merge-status/requirements-format
6
6
  import { appendMergeQueueHeader, formatDeferredWorkLine, formatMergeAction, } from "./iterate-merge-formatter.mjs";
7
7
  import { formatApiUsage, formatQuotaWarning } from "./api-usage-formatter.mjs";
8
8
  import { formatActivityLine } from "./iterate-activity-formatter.mjs";
9
+ import { branchStateSegment } from "./iterate-branch-segment.mjs";
10
+ import { insertRuleAutoResolveSection } from "../commands/rule-auto-resolve-format.mjs";
9
11
  /**
10
12
  * Format an IterateResult as human-readable Markdown.
11
13
  *
@@ -23,21 +25,17 @@ import { formatActivityLine } from "./iterate-activity-formatter.mjs";
23
25
  export function formatIterateResult(result, opts) {
24
26
  const verbose = opts?.verbose ?? false;
25
27
  const readyDelaySuffix = opts?.readyDelaySuffix;
28
+ const finish = (text) => insertRuleAutoResolveSection(text, result.ruleAutoResolve);
26
29
  const heading = `# PR #${result.pr} [${result.action.toUpperCase()}]`;
27
30
  const reviewDecisionSeg = result.mergeStatus === "BLOCKED" && result.reviewDecision
28
31
  ? ` · **reviewDecision** \`${result.reviewDecision}\``
29
32
  : "";
30
33
  const baseBranchSeg = verbose && result.baseBranch ? ` · **baseBranch** \`${result.baseBranch}\`` : "";
31
34
  const baseLine = `**status** \`${result.status}\` · **merge** \`${result.mergeStateStatus}\`${reviewDecisionSeg} · **state** \`${result.state}\` · **repo** \`${result.repo}\`${baseBranchSeg}`;
35
+ const branchSeg = branchStateSegment(result);
32
36
  let summaryLine;
33
37
  if (verbose) {
34
- let verboseBranch = "";
35
- if (result.mergeStatus === "BEHIND" && result.baseBranch) {
36
- verboseBranch = ` · **branch** behind PR base \`${result.baseBranch}\``;
37
- }
38
- else if (result.mergeStatus === "CONFLICTS" && result.baseBranch) {
39
- verboseBranch = ` · **branch** conflicts with PR base \`${result.baseBranch}\``;
40
- }
38
+ const verboseBranch = branchSeg ? ` · ${branchSeg}` : "";
41
39
  summaryLine = `**summary** ${result.summary.passing} passing, ${result.summary.skipped} skipped, ${result.summary.filtered} filtered, ${result.summary.inProgress} inProgress, ${result.summary.superseded} superseded · **remainingSeconds** ${result.remainingSeconds} · **blockingBotReviewInProgress** ${result.blockingBotReviewInProgress} · **isDraft** ${result.isDraft} · **shouldCancel** ${result.shouldCancel}${verboseBranch}`;
42
40
  }
43
41
  else {
@@ -58,12 +56,8 @@ export function formatIterateResult(result, opts) {
58
56
  segs.push(`**blockingBotReviewInProgress**`);
59
57
  if (result.isDraft)
60
58
  segs.push(`**isDraft**`);
61
- if (result.mergeStatus === "BEHIND" && result.baseBranch) {
62
- segs.push(`**branch** behind PR base \`${result.baseBranch}\``);
63
- }
64
- else if (result.mergeStatus === "CONFLICTS" && result.baseBranch) {
65
- segs.push(`**branch** conflicts with PR base \`${result.baseBranch}\``);
66
- }
59
+ if (branchSeg)
60
+ segs.push(branchSeg);
67
61
  summaryLine = segs.join(" · ");
68
62
  }
69
63
  // Surface an explicit `--ready-delay` override (set only when the user passed the flag)
@@ -105,6 +99,7 @@ export function formatIterateResult(result, opts) {
105
99
  const names = result.supersededNames.map((n) => "`" + n + "`").join(", ");
106
100
  headerLines.push(`**superseded** ${names}`);
107
101
  }
102
+ appendUnreportedLines(headerLines, result);
108
103
  appendMergeQueueHeader(headerLines, result);
109
104
  const activityLine = formatActivityLine(result);
110
105
  if (activityLine)
@@ -115,22 +110,29 @@ export function formatIterateResult(result, opts) {
115
110
  const verboseChecks = verbose ? formatRelevantChecks(result.checks) : null;
116
111
  const telemetrySections = [quotaWarning, apiUsage, verboseChecks];
117
112
  switch (result.action) {
113
+ case "ready":
114
+ return joinSections([
115
+ header,
116
+ ...telemetrySections,
117
+ adaptIterateLog(result.log),
118
+ `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result))}`,
119
+ ]);
118
120
  case "wait": {
119
121
  const waitLines = [header, ...telemetrySections, adaptIterateLog(result.log)];
120
122
  if (result.deferredWork)
121
123
  waitLines.push(formatDeferredWorkLine(result.deferredWork));
122
124
  waitLines.push(`## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result))}`);
123
- return joinSections(waitLines);
125
+ return finish(joinSections(waitLines));
124
126
  }
125
127
  case "mark_ready":
126
- return joinSections([
128
+ return finish(joinSections([
127
129
  header,
128
130
  ...telemetrySections,
129
131
  adaptIterateLog(result.log),
130
132
  `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result))}`,
131
- ]);
133
+ ]));
132
134
  case "merge":
133
- return formatMergeAction(joinSections([header, ...telemetrySections]), result);
135
+ return finish(formatMergeAction(joinSections([header, ...telemetrySections]), result));
134
136
  case "cancel": {
135
137
  const cancelHeaderLines = [`${heading} — ${result.reason}`, "", baseLine, summaryLine];
136
138
  if (result.mergeRequirements) {
@@ -147,26 +149,36 @@ export function formatIterateResult(result, opts) {
147
149
  const supersededStr = result.supersededNames.map((n) => "`" + n + "`").join(", ");
148
150
  cancelHeaderLines.push(`**superseded** ${supersededStr}`);
149
151
  }
152
+ appendUnreportedLines(cancelHeaderLines, result);
150
153
  appendMergeQueueHeader(cancelHeaderLines, result);
151
154
  if (activityLine)
152
155
  cancelHeaderLines.push(activityLine);
153
- return joinSections([
156
+ return finish(joinSections([
154
157
  cancelHeaderLines.join("\n"),
155
158
  ...(apiUsage ? [apiUsage] : []),
156
159
  ...(verboseChecks ? [verboseChecks] : []),
157
160
  adaptIterateLog(result.log),
158
161
  `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result))}`,
159
- ]);
162
+ ]));
160
163
  }
161
164
  case "escalate":
162
- return joinSections([
165
+ return finish(joinSections([
163
166
  header,
164
167
  ...(apiUsage ? [apiUsage] : []),
165
168
  ...(verboseChecks ? [verboseChecks] : []),
166
169
  result.escalate.humanMessage,
167
170
  `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result))}`,
168
- ]);
171
+ ]));
169
172
  case "fix_code":
170
- return formatFixCodeResult(joinSections([header, ...telemetrySections]), result, { verbose });
173
+ return finish(formatFixCodeResult(joinSections([header, ...telemetrySections]), result, { verbose }));
174
+ }
175
+ }
176
+ function appendUnreportedLines(lines, result) {
177
+ if (result.unreportedRequiredChecks && result.unreportedRequiredChecks.length > 0) {
178
+ const names = result.unreportedRequiredChecks.map((name) => "`" + name + "`").join(", ");
179
+ lines.push(`**unreported required** ${names}`);
180
+ }
181
+ if (result.trunkBehindBy !== undefined && result.trunkBehindBy > 0) {
182
+ lines.push(`**trunk behind** \`${result.trunkBehindBy}\``);
171
183
  }
172
184
  }
@@ -6,6 +6,12 @@ import { AUTO_MARK_READY_DISABLED_HOLD } from "../commands/stack-work.mjs";
6
6
  import { buildPrShepherdCommand } from "./runner.mjs";
7
7
  export function buildSimpleIterateInstructions(result) {
8
8
  switch (result.action) {
9
+ case "ready": {
10
+ const sentence = `PR #${result.pr} is ready. Ready-delay has ${result.remainingSeconds}s left. Run this same command again when the timer elapses. Do not start other work.`;
11
+ return [
12
+ result.quotaWarning ? buildQuotaAwareContinuation(result.quotaWarning, sentence) : sentence,
13
+ ];
14
+ }
9
15
  case "wait":
10
16
  if (result.stackDraftHold)
11
17
  return [buildStackDraftHoldInstruction(result, result.stackDraftHold)];
@@ -1,4 +1,5 @@
1
1
  import { adaptIterateLog, buildSimpleIterateInstructions } from "./iterate-instructions.mjs";
2
+ import { projectRuleAutoResolve } from "../commands/rule-auto-resolve-format.mjs";
2
3
  /**
3
4
  * Project an IterateResult to a lean JSON shape for the default (non-verbose) output.
4
5
  * Omits fields that are the trivial default (false, 0, empty) or state-gated fields
@@ -41,6 +42,7 @@ export function projectIterateLean(result, opts) {
41
42
  remainingSeconds: result.remainingSeconds,
42
43
  }),
43
44
  ...(result.baseBranch && { baseBranch: result.baseBranch }),
45
+ ...(result.stackTrunkConflict && { stackTrunkConflict: result.stackTrunkConflict }),
44
46
  ...(result.branchProtection !== null && { branchProtection: result.branchProtection }),
45
47
  ...(result.mergeRequirements && { mergeRequirements: result.mergeRequirements }),
46
48
  ...(result.mergeQueue && { mergeQueue: result.mergeQueue }),
@@ -65,9 +67,23 @@ export function projectIterateLean(result, opts) {
65
67
  ...((result.supersededNames?.length ?? 0) > 0 && {
66
68
  supersededNames: result.supersededNames,
67
69
  }),
70
+ ...((result.unreportedRequiredChecks?.length ?? 0) > 0 && {
71
+ unreportedRequiredChecks: result.unreportedRequiredChecks,
72
+ }),
73
+ ...(result.trunkBehindBy !== undefined &&
74
+ result.trunkBehindBy > 0 && { trunkBehindBy: result.trunkBehindBy }),
68
75
  ...(result.quotaWarning && { quotaWarning: result.quotaWarning }),
76
+ ...(result.ruleAutoResolve && {
77
+ ruleAutoResolve: projectRuleAutoResolve(result.ruleAutoResolve),
78
+ }),
69
79
  };
70
80
  switch (result.action) {
81
+ case "ready":
82
+ return {
83
+ ...base,
84
+ log: adaptIterateLog(result.log),
85
+ instructions: simpleInstructions(result),
86
+ };
71
87
  case "wait":
72
88
  return {
73
89
  ...base,
@@ -1,7 +1,10 @@
1
1
  import { EXIT } from "../exit-codes.mjs";
2
2
  import { formatPollSummaryResult } from "./poll-summary-formatter.mjs";
3
+ import { projectStackOverview } from "./stack-overview.mjs";
3
4
  export function emitPollSummaryResult(result, opts) {
4
- process.stdout.write(opts.format === "json" ? `${JSON.stringify(result)}\n` : `${formatPollSummaryResult(result)}\n`);
5
+ process.stdout.write(opts.format === "json"
6
+ ? `${JSON.stringify(result.selection.kind === "stack" ? projectStackOverview(result) : result)}\n`
7
+ : `${formatPollSummaryResult(result)}\n`);
5
8
  process.exitCode = pollSummaryExitCode(result);
6
9
  }
7
10
  function pollSummaryExitCode(result) {
@@ -1,9 +1,10 @@
1
1
  import { formatApiUsage, formatQuotaWarning } from "./api-usage-formatter.mjs";
2
2
  import { withPollSummaryInstructions } from "../commands/poll-summary-instructions.mjs";
3
+ import { formatStackOverview, projectStackOverview } from "./stack-overview.mjs";
3
4
  export function formatPollSummaryResult(result) {
4
- const selection = result.selection.kind === "stack"
5
- ? `stack #${result.selection.stackNumber} anchored at PR #${result.selection.anchor} (${result.selection.stackSize} PRs)`
6
- : `PRs ${result.selection.requested.map((pr) => `#${pr}`).join(", ")}`;
5
+ if (result.selection.kind === "stack")
6
+ return formatStackOverview(projectStackOverview(result));
7
+ const selection = `PRs ${result.selection.requested.map((pr) => `#${pr}`).join(", ")}`;
7
8
  const lines = [
8
9
  `# Poll summary [${result.reason.toUpperCase()}]`,
9
10
  "",
@@ -52,6 +53,12 @@ function formatItem(item) {
52
53
  ...(checks
53
54
  ? [` - checks: ${formatCounts(checks, checks.incomplete ? ", incomplete" : "")}`]
54
55
  : []),
56
+ ...(checks?.unreportedRequired?.length
57
+ ? [
58
+ ` - unreported required: ${checks.unreportedRequired.map((name) => `\`${name}\``).join(", ")}`,
59
+ ]
60
+ : []),
61
+ ...(checks?.actionsWorkflowInProgress ? [" - actions workflow in progress"] : []),
55
62
  ...(review
56
63
  ? [` - review: ${formatCounts(review, review.incomplete ? ", incomplete" : "")}`]
57
64
  : []),
@@ -0,0 +1,48 @@
1
+ import type { PollSummaryItem, PollSummaryResult, PollSummaryStackAncestry, StackNextAction } from "../types.mts";
2
+ import type { ApiUsage, GraphqlQuotaWarning } from "../types/api-usage.mts";
3
+ /** Agent-facing stack row. Planning keeps the richer PollSummaryItem. */
4
+ interface StackLayerView {
5
+ pr: number;
6
+ title: string;
7
+ url: string;
8
+ state: PollSummaryItem["state"];
9
+ shepherded: boolean;
10
+ mergeable: boolean;
11
+ blocker?: string;
12
+ author?: string;
13
+ owned?: true;
14
+ isDraft?: true;
15
+ isInMergeQueue?: true;
16
+ position?: number;
17
+ stackSize?: number;
18
+ baseRefName: string;
19
+ failing?: number;
20
+ inProgress?: number;
21
+ actionable?: number;
22
+ checksIncomplete?: true;
23
+ reviewIncomplete?: true;
24
+ queueRemoval?: {
25
+ reason: string | null;
26
+ actor?: string;
27
+ };
28
+ }
29
+ export interface StackOverview {
30
+ mode: "summary";
31
+ repo: string;
32
+ selection: Extract<PollSummaryResult["selection"], {
33
+ kind: "stack";
34
+ }>;
35
+ reason: PollSummaryResult["reason"];
36
+ stackMergeable?: boolean;
37
+ nextAction?: StackNextAction;
38
+ prs: StackLayerView[];
39
+ stackAncestry?: PollSummaryStackAncestry[];
40
+ instructions?: string[];
41
+ apiUsage?: ApiUsage;
42
+ quotaWarning?: GraphqlQuotaWarning;
43
+ }
44
+ /** Lean stack payload shared by Markdown, JSON, and MCP. */
45
+ export declare function projectStackOverview(result: PollSummaryResult): StackOverview;
46
+ /** Markdown for a stack overview. */
47
+ export declare function formatStackOverview(overview: StackOverview): string;
48
+ export {};
@@ -0,0 +1,138 @@
1
+ import { stackLayerBlockReason } from "../commands/stack-layer-readiness.mjs";
2
+ import { formatApiUsage, formatQuotaWarning } from "./api-usage-formatter.mjs";
3
+ /** Lean stack payload shared by Markdown, JSON, and MCP. */
4
+ export function projectStackOverview(result) {
5
+ const selection = result.selection;
6
+ if (selection.kind !== "stack") {
7
+ throw new Error("projectStackOverview requires a stack selection");
8
+ }
9
+ const stale = new Set(result.stackAncestry?.map((gap) => gap.childPr) ?? []);
10
+ return {
11
+ mode: "summary",
12
+ repo: result.repo,
13
+ selection,
14
+ reason: result.reason,
15
+ ...(result.stackMergeable !== undefined && { stackMergeable: result.stackMergeable }),
16
+ ...(result.nextAction && { nextAction: result.nextAction }),
17
+ prs: result.prs.map((item) => projectLayer(item, stale.has(item.pr))),
18
+ ...(result.stackAncestry?.length && { stackAncestry: result.stackAncestry }),
19
+ ...(result.instructions?.length && { instructions: result.instructions }),
20
+ ...(result.apiUsage && { apiUsage: result.apiUsage }),
21
+ ...(result.quotaWarning && { quotaWarning: result.quotaWarning }),
22
+ };
23
+ }
24
+ function projectLayer(item, stale) {
25
+ const blocker = layerBlocker(item, stale);
26
+ const removal = item.queueRemoval;
27
+ return {
28
+ pr: item.pr,
29
+ title: item.title,
30
+ url: item.url,
31
+ state: item.state,
32
+ shepherded: item.readyReceipt === true,
33
+ mergeable: blocker === undefined,
34
+ ...(blocker && { blocker }),
35
+ ...(item.authorLogin && { author: item.authorLogin }),
36
+ ...(item.owned && { owned: true }),
37
+ ...(item.isDraft && { isDraft: true }),
38
+ ...(item.isInMergeQueue && { isInMergeQueue: true }),
39
+ ...(item.stack && { position: item.stack.position, stackSize: item.stack.size }),
40
+ baseRefName: item.baseRefName,
41
+ ...(blocker === "failing-checks" &&
42
+ (item.checks?.failing ?? 0) > 0 && { failing: item.checks?.failing }),
43
+ ...(blocker === "checks-in-progress" &&
44
+ (item.checks?.inProgress ?? 0) > 0 && { inProgress: item.checks?.inProgress }),
45
+ ...(blocker === "review-work" &&
46
+ (item.review?.actionable ?? 0) > 0 && { actionable: item.review?.actionable }),
47
+ ...(item.checks?.incomplete && { checksIncomplete: true }),
48
+ ...(item.review?.incomplete && { reviewIncomplete: true }),
49
+ ...(removal && {
50
+ queueRemoval: {
51
+ reason: removal.reason,
52
+ ...(removal.actor && { actor: removal.actor }),
53
+ },
54
+ }),
55
+ };
56
+ }
57
+ /** Mergeability blocker. A missing receipt is "not shepherded", not a mergeability reason. */
58
+ function layerBlocker(item, stale) {
59
+ if (item.action === "escalate") {
60
+ return item.reasons.find((reason) => reason !== "appears-ready") ?? "escalate";
61
+ }
62
+ if (stale)
63
+ return "stale-ancestry";
64
+ const reason = stackLayerBlockReason(item);
65
+ if (reason === undefined || reason === "no-ready-receipt")
66
+ return undefined;
67
+ return reason;
68
+ }
69
+ /** Skill stop tokens. Other stack actions stay untagged so the loop keeps running. */
70
+ function stackTerminalTag(nextAction) {
71
+ if (nextAction === "cancel")
72
+ return " [CANCEL]";
73
+ if (nextAction === "escalate")
74
+ return " [ESCALATE]";
75
+ return "";
76
+ }
77
+ /** Markdown for a stack overview. */
78
+ export function formatStackOverview(overview) {
79
+ const stack = overview.selection;
80
+ const lines = [
81
+ `# ${overview.repo} stack #${stack.stackNumber}${stackTerminalTag(overview.nextAction)} — ${overview.reason}`,
82
+ "",
83
+ `Stack: #${stack.stackNumber} · anchor PR #${stack.anchor} · ${stack.stackSize} layers · mode \`${overview.mode}\``,
84
+ ...(overview.stackMergeable !== undefined
85
+ ? [`stackMergeable: ${overview.stackMergeable}`]
86
+ : []),
87
+ ...(overview.nextAction ? [`nextAction: ${overview.nextAction}`] : []),
88
+ "",
89
+ "## Layers",
90
+ "",
91
+ ...overview.prs.flatMap(formatLayerLines),
92
+ ];
93
+ if (overview.stackAncestry?.length) {
94
+ lines.push("", "## Stack ancestry", "");
95
+ for (const gap of overview.stackAncestry) {
96
+ lines.push(`- PR #${gap.childPr} base \`${gap.childBaseRefName}\` at \`${gap.childBaseRefOid}\` differs from parent PR #${gap.parentPr} head \`${gap.parentHeadRefName}\` at \`${gap.parentHeadRefOid}\`.`);
97
+ }
98
+ }
99
+ if (overview.instructions?.length) {
100
+ lines.push("", "## Instructions", "", ...overview.instructions);
101
+ }
102
+ const quota = formatQuotaWarning(overview.quotaWarning);
103
+ const usage = formatApiUsage(overview.apiUsage);
104
+ if (quota)
105
+ lines.push("", quota);
106
+ if (usage)
107
+ lines.push("", usage);
108
+ return lines.join("\n");
109
+ }
110
+ function formatLayerLines(layer) {
111
+ const facts = [
112
+ layer.shepherded ? "shepherded" : "not shepherded",
113
+ layer.mergeable ? "mergeable" : `not mergeable (\`${layer.blocker ?? "unknown"}\`)`,
114
+ layer.author ? `owner \`@${layer.author}\`` : undefined,
115
+ layer.owned ? "owned" : undefined,
116
+ ].filter((fact) => fact !== undefined);
117
+ const details = [
118
+ layer.state,
119
+ layer.isDraft ? "draft" : undefined,
120
+ layer.isInMergeQueue ? "in merge queue" : undefined,
121
+ layer.position !== undefined && layer.stackSize !== undefined
122
+ ? `position ${layer.position}/${layer.stackSize}`
123
+ : undefined,
124
+ `base \`${layer.baseRefName}\``,
125
+ layer.failing ? `${layer.failing} failing` : undefined,
126
+ layer.inProgress ? `${layer.inProgress} in progress` : undefined,
127
+ layer.actionable ? `${layer.actionable} actionable` : undefined,
128
+ layer.checksIncomplete ? "checks incomplete" : undefined,
129
+ layer.reviewIncomplete ? "review incomplete" : undefined,
130
+ layer.queueRemoval
131
+ ? `removed from merge queue (${layer.queueRemoval.reason ?? "unknown reason"}${layer.queueRemoval.actor ? ` by @${layer.queueRemoval.actor}` : ""})`
132
+ : undefined,
133
+ ].filter((detail) => detail !== undefined);
134
+ return [
135
+ `- [PR #${layer.pr}: ${layer.title}](${layer.url}) — ${facts.join(" · ")}`,
136
+ ` - ${details.join(" · ")}`,
137
+ ];
138
+ }
@@ -2,6 +2,7 @@ import { getMergeableState } from "../github/client.mjs";
2
2
  import { fetchPrFingerprint, fingerprintsEqual, } from "../github/fingerprint.mjs";
3
3
  import { fingerprintInputDigest, loadPrFingerprint } from "../state/pr-fingerprint.mjs";
4
4
  import { hasCheckDrivenActionableWork } from "./check-annotations.mjs";
5
+ import { stripReplayedRuleAutoResolve } from "./rule-auto-resolve-format.mjs";
5
6
  function reportAllowsFingerprintSkip(report) {
6
7
  if (report.status === "READY")
7
8
  return false;
@@ -50,7 +51,7 @@ export async function tryReuseFingerprintReport(prNumber, repo, stateKey, config
50
51
  if (!(await cachedReportSurvivesMergeabilityRefresh(prNumber, repo, cached.report, cached.fingerprint))) {
51
52
  return null;
52
53
  }
53
- return { ...cached.report, fingerprintReused: true };
54
+ return { ...stripReplayedRuleAutoResolve(cached.report), fingerprintReused: true };
54
55
  }
55
56
  async function cachedReportSurvivesMergeabilityRefresh(prNumber, repo, report, fingerprint) {
56
57
  const restDerived = fingerprint.mergeable !== report.mergeStatus.mergeable ||
@@ -1,3 +1,3 @@
1
1
  import type { MergeStatusResult, ShepherdStatus } from "../types.mts";
2
2
  import type { CiVerdict } from "../checks/classify.mts";
3
- export declare function computeStatus(verdict: CiVerdict, unresolvedThreads: number, unresolvedComments: number, mergeStatus: MergeStatusResult, changesRequestedReviews: number): ShepherdStatus;
3
+ export declare function computeStatus(verdict: CiVerdict, unresolvedThreads: number, unresolvedComments: number, mergeStatus: MergeStatusResult, changesRequestedReviews: number, hasUnreportedRequired?: boolean): ShepherdStatus;
@@ -1,4 +1,4 @@
1
- export function computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStatus, changesRequestedReviews) {
1
+ export function computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStatus, changesRequestedReviews, hasUnreportedRequired = false) {
2
2
  // Merge conflicts are always terminal regardless of CI state.
3
3
  if (mergeStatus.status === "CONFLICTS")
4
4
  return "FAILING";
@@ -25,7 +25,8 @@ export function computeStatus(verdict, unresolvedThreads, unresolvedComments, me
25
25
  unresolvedComments === 0 &&
26
26
  changesRequestedReviews === 0 &&
27
27
  (mergeStatus.status === "BLOCKED" || mergeStatus.status === "UNSTABLE") &&
28
- !mergeStatus.blockingBotReviewInProgress) {
28
+ !mergeStatus.blockingBotReviewInProgress &&
29
+ !hasUnreportedRequired) {
29
30
  return "READY";
30
31
  }
31
32
  if (mergeStatus.status === "BLOCKED" ||
@@ -37,7 +38,11 @@ export function computeStatus(verdict, unresolvedThreads, unresolvedComments, me
37
38
  if (changesRequestedReviews > 0 || unresolvedThreads > 0 || unresolvedComments > 0)
38
39
  return "UNRESOLVED_COMMENTS";
39
40
  // DRAFT is treated the same as CLEAN for readiness — marking the PR ready resolves it.
40
- if ((mergeStatus.status === "CLEAN" || mergeStatus.status === "DRAFT") && verdict.allPassed)
41
+ if ((mergeStatus.status === "CLEAN" || mergeStatus.status === "DRAFT") &&
42
+ verdict.allPassed &&
43
+ !hasUnreportedRequired)
41
44
  return "READY";
45
+ if (hasUnreportedRequired && (mergeStatus.status === "CLEAN" || mergeStatus.status === "DRAFT"))
46
+ return "PENDING";
42
47
  return "UNKNOWN";
43
48
  }
@@ -0,0 +1,26 @@
1
+ import { type WorkflowSuiteSnapshot } from "../checks/unreported-required.mts";
2
+ import type { BatchPrData, CheckRun, ShepherdReport } from "../types.mts";
3
+ import type { RepoInfo } from "../github/client.mts";
4
+ export interface UnreportedRequiredFields {
5
+ unreportedRequiredChecks?: string[];
6
+ trunkBehindBy?: number;
7
+ actionsWorkflowInProgress?: true;
8
+ stackBottomPr?: number;
9
+ headRefName: string;
10
+ hasUnreportedRequired: boolean;
11
+ }
12
+ /** Load trunk rules for a stack and diff them against the head's check names. */
13
+ export declare function collectUnreportedRequired(input: {
14
+ batchData: BatchPrData;
15
+ checks: readonly CheckRun[];
16
+ suites: readonly WorkflowSuiteSnapshot[];
17
+ owner: string;
18
+ name: string;
19
+ pr: number;
20
+ relevantEvents: readonly string[];
21
+ }): Promise<UnreportedRequiredFields>;
22
+ /**
23
+ * A fingerprint hit can keep a stale trunk `behindBy`. Refresh the trunk compare
24
+ * and the required-context diff without refetching the whole batch.
25
+ */
26
+ export declare function refreshCachedUnreported(report: ShepherdReport, repo: RepoInfo): Promise<ShepherdReport>;
@@ -0,0 +1,73 @@
1
+ import { actionsWorkflowInProgress, reportedCheckNames, unreportedRequiredContexts, } from "../checks/unreported-required.mjs";
2
+ import { loadMergeTargetStatus } from "../github/merge-target-rules.mjs";
3
+ /** Load trunk rules for a stack and diff them against the head's check names. */
4
+ export async function collectUnreportedRequired(input) {
5
+ const localContexts = input.batchData.branchRules?.requiredStatusCheckContexts ??
6
+ input.batchData.branchProtection?.requiredStatusCheckContexts ??
7
+ [];
8
+ const target = await loadMergeTargetStatus({
9
+ owner: input.owner,
10
+ name: input.name,
11
+ pr: input.pr,
12
+ baseRefName: input.batchData.baseRefName,
13
+ headRefName: input.batchData.headRefName,
14
+ localContexts,
15
+ stack: input.batchData.stack,
16
+ });
17
+ const unreported = unreportedRequiredContexts(target.contexts, reportedCheckNames(input.checks));
18
+ const actionsRunning = actionsWorkflowInProgress(input.suites, new Set(input.relevantEvents));
19
+ return {
20
+ ...(unreported.length > 0 && { unreportedRequiredChecks: unreported }),
21
+ ...(target.trunkBehindBy !== undefined &&
22
+ target.trunkBehindBy > 0 && { trunkBehindBy: target.trunkBehindBy }),
23
+ ...(actionsRunning && { actionsWorkflowInProgress: true }),
24
+ ...(target.stackBottomPr !== undefined && { stackBottomPr: target.stackBottomPr }),
25
+ headRefName: input.batchData.headRefName,
26
+ hasUnreportedRequired: unreported.length > 0,
27
+ };
28
+ }
29
+ /**
30
+ * A fingerprint hit can keep a stale trunk `behindBy`. Refresh the trunk compare
31
+ * and the required-context diff without refetching the whole batch.
32
+ */
33
+ export async function refreshCachedUnreported(report, repo) {
34
+ const stack = report.mergeStatus?.mergeRequirements?.stack;
35
+ if (!stack || !report.headRefName)
36
+ return report;
37
+ const target = await loadMergeTargetStatus({
38
+ owner: repo.owner,
39
+ name: repo.name,
40
+ pr: report.pr,
41
+ baseRefName: report.baseBranch,
42
+ headRefName: report.headRefName,
43
+ localContexts: report.mergeStatus.mergeRequirements?.requiredStatusChecks?.contexts ?? [],
44
+ stack,
45
+ });
46
+ const unreported = unreportedRequiredContexts(target.contexts, reportedCheckNames(reportChecks(report)));
47
+ const next = { ...report };
48
+ if (unreported.length > 0)
49
+ next.unreportedRequiredChecks = unreported;
50
+ else
51
+ delete next.unreportedRequiredChecks;
52
+ if (target.trunkBehindBy !== undefined && target.trunkBehindBy > 0) {
53
+ next.trunkBehindBy = target.trunkBehindBy;
54
+ }
55
+ else
56
+ delete next.trunkBehindBy;
57
+ if (target.stackBottomPr !== undefined)
58
+ next.stackBottomPr = target.stackBottomPr;
59
+ if (unreported.length > 0 && next.status === "READY")
60
+ next.status = "PENDING";
61
+ return next;
62
+ }
63
+ function reportChecks(report) {
64
+ return [
65
+ ...report.checks.passing,
66
+ ...report.checks.failing,
67
+ ...report.checks.inProgress,
68
+ ...report.checks.skipped,
69
+ ...report.checks.filtered,
70
+ ...(report.checks.ignored ?? []),
71
+ ...(report.checks.supersededNames ?? []).map((name) => ({ name })),
72
+ ];
73
+ }