pr-shepherd 0.54.1 → 0.55.1

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 (121) 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 +32 -9
  17. package/bin/cli/iterate-instructions.mjs +6 -0
  18. package/bin/cli/iterate-lean.mjs +17 -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 +28 -0
  27. package/bin/commands/check-unreported.mjs +101 -0
  28. package/bin/commands/check.mjs +45 -38
  29. package/bin/commands/commit-suggestion-instruction.d.mts +1 -1
  30. package/bin/commands/commit-suggestion-instruction.mjs +1 -1
  31. package/bin/commands/iterate/base.mjs +11 -0
  32. package/bin/commands/iterate/check-instructions.d.mts +1 -1
  33. package/bin/commands/iterate/check-instructions.mjs +1 -1
  34. package/bin/commands/iterate/escalate.mjs +4 -0
  35. package/bin/commands/iterate/index.mjs +69 -3
  36. package/bin/commands/iterate/merge-state.mjs +12 -12
  37. package/bin/commands/iterate/merge.d.mts +12 -2
  38. package/bin/commands/iterate/merge.mjs +32 -2
  39. package/bin/commands/iterate/unreported-required.d.mts +18 -0
  40. package/bin/commands/iterate/unreported-required.mjs +136 -0
  41. package/bin/commands/poll-summary.mjs +1 -0
  42. package/bin/commands/poll.mjs +9 -2
  43. package/bin/commands/ready-mergeability.d.mts +1 -1
  44. package/bin/commands/ready-mergeability.mjs +2 -2
  45. package/bin/commands/rule-auto-resolve-format.d.mts +22 -0
  46. package/bin/commands/rule-auto-resolve-format.mjs +151 -0
  47. package/bin/commands/rule-auto-resolve.d.mts +21 -0
  48. package/bin/commands/rule-auto-resolve.mjs +113 -0
  49. package/bin/commands/shepherd-journal.d.mts +1 -1
  50. package/bin/commands/shepherd-journal.mjs +1 -1
  51. package/bin/commands/stack-drain.d.mts +0 -5
  52. package/bin/commands/stack-drain.mjs +26 -16
  53. package/bin/commands/stack-merge-flag.d.mts +7 -0
  54. package/bin/commands/stack-merge-flag.mjs +6 -0
  55. package/bin/config/load.d.mts +3 -0
  56. package/bin/config/load.mjs +9 -2
  57. package/bin/config/merge-method.d.mts +27 -0
  58. package/bin/config/merge-method.mjs +56 -0
  59. package/bin/exit-codes.d.mts +1 -1
  60. package/bin/exit-codes.mjs +2 -1
  61. package/bin/github/batch-parse-suites.d.mts +3 -0
  62. package/bin/github/batch-parse-suites.mjs +15 -0
  63. package/bin/github/batch-parsers.d.mts +1 -1
  64. package/bin/github/batch-parsers.mjs +7 -0
  65. package/bin/github/batch-raw-types.d.mts +5 -0
  66. package/bin/github/batch.d.mts +3 -0
  67. package/bin/github/batch.mjs +9 -1
  68. package/bin/github/errors.d.mts +6 -0
  69. package/bin/github/errors.mjs +23 -5
  70. package/bin/github/fingerprint-fields.d.mts +1 -0
  71. package/bin/github/fingerprint-fields.mjs +1 -1
  72. package/bin/github/gql/base-behind.gql +20 -0
  73. package/bin/github/gql/batch-pr-page.gql +2 -0
  74. package/bin/github/gql/batch-pr.gql +5 -0
  75. package/bin/github/gql/commit-check-suites.gql +1 -0
  76. package/bin/github/gql/poll-stack-summary.gql +6 -0
  77. package/bin/github/gql/poll-summary-annotation-probe.gql +32 -0
  78. package/bin/github/gql/poll-summary-check-contexts.gql +2 -3
  79. package/bin/github/gql/poll-summary-fragment.gql +16 -0
  80. package/bin/github/gql/pr-merge-policy.gql +1 -44
  81. package/bin/github/gql/ref-rules-query.gql +18 -0
  82. package/bin/github/gql/ref-rules.gql +46 -0
  83. package/bin/github/graphql-internal-retry.d.mts +1 -1
  84. package/bin/github/graphql-internal-retry.mjs +4 -3
  85. package/bin/github/merge-queue-checks.mjs +7 -3
  86. package/bin/github/merge-target-rules.d.mts +28 -0
  87. package/bin/github/merge-target-rules.mjs +77 -0
  88. package/bin/github/poll-summary-annotation-probe.d.mts +19 -0
  89. package/bin/github/poll-summary-annotation-probe.mjs +88 -0
  90. package/bin/github/poll-summary-fingerprint.mjs +1 -1
  91. package/bin/github/poll-summary-projector.d.mts +1 -1
  92. package/bin/github/poll-summary-projector.mjs +19 -12
  93. package/bin/github/poll-summary-queue-removal.mjs +7 -3
  94. package/bin/github/poll-summary-raw.d.mts +16 -0
  95. package/bin/github/poll-summary-readiness.mjs +1 -0
  96. package/bin/github/poll-summary-route.mjs +5 -0
  97. package/bin/github/poll-summary-unreported.d.mts +6 -0
  98. package/bin/github/poll-summary-unreported.mjs +51 -0
  99. package/bin/github/poll-summary.d.mts +1 -0
  100. package/bin/github/poll-summary.mjs +28 -3
  101. package/bin/github/queries.d.mts +9 -0
  102. package/bin/github/queries.mjs +11 -2
  103. package/bin/github/queue-removal-freshness.d.mts +20 -5
  104. package/bin/github/queue-removal-freshness.mjs +37 -10
  105. package/bin/github/stack-read.d.mts +4 -0
  106. package/bin/github/stack-read.mjs +13 -1
  107. package/bin/mcp/server.mjs +6 -1
  108. package/bin/state/ci-retrigger.d.mts +25 -0
  109. package/bin/state/ci-retrigger.mjs +48 -0
  110. package/bin/types/escalate.d.mts +1 -1
  111. package/bin/types/github.d.mts +11 -0
  112. package/bin/types/iterate.d.mts +16 -3
  113. package/bin/types/poll-summary.d.mts +13 -0
  114. package/bin/types/report.d.mts +34 -1
  115. package/package.json +1 -1
  116. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  117. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  118. package/plugins/pr-shepherd/.mcp.json +1 -1
  119. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -3
  120. package/plugins/pr-shepherd/skills/reduce-pr-noise/references/classifiers.md +1 -1
  121. 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,25 @@ 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 }),
