pr-shepherd 0.54.1 → 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 (114) 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-formatter.mjs +29 -9
  17. package/bin/cli/iterate-instructions.mjs +6 -0
  18. package/bin/cli/iterate-lean.mjs +15 -0
  19. package/bin/cli/poll-summary-emitter.mjs +4 -1
  20. package/bin/cli/poll-summary-formatter.mjs +10 -3
  21. package/bin/cli/stack-overview.d.mts +48 -0
  22. package/bin/cli/stack-overview.mjs +138 -0
  23. package/bin/commands/check-fingerprint.mjs +2 -1
  24. package/bin/commands/check-status.d.mts +1 -1
  25. package/bin/commands/check-status.mjs +8 -3
  26. package/bin/commands/check-unreported.d.mts +26 -0
  27. package/bin/commands/check-unreported.mjs +73 -0
  28. package/bin/commands/check.mjs +44 -38
  29. package/bin/commands/iterate/base.mjs +9 -0
  30. package/bin/commands/iterate/escalate.mjs +4 -0
  31. package/bin/commands/iterate/index.mjs +69 -3
  32. package/bin/commands/iterate/merge-state.mjs +12 -12
  33. package/bin/commands/iterate/merge.d.mts +12 -2
  34. package/bin/commands/iterate/merge.mjs +32 -2
  35. package/bin/commands/iterate/unreported-required.d.mts +18 -0
  36. package/bin/commands/iterate/unreported-required.mjs +122 -0
  37. package/bin/commands/poll-summary.mjs +1 -0
  38. package/bin/commands/poll.mjs +9 -2
  39. package/bin/commands/ready-mergeability.d.mts +1 -1
  40. package/bin/commands/ready-mergeability.mjs +2 -2
  41. package/bin/commands/rule-auto-resolve-format.d.mts +22 -0
  42. package/bin/commands/rule-auto-resolve-format.mjs +151 -0
  43. package/bin/commands/rule-auto-resolve.d.mts +21 -0
  44. package/bin/commands/rule-auto-resolve.mjs +113 -0
  45. package/bin/commands/stack-drain.d.mts +0 -5
  46. package/bin/commands/stack-drain.mjs +26 -16
  47. package/bin/commands/stack-merge-flag.d.mts +7 -0
  48. package/bin/commands/stack-merge-flag.mjs +6 -0
  49. package/bin/config/load.d.mts +3 -0
  50. package/bin/config/load.mjs +9 -2
  51. package/bin/config/merge-method.d.mts +27 -0
  52. package/bin/config/merge-method.mjs +56 -0
  53. package/bin/exit-codes.d.mts +1 -1
  54. package/bin/exit-codes.mjs +2 -1
  55. package/bin/github/batch-parse-suites.d.mts +3 -0
  56. package/bin/github/batch-parse-suites.mjs +15 -0
  57. package/bin/github/batch-parsers.d.mts +1 -1
  58. package/bin/github/batch-parsers.mjs +7 -0
  59. package/bin/github/batch-raw-types.d.mts +5 -0
  60. package/bin/github/batch.d.mts +3 -0
  61. package/bin/github/batch.mjs +9 -1
  62. package/bin/github/errors.d.mts +6 -0
  63. package/bin/github/errors.mjs +23 -5
  64. package/bin/github/fingerprint-fields.d.mts +1 -0
  65. package/bin/github/fingerprint-fields.mjs +1 -1
  66. package/bin/github/gql/batch-pr-page.gql +2 -0
  67. package/bin/github/gql/batch-pr.gql +5 -0
  68. package/bin/github/gql/commit-check-suites.gql +1 -0
  69. package/bin/github/gql/poll-stack-summary.gql +6 -0
  70. package/bin/github/gql/poll-summary-annotation-probe.gql +32 -0
  71. package/bin/github/gql/poll-summary-check-contexts.gql +2 -3
  72. package/bin/github/gql/poll-summary-fragment.gql +16 -0
  73. package/bin/github/gql/pr-merge-policy.gql +1 -44
  74. package/bin/github/gql/ref-rules-query.gql +18 -0
  75. package/bin/github/gql/ref-rules.gql +46 -0
  76. package/bin/github/graphql-internal-retry.d.mts +1 -1
  77. package/bin/github/graphql-internal-retry.mjs +4 -3
  78. package/bin/github/merge-queue-checks.mjs +7 -3
  79. package/bin/github/merge-target-rules.d.mts +20 -0
  80. package/bin/github/merge-target-rules.mjs +54 -0
  81. package/bin/github/poll-summary-annotation-probe.d.mts +19 -0
  82. package/bin/github/poll-summary-annotation-probe.mjs +88 -0
  83. package/bin/github/poll-summary-fingerprint.mjs +1 -1
  84. package/bin/github/poll-summary-projector.d.mts +1 -1
  85. package/bin/github/poll-summary-projector.mjs +19 -12
  86. package/bin/github/poll-summary-queue-removal.mjs +7 -3
  87. package/bin/github/poll-summary-raw.d.mts +16 -0
  88. package/bin/github/poll-summary-readiness.mjs +1 -0
  89. package/bin/github/poll-summary-route.mjs +5 -0
  90. package/bin/github/poll-summary-unreported.d.mts +6 -0
  91. package/bin/github/poll-summary-unreported.mjs +51 -0
  92. package/bin/github/poll-summary.d.mts +1 -0
  93. package/bin/github/poll-summary.mjs +28 -3
  94. package/bin/github/queries.d.mts +7 -0
  95. package/bin/github/queries.mjs +9 -2
  96. package/bin/github/queue-removal-freshness.d.mts +20 -5
  97. package/bin/github/queue-removal-freshness.mjs +37 -10
  98. package/bin/github/stack-read.d.mts +4 -0
  99. package/bin/github/stack-read.mjs +13 -1
  100. package/bin/mcp/server.mjs +6 -1
  101. package/bin/state/ci-retrigger.d.mts +25 -0
  102. package/bin/state/ci-retrigger.mjs +48 -0
  103. package/bin/types/escalate.d.mts +1 -1
  104. package/bin/types/github.d.mts +11 -0
  105. package/bin/types/iterate.d.mts +14 -3
  106. package/bin/types/poll-summary.d.mts +13 -0
  107. package/bin/types/report.d.mts +32 -1
  108. package/package.json +1 -1
  109. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  110. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  111. package/plugins/pr-shepherd/.mcp.json +1 -1
  112. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +2 -2
  113. package/plugins/pr-shepherd/skills/reduce-pr-noise/references/classifiers.md +1 -1
  114. package/src/classify/types.mts +1 -1