75
+ ...(result.baseBehindBy !== undefined &&
76
+ result.baseBehindBy > 0 && { baseBehindBy: result.baseBehindBy }),
69
77
  ...(result.quotaWarning && { quotaWarning: result.quotaWarning }),
78
+ ...(result.ruleAutoResolve && {
79
+ ruleAutoResolve: projectRuleAutoResolve(result.ruleAutoResolve),
80
+ }),
70
81
  };
71
82
  switch (result.action) {
83
+ case "ready":
84
+ return {
85
+ ...base,
86
+ log: adaptIterateLog(result.log),
87
+ instructions: simpleInstructions(result),
88
+ };
72
89
  case "wait":
73
90
  return {
74
91
  ...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,28 @@
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
+ /** Commits on the PR base that this head does not contain. Omitted when zero or a stack. */
8
+ baseBehindBy?: number;
9
+ actionsWorkflowInProgress?: true;
10
+ stackBottomPr?: number;
11
+ headRefName: string;
12
+ hasUnreportedRequired: boolean;
13
+ }
14
+ /** Load trunk rules for a stack and diff them against the head's check names. */
15
+ export declare function collectUnreportedRequired(input: {
16
+ batchData: BatchPrData;
17
+ checks: readonly CheckRun[];
18
+ suites: readonly WorkflowSuiteSnapshot[];
19
+ owner: string;
20
+ name: string;
21
+ pr: number;
22
+ relevantEvents: readonly string[];
23
+ }): Promise<UnreportedRequiredFields>;
24
+ /**
25
+ * A fingerprint hit can keep a stale trunk `behindBy`. Refresh the trunk compare
26
+ * and the required-context diff without refetching the whole batch.
27
+ */
28
+ export declare function refreshCachedUnreported(report: ShepherdReport, repo: RepoInfo): Promise<ShepherdReport>;
@@ -0,0 +1,101 @@
1
+ import { actionsWorkflowInProgress, reportedCheckNames, unreportedRequiredContexts, } from "../checks/unreported-required.mjs";
2
+ import { loadBaseBehindBy, 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
+ const baseBehindBy = unreported.length > 0 && !input.batchData.stack
20
+ ? await loadBaseBehindBy(input.owner, input.name, input.batchData.baseRefName, input.batchData.headRefOid)
21
+ : 0;
22
+ return {
23
+ ...(unreported.length > 0 && { unreportedRequiredChecks: unreported }),
24
+ ...(target.trunkBehindBy !== undefined &&
25
+ target.trunkBehindBy > 0 && { trunkBehindBy: target.trunkBehindBy }),
26
+ ...(baseBehindBy > 0 && { baseBehindBy }),
27
+ ...(actionsRunning && { actionsWorkflowInProgress: true }),
28
+ ...(target.stackBottomPr !== undefined && { stackBottomPr: target.stackBottomPr }),
29
+ headRefName: input.batchData.headRefName,
30
+ hasUnreportedRequired: unreported.length > 0,
31
+ };
32
+ }
33
+ /**
34
+ * A fingerprint hit can keep a stale trunk `behindBy`. Refresh the trunk compare
35
+ * and the required-context diff without refetching the whole batch.
36
+ */
37
+ export async function refreshCachedUnreported(report, repo) {
38
+ const stack = report.mergeStatus?.mergeRequirements?.stack;
39
+ if (!report.headRefName)
40
+ return report;
41
+ if (!stack)
42
+ return refreshBaseBehind(report, repo);
43
+ const target = await loadMergeTargetStatus({
44
+ owner: repo.owner,
45
+ name: repo.name,
46
+ pr: report.pr,
47
+ baseRefName: report.baseBranch,
48
+ headRefName: report.headRefName,
49
+ localContexts: report.mergeStatus.mergeRequirements?.requiredStatusChecks?.contexts ?? [],
50
+ stack,
51
+ });
52
+ const unreported = unreportedRequiredContexts(target.contexts, reportedCheckNames(reportChecks(report)));
53
+ const next = { ...report };
54
+ if (unreported.length > 0)
55
+ next.unreportedRequiredChecks = unreported;
56
+ else
57
+ delete next.unreportedRequiredChecks;
58
+ if (target.trunkBehindBy !== undefined && target.trunkBehindBy > 0) {
59
+ next.trunkBehindBy = target.trunkBehindBy;
60
+ }
61
+ else
62
+ delete next.trunkBehindBy;
63
+ if (target.stackBottomPr !== undefined)
64
+ next.stackBottomPr = target.stackBottomPr;
65
+ if (unreported.length > 0 && next.status === "READY")
66
+ next.status = "PENDING";
67
+ return next;
68
+ }
69
+ /** A fingerprint hit can keep a stale base compare after main moves. */
70
+ async function refreshBaseBehind(report, repo) {
71
+ if ((report.unreportedRequiredChecks?.length ?? 0) === 0) {
72
+ if (report.baseBehindBy === undefined)
73
+ return report;
74
+ const cleared = { ...report };
75
+ delete cleared.baseBehindBy;
76
+ return cleared;
77
+ }
78
+ // A missing head OID must not fall back to the branch name. That name can
79
+ // resolve inside the base repository when the PR comes from a fork.
80
+ const headOid = report.headSha;
81
+ if (!headOid)
82
+ return report;
83
+ const behind = await loadBaseBehindBy(repo.owner, repo.name, report.baseBranch, headOid);
84
+ const next = { ...report };
85
+ if (behind > 0)
86
+ next.baseBehindBy = behind;
87
+ else
88
+ delete next.baseBehindBy;
89
+ return next;
90
+ }
91
+ function reportChecks(report) {
92
+ return [
93
+ ...report.checks.passing,
94
+ ...report.checks.failing,
95
+ ...report.checks.inProgress,
96
+ ...report.checks.skipped,
97
+ ...report.checks.filtered,
98
+ ...(report.checks.ignored ?? []),
99
+ ...(report.checks.supersededNames ?? []).map((name) => ({ name })),
100
+ ];
101
+ }
@@ -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,18 @@ 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.baseBehindBy !== undefined && { baseBehindBy: unreported.baseBehindBy }),
351
+ ...(unreported.actionsWorkflowInProgress && {
352
+ actionsWorkflowInProgress: true,
353
+ }),
354
+ ...(unreported.stackBottomPr !== undefined && { stackBottomPr: unreported.stackBottomPr }),
319
355
  };