@@ -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
@@ -66,9 +67,23 @@ export function projectIterateLean(result, opts) {
66
67
  ...((result.supersededNames?.length ?? 0) > 0 && {
67
68
  supersededNames: result.supersededNames,
68
69
  }),
70
+ ...((result.unreportedRequiredChecks?.length ?? 0) > 0 && {
71
+ unreportedRequiredChecks: result.unreportedRequiredChecks,
72
+ }),
73
+ ...(result.trunkBehindBy !== undefined &&
74
+ result.trunkBehindBy > 0 && { trunkBehindBy: result.trunkBehindBy }),
69
75
  ...(result.quotaWarning && { quotaWarning: result.quotaWarning }),
76
+ ...(result.ruleAutoResolve && {
77
+ ruleAutoResolve: projectRuleAutoResolve(result.ruleAutoResolve),
78
+ }),
70
79
  };
71
80
  switch (result.action) {
81
+ case "ready":
82
+ return {
83
+ ...base,
84
+ log: adaptIterateLog(result.log),
85
+ instructions: simpleInstructions(result),
86
+ };
72
87
  case "wait":
73
88
  return {
74
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
+ }
@@ -2,6 +2,7 @@ import { fetchPrBatch } from "../github/batch.mjs";
2
2
  import { queueRemovalAppliesToHead } from "../github/queue-removal-freshness.mjs";
3
3
  import { storePrFingerprint } from "../state/pr-fingerprint.mjs";
4
4
  import { tryReuseFingerprintReport } from "./check-fingerprint.mjs";
5
+ import { collectUnreportedRequired, refreshCachedUnreported } from "./check-unreported.mjs";
5
6
  import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
6
7
  import { classifyChecks, getCiVerdict } from "../checks/classify.mjs";
7
8
  import { mergeStartupFailureChecks } from "../checks/startup-failures.mjs";
@@ -17,7 +18,7 @@ import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs"
17
18
  import { threadTranscriptBody } from "../threads/transcript.mjs";
18
19
  import { classifyThreadVisibility } from "../comments/thread-visibility.mjs";
19
20
  import { classifyReviewsForDisplay, classifyChangesRequestedReviewsForDisplay, } from "../comments/review-visibility.mjs";
20
- import { autoMinimizeComments, autoResolveThreads } from "../comments/resolve.mjs";
21
+ import { applySuppressedRuleAutoResolve } from "./rule-auto-resolve.mjs";
21
22
  import { markReviewInlineThreadMarkers } from "../comments/review-thread-markers.mjs";
22
23
  import { isConfiguredBotAuthor, isHumanAuthor, normalizeBotUsernames, } from "../comments/authors.mjs";
23
24
  import { buildThreadMutationRouting, threadHasAuthorizedMutation, } from "./iterate/thread-mutation-routing.mjs";
@@ -37,7 +38,7 @@ export async function runCheck(opts) {
37
38
  if (reuseFingerprint) {
38
39
  const cached = await tryReuseFingerprintReport(prNumber, repo, stateKey, config);
39
40
  if (cached)
40
- return cached;
41
+ return refreshCachedUnreported(cached, repo);
41
42
  }
42
43
  const paginateApprovedReviews = config.iterate.minimizeApprovals;
43
44
  const result = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews });
@@ -67,8 +68,10 @@ export async function runCheck(opts) {
67
68
  // it as stale/updated rather than as still current, so Shepherd doesn't escalate
68
69
  // `merge-queue-removed` permanently on data it can no longer check. A squash or rebase
69
70
  // queue commit has one parent and does not list the PR head; that removal stays current
70
- // until the head's committer time is later than the removal. The raw removal fields still
71
- // render in the merge-queue header regardless of this flag.
71
+ // until this head reached the PR after it. The push time is the earliest pull_request
72
+ // check on the head, with committer time as the fallback when no check time is
73
+ // available. The raw removal fields still render in the merge-queue header regardless
74
+ // of this flag.
72
75
  const headCommittedAtUnix = batchData.activity?.latestCommitCommittedAtUnix;
73
76
  const headUpdatedAfterRemoval = Boolean(latestRemoval &&
74
77
  !queueRemovalAppliesToHead({
@@ -76,6 +79,9 @@ export async function runCheck(opts) {
76
79
  headOid: batchData.headRefOid,
77
80
  ...(headCommittedAtUnix !== undefined &&
78
81
  headCommittedAtUnix !== null && { headCommittedAtUnix }),
82
+ ...(batchData.headPushedAtUnix !== undefined && {
83
+ headPushedAtUnix: batchData.headPushedAtUnix,
84
+ }),
79
85
  removedAtUnix: latestRemoval.createdAtUnix,
80
86
  }));
81
87
  const queueRawChecks = batchData.isInMergeQueue
@@ -154,14 +160,23 @@ export async function runCheck(opts) {
154
160
  visibleChangesRequestedIds.has(review.id));
155
161
  }).length;
156
162
  const approvedReviews = approvedReviewVisibility.visible;
157
- let status = computeStatus(verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, changesRequestedReviewCount);
163
+ const unreported = await collectUnreportedRequired({
164
+ batchData,
165
+ checks: allChecks,
166
+ suites: result.headWorkflowSuites ?? [],
167
+ owner: repo.owner,
168
+ name: repo.name,
169
+ pr: prNumber,
170
+ relevantEvents: config.checks.ciTriggerEvents,
171
+ });
172
+ let status = computeStatus(verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, changesRequestedReviewCount, unreported.hasUnreportedRequired);
158
173
  // Resolve any pending mergeability refresh (and the resulting MERGED/CLOSED short-circuit)
159
174
  // before deciding what to persist below — deferWhileQueued must see the same final,
160
175
  // possibly-refreshed mergeStatus that commands/iterate/index.mts acts on, not the pre-refresh
161
176
  // snapshot. A conflict newly discovered by this REST read is exactly the kind of check-driven
162
177
  // signal that keeps a queued PR out of the deferral path.