320
356
  if (result.fingerprint) {
321
357
  await storePrFingerprint(stateKey, result.fingerprint, report, config);
322
358
  }
323
359
  return report;
324
360
  }
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
- }
@@ -3,7 +3,7 @@
3
3
  * Currently emitted by iterate `fix_code` for suggestion review threads. The CLI keeps
4
4
  * only the trigger and the concrete command; refusal/drift handling is invariant across
5
5
  * every invocation, so it lives in the pr-shepherd skill's "Suggestion patches" playbook
6
- * instead of being re-emitted every tick (see CLAUDE.md "Keep skills and loop prompts
6
+ * instead of being re-emitted every tick (see AGENTS.md "Keep skills and loop prompts
7
7
  * minimal").
8
8
  * @param sectionName - The markdown section heading where suggestion threads appear,
9
9
  * e.g. `"## Review threads"`.
@@ -4,7 +4,7 @@ import { buildPrShepherdCommand } from "../cli/runner.mjs";
4
4
  * Currently emitted by iterate `fix_code` for suggestion review threads. The CLI keeps
5
5
  * only the trigger and the concrete command; refusal/drift handling is invariant across
6
6
  * every invocation, so it lives in the pr-shepherd skill's "Suggestion patches" playbook
7
- * instead of being re-emitted every tick (see CLAUDE.md "Keep skills and loop prompts
7
+ * instead of being re-emitted every tick (see AGENTS.md "Keep skills and loop prompts
8
8
  * minimal").
9
9
  * @param sectionName - The markdown section heading where suggestion threads appear,
10
10
  * e.g. `"## Review threads"`.