163
178
  if (status === "READY" && !didRefreshMergeability) {
164
- const refreshed = await refreshReadyMergeability(prNumber, repo, batchData, verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, changesRequestedReviewCount);
179
+ const refreshed = await refreshReadyMergeability(prNumber, repo, batchData, verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, changesRequestedReviewCount, unreported.hasUnreportedRequired);
165
180
  batchData = refreshed.batchData;
166
181
  mergeStatus = refreshed.mergeStatus;
167
182
  status = refreshed.status;
@@ -236,7 +251,13 @@ export async function runCheck(opts) {
236
251
  ruleAutoResolveCommentIds: partition.ruleAutoResolveCommentIds.filter((id) => batchData.comments.find((comment) => comment.id === id)?.viewerCanMinimize === true),
237
252
  ruleAutoResolveReviewSummaryIds: partition.ruleAutoResolveReviewSummaryIds.filter((id) => batchData.reviewSummaries.find((review) => review.id === id)?.viewerCanMinimize === true),
238
253
  };
239
- const { threadIds: authorizedRuleAutoResolveThreadIds, commentIds: ruleAutoResolveCommentIds, reviewSummaryIds: ruleAutoResolveReviewSummaryIds, } = await remainingRuleAutoResolveIds(authorizedPartition, opts.autoMinimizeSuppressed);
254
+ const { threadIds: authorizedRuleAutoResolveThreadIds, commentIds: ruleAutoResolveCommentIds, reviewSummaryIds: ruleAutoResolveReviewSummaryIds, autoResolved, autoMinimized, autoResolveErrors, errorReasons, } = await applySuppressedRuleAutoResolve({
255
+ enabled: opts.autoMinimizeSuppressed === true,
256
+ partition: authorizedPartition,
257
+ batch: batchData,
258
+ prNumber,
259
+ repo,
260
+ });
240
261
  const visibleMutationThreadIds = new Set([...threadVisibility.activeThreads, ...threadVisibility.resolutionOnlyThreads].map((thread) => thread.id));
241
262
  const ruleAutoResolveThreadIds = [
242
263
  ...authorizedRuleAutoResolveThreadIds,
@@ -254,6 +275,7 @@ export async function runCheck(opts) {
254
275
  pr: prNumber,
255
276
  nodeId: batchData.nodeId,
256
277
  headSha: batchData.headRefOid,
278
+ headRefName: unreported.headRefName,
257
279
  repo: `${repo.owner}/${repo.name}`,
258
280
  ...(batchData.viewerAuthorization && { viewerAuthorization: batchData.viewerAuthorization }),
259
281
  status,
@@ -276,8 +298,9 @@ export async function runCheck(opts) {
276
298
  threads: {
277
299
  actionable: threadVisibility.activeThreads,
278
300
  resolutionOnly: threadVisibility.resolutionOnlyThreads,
279
- autoResolved: [],
280
- autoResolveErrors: [],
301
+ autoResolved,
302
+ autoResolveErrors,
303
+ ...(errorReasons.length > 0 && { autoResolveErrorReasons: errorReasons }),
281
304
  firstLook: threadVisibility.firstLookThreads,
282
305
  ...(ruleAutoResolveThreadIds.length > 0
283
306
  ? { ruleAutoResolveIds: ruleAutoResolveThreadIds }
@@ -287,6 +310,7 @@ export async function runCheck(opts) {
287
310
  actionable: visibleCommentClassification.actionable,
288
311
  minimizeIds: [...visibleCommentClassification.minimizeIds, ...ruleAutoResolveCommentIds],
289
312
  firstLook: firstLookComments,
313
+ ...(autoMinimized.length > 0 && { autoMinimized }),
290
314
  },
291
315
  changesRequestedReviews,
292
316
  reviewSummaries: seenSummaries,
@@ -297,6 +321,9 @@ export async function runCheck(opts) {
297
321
  ? { ruleAutoResolveReviewSummaryIds }
298
322
  : undefined),
299
323
  branchProtection: batchData.branchProtection,
324
+ ...(batchData.allowedMergeMethods && {
325
+ allowedMergeMethods: batchData.allowedMergeMethods,
326
+ }),
300
327
  activity: batchData.activity,
301
328
  ...(hasQueueState && {
302
329
  mergeQueue: {
@@ -316,38 +343,17 @@ export async function runCheck(opts) {
316
343
  ...(headUpdatedAfterRemoval && { headUpdatedAfterRemoval: true }),
317
344
  },
318
345
  }),
346
+ ...(unreported.unreportedRequiredChecks && {
347
+ unreportedRequiredChecks: unreported.unreportedRequiredChecks,
348
+ }),
349
+ ...(unreported.trunkBehindBy !== undefined && { trunkBehindBy: unreported.trunkBehindBy }),
350
+ ...(unreported.actionsWorkflowInProgress && {
351
+ actionsWorkflowInProgress: true,
352
+ }),
353
+ ...(unreported.stackBottomPr !== undefined && { stackBottomPr: unreported.stackBottomPr }),
319
354
  };
320
355
  if (result.fingerprint) {
321
356
  await storePrFingerprint(stateKey, result.fingerprint, report, config);
322
357
  }
323
358
  return report;
324
359
  }
325
- async function remainingRuleAutoResolveIds(partition, autoMinimizeSuppressed = false) {
326
- const consumedIds = autoMinimizeSuppressed
327
- ? await selfApplySuppressedRuleAutoResolve(partition)
328
- : { minimized: new Set(), resolvedThreads: new Set() };
329
- return {
330
- threadIds: partition.ruleAutoResolveThreadIds.filter((id) => !consumedIds.resolvedThreads.has(id)),
331
- commentIds: partition.ruleAutoResolveCommentIds.filter((id) => !consumedIds.minimized.has(id)),
332
- reviewSummaryIds: partition.ruleAutoResolveReviewSummaryIds.filter((id) => !consumedIds.minimized.has(id)),
333
- };
334
- }
335
- async function selfApplySuppressedRuleAutoResolve(partition) {
336
- const minimizeIds = [
337
- ...partition.ruleAutoResolveCommentIds.filter((id) => partition.suppressedCommentIds.has(id)),
338
- ...partition.ruleAutoResolveReviewSummaryIds.filter((id) => partition.suppressedReviewSummaryIds.has(id)),
339
- ];
340
- const threadIds = partition.ruleAutoResolveThreadIds.filter((id) => partition.suppressedThreadIds.has(id));
341
- const [minimized, resolved] = await Promise.all([
342
- minimizeIds.length > 0
343
- ? autoMinimizeComments(minimizeIds)
344
- : Promise.resolve({ minimized: [], errors: [] }),
345
- threadIds.length > 0
346
- ? autoResolveThreads(threadIds)
347
- : Promise.resolve({ resolved: [], errors: [] }),
348
- ]);
349
- return {
350
- minimized: new Set(minimized.minimized),
351
- resolvedThreads: new Set(resolved.resolved),
352
- };
353
- }
@@ -1,5 +1,7 @@
1
+ import { ruleAutoResolveFromReport } from "../rule-auto-resolve-format.mjs";
1
2
  import { buildActiveChecks, buildRelevantChecks, buildSummary, buildSuppressedCheckFields, } from "./helpers.mjs";
2
3
  export function buildIterateBase(report, readyState) {
4
+ const ruleAutoResolve = ruleAutoResolveFromReport(report);
3
5
  return {
4
6
  pr: report.pr,
5
7
  repo: report.repo,
@@ -21,6 +23,13 @@ export function buildIterateBase(report, readyState) {
21
23
  ...buildSuppressedCheckFields(report),
22
24
  activity: report.activity,
23
25
  mergeQueue: report.mergeQueue,
26
+ ...(report.unreportedRequiredChecks &&
27
+ report.unreportedRequiredChecks.length > 0 && {
28
+ unreportedRequiredChecks: report.unreportedRequiredChecks,
29
+ }),
30
+ ...(report.trunkBehindBy !== undefined &&
31
+ report.trunkBehindBy > 0 && { trunkBehindBy: report.trunkBehindBy }),
24
32
  ...(report.fingerprintReused === true && { fingerprintReused: true }),
33
+ ...(ruleAutoResolve && { ruleAutoResolve }),
25
34
  };
26
35
  }
@@ -240,6 +240,10 @@ export function buildEscalateSuggestion(triggers, detail) {
240
240
  const duration = detail ?? "60 minutes";
241
241
  return `No progress detected for ${duration} — state has not changed. This is a manual checkpoint: inspect the PR and apply a manual fix before resuming.`;
242
242
  }
243
+ if (triggers.includes("required-checks-unreported")) {
244
+ const names = detail ? ` (${detail})` : "";
245
+ return `Required checks are still unreported after one close/reopen of this head${names}. Another reopen will not create a job the workflow does not emit. Path filters are the usual cause.`;
246
+ }
243
247
  if (triggers.includes("stall-state-unavailable")) {
244
248
  const reason = detail ?? "unknown error";
245
249
  return `The stall timer could not be saved (${reason}). Fix PR_SHEPHERD_STATE_DIR or the directory permissions, then resume. Automated polling is paused because a stuck loop would not be detected.`;