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
@@ -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,11 +275,13 @@ 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,
260
282
  baseBranch: batchData.baseRefName,
261
283
  ...(batchData.baseRefOid && { baseRefOid: batchData.baseRefOid }),
284
+ ...(result.headCheckSuitesEmpty && { headCheckSuitesEmpty: true }),
262
285
  mergeStatus,
263
286
  checks: {
264
287
  passing: merged.passing,
@@ -275,8 +298,9 @@ export async function runCheck(opts) {
275
298
  threads: {
276
299
  actionable: threadVisibility.activeThreads,
277
300
  resolutionOnly: threadVisibility.resolutionOnlyThreads,
278
- autoResolved: [],
279
- autoResolveErrors: [],
301
+ autoResolved,
302
+ autoResolveErrors,
303
+ ...(errorReasons.length > 0 && { autoResolveErrorReasons: errorReasons }),
280
304
  firstLook: threadVisibility.firstLookThreads,
281
305
  ...(ruleAutoResolveThreadIds.length > 0
282
306
  ? { ruleAutoResolveIds: ruleAutoResolveThreadIds }
@@ -286,6 +310,7 @@ export async function runCheck(opts) {
286
310
  actionable: visibleCommentClassification.actionable,
287
311
  minimizeIds: [...visibleCommentClassification.minimizeIds, ...ruleAutoResolveCommentIds],
288
312
  firstLook: firstLookComments,
313
+ ...(autoMinimized.length > 0 && { autoMinimized }),
289
314
  },
290
315
  changesRequestedReviews,
291
316
  reviewSummaries: seenSummaries,
@@ -296,6 +321,9 @@ export async function runCheck(opts) {
296
321
  ? { ruleAutoResolveReviewSummaryIds }
297
322
  : undefined),
298
323
  branchProtection: batchData.branchProtection,
324
+ ...(batchData.allowedMergeMethods && {
325
+ allowedMergeMethods: batchData.allowedMergeMethods,
326
+ }),
299
327
  activity: batchData.activity,
300
328
  ...(hasQueueState && {
301
329
  mergeQueue: {
@@ -315,38 +343,17 @@ export async function runCheck(opts) {
315
343
  ...(headUpdatedAfterRemoval && { headUpdatedAfterRemoval: true }),
316
344
  },
317
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 }),
318
354
  };
319
355
  if (result.fingerprint) {
320
356
  await storePrFingerprint(stateKey, result.fingerprint, report, config);
321
357
  }
322
358
  return report;
323
359
  }
324
- async function remainingRuleAutoResolveIds(partition, autoMinimizeSuppressed = false) {
325
- const consumedIds = autoMinimizeSuppressed
326
- ? await selfApplySuppressedRuleAutoResolve(partition)
327
- : { minimized: new Set(), resolvedThreads: new Set() };
328
- return {
329
- threadIds: partition.ruleAutoResolveThreadIds.filter((id) => !consumedIds.resolvedThreads.has(id)),
330
- commentIds: partition.ruleAutoResolveCommentIds.filter((id) => !consumedIds.minimized.has(id)),
331
- reviewSummaryIds: partition.ruleAutoResolveReviewSummaryIds.filter((id) => !consumedIds.minimized.has(id)),
332
- };
333
- }
334
- async function selfApplySuppressedRuleAutoResolve(partition) {
335
- const minimizeIds = [
336
- ...partition.ruleAutoResolveCommentIds.filter((id) => partition.suppressedCommentIds.has(id)),
337
- ...partition.ruleAutoResolveReviewSummaryIds.filter((id) => partition.suppressedReviewSummaryIds.has(id)),
338
- ];
339
- const threadIds = partition.ruleAutoResolveThreadIds.filter((id) => partition.suppressedThreadIds.has(id));
340
- const [minimized, resolved] = await Promise.all([
341
- minimizeIds.length > 0
342
- ? autoMinimizeComments(minimizeIds)
343
- : Promise.resolve({ minimized: [], errors: [] }),
344
- threadIds.length > 0
345
- ? autoResolveThreads(threadIds)
346
- : Promise.resolve({ resolved: [], errors: [] }),
347
- ]);
348
- return {
349
- minimized: new Set(minimized.minimized),
350
- resolvedThreads: new Set(resolved.resolved),
351
- };
352
- }
@@ -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
  }
@@ -0,0 +1,18 @@
1
+ export declare function conflictingHeadCiNote(input: {
2
+ hasConflicts: boolean;
3
+ headCheckSuitesEmpty: boolean;
4
+ checkRunCount: number;
5
+ /** Unix seconds when Shepherd first saw this head. Omit when that time is unknown. */
6
+ firstSeenAtUnix: number | null | undefined;
7
+ nowMs: number;
8
+ }): string | undefined;
9
+ export declare function countReportedChecks(checks: {
10
+ passing: readonly unknown[];
11
+ failing: readonly unknown[];
12
+ inProgress: readonly unknown[];
13
+ skipped: readonly unknown[];
14
+ filtered: readonly unknown[];
15
+ ignored?: readonly unknown[];
16
+ }): number;
17
+ /** Place the missing-CI note with the conflict step, ahead of later repair lines. */
18
+ export declare function insertConflictingHeadCiNote(instructions: string[], note: string | undefined): void;
@@ -0,0 +1,32 @@
1
+ /** How long a conflicting head may sit with no CI before Shepherd says none will start. */
2
+ const CONFLICTING_HEAD_CI_GRACE_MS = 2 * 60 * 1000;
3
+ const NO_CI_NOTE = "GitHub did not start pull_request workflows for this conflicting head: no check suites or workflow runs have appeared for at least 2 minutes. Missing CI is a consequence of the conflict, not an outage, and pushing this same head again will not start them.";
4
+ export function conflictingHeadCiNote(input) {
5
+ if (!input.hasConflicts || !input.headCheckSuitesEmpty || input.checkRunCount > 0) {
6
+ return undefined;
7
+ }
8
+ if (typeof input.firstSeenAtUnix !== "number")
9
+ return undefined;
10
+ const ageMs = input.nowMs - input.firstSeenAtUnix * 1000;
11
+ if (!Number.isFinite(ageMs) || ageMs < CONFLICTING_HEAD_CI_GRACE_MS)
12
+ return undefined;
13
+ return NO_CI_NOTE;
14
+ }
15
+ export function countReportedChecks(checks) {
16
+ return (checks.passing.length +
17
+ checks.failing.length +
18
+ checks.inProgress.length +
19
+ checks.skipped.length +
20
+ checks.filtered.length +
21
+ (checks.ignored?.length ?? 0));
22
+ }
23
+ /** Place the missing-CI note with the conflict step, ahead of later repair lines. */
24
+ export function insertConflictingHeadCiNote(instructions, note) {
25
+ if (!note)
26
+ return;
27
+ const conflictAt = instructions.findIndex((step) => step.includes("merge conflicts"));
28
+ if (conflictAt >= 0)
29
+ instructions.splice(conflictAt + 1, 0, note);
30
+ else
31
+ instructions.unshift(note);
32
+ }
@@ -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.`;
@@ -8,6 +8,9 @@ import { buildThreadMutationRouting, threadHasAuthorizedMutation, } from "./thre
8
8
  import { buildFixInstructions } from "./render.mjs";
9
9
  import { buildReleasedBlockerInstruction } from "./check-instructions.mjs";
10
10
  import { buildNativeStackLayerRebase } from "./native-stack-rebase.mjs";
11
+ import { lookupUpperLayerTrunkConflict } from "./stack-trunk-conflict.mjs";
12
+ import { conflictingHeadCiNote, countReportedChecks, insertConflictingHeadCiNote, } from "./conflicting-head-ci.mjs";
13
+ import { conflictingHeadFirstSeenUnix } from "../../state/conflicting-head-seen.mjs";
11
14
  import { applyStallGuard } from "./stall.mjs";
12
15
  import { annotationMarkerBody, checksWithActionableAnnotations } from "../check-annotations.mjs";
13
16
  import { threadTranscriptBody } from "../../threads/transcript.mjs";
@@ -282,9 +285,21 @@ export async function handleFixCode(ctx) {
282
285
  }
283
286
  const firstLookThreads = report.threads.firstLook;
284
287
  const firstLookComments = report.comments.firstLook;
288
+ const stack = report.mergeStatus.mergeRequirements?.stack;
289
+ const headRef = report.headSha;
290
+ // Only an upper layer can be dirty against trunk while already containing its parent.
291
+ const trunkConflict = hasConflicts && stack && headRef && baseLookup.branch !== stack.baseRefName
292
+ ? await lookupUpperLayerTrunkConflict({
293
+ owner: repoOwner,
294
+ name: repoName,
295
+ pr: prNumber,
296
+ headRef,
297
+ trunk: stack.baseRefName,
298
+ })
299
+ : undefined;
285
300
  // Conflicts, and a behind branch whose rerun already failed, both ask for a branch update.
286
301
  const stackRebase = hasConflicts || (isBehind && exhaustedAttempts.length > 0)
287
- ? buildNativeStackLayerRebase(report.repo, { number: prNumber, baseBranch: baseLookup.branch }, report.mergeStatus.mergeRequirements?.stack)
302
+ ? buildNativeStackLayerRebase(report.repo, { number: prNumber, baseBranch: baseLookup.branch }, stack, trunkConflict)
288
303
  : undefined;
289
304
  const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prReference, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind, report.viewerAuthorization?.viewerCanUpdate === true, exhaustedAttempts.length > 0, stackRebase);
290
305
  if (failingAgentChecks.some((check) => releasedCheckNames.has(check.name))) {
@@ -296,9 +311,23 @@ export async function handleFixCode(ctx) {
296
311
  if (repairInstructions && repairInstructions.length > 0) {
297
312
  instructions.unshift(...repairInstructions);
298
313
  }
314
+ const checkRunCount = countReportedChecks(report.checks);
315
+ const suitesEmpty = report.headCheckSuitesEmpty === true;
316
+ const nowMs = Date.now();
317
+ const firstSeenAtUnix = hasConflicts && suitesEmpty && checkRunCount === 0 && report.headSha
318
+ ? await conflictingHeadFirstSeenUnix({ owner: repoOwner, repo: repoName, pr: prNumber }, report.headSha, nowMs)
319
+ : undefined;
320
+ insertConflictingHeadCiNote(instructions, conflictingHeadCiNote({
321
+ hasConflicts,
322
+ headCheckSuitesEmpty: suitesEmpty,
323
+ checkRunCount,
324
+ firstSeenAtUnix,
325
+ nowMs,
326
+ }));
299
327
  const prospectiveResult = {
300
328
  ...base,
301
329
  baseBranch: baseLookup.branch,
330
+ ...(trunkConflict && { stackTrunkConflict: trunkConflict.trunk }),
302
331
  action: "fix_code",
303
332
  fix: {
304
333
  threads,
@@ -18,6 +18,7 @@ import { markReadyIfAuthorized } from "./mark-ready.mjs";
18
18
  import { withIterateApiUsage } from "./run.mjs";
19
19
  import { fetchRawSummaryPr } from "../../github/poll-summary.mjs";
20
20
  import { summarizePollSummaryPr } from "../../github/poll-summary-projector.mjs";
21
+ import { annotationProbeUnavailable } from "../../github/poll-summary-annotation-probe.mjs";
21
22
  import { fingerprintRawSummaryPr } from "../../github/poll-summary-fingerprint.mjs";
22
23
  import { currentQueueRemovalEvent } from "../../github/poll-summary-queue-removal.mjs";
23
24
  import { isCurrentSummaryReady } from "../../github/poll-summary-readiness.mjs";
@@ -25,6 +26,7 @@ import { clearReadyReceipt, isReadyReceiptCurrent, readReadyReceipt, writeReadyR
25
26
  import { stackDraftHold } from "./parent-first.mjs";
26
27
  import { findStaleNativeStackAncestry } from "./stale-ancestry.mjs";
27
28
  import { annotateBlockedWait, resolveCheckBlockerGate } from "./check-blocker-gate.mjs";
29
+ import { buildUnreportedFixResult, planUnreportedRequired } from "./unreported-required.mjs";
28
30
  export function runIterate(opts) {
29
31
  return withIterateApiUsage(opts, () => runIterateCore(opts));
30
32
  }
@@ -122,6 +124,16 @@ async function runIterateCore(opts) {
122
124
  // readiness evidence, so a rerun (e.g. with --merge) does not wait again.
123
125
  const readyState = await updateReadyDelay(report.pr, isCleanReadyState, readyDelaySeconds, repoOwner, repoName, { headSha, alreadyElapsed: receiptCurrent });
124
126
  const base = buildIterateBase(reportForWork, readyState);
127
+ const unreportedPlan = await planUnreportedRequired({
128
+ report,
129
+ base,
130
+ stateKey: stallKey,
131
+ headSha,
132
+ otherAutonomousWork: hasActionableWork || staleAncestry !== null,
133
+ });
134
+ if (unreportedPlan.escalate)
135
+ return unreportedPlan.escalate;
136
+ const unreportedRepair = unreportedPlan.repairInstructions ?? [];
125
137
  // Checks (including merge-queue synthetic-commit checks) and hard conflicts are signals
126
138
  // GitHub itself is already acting on — the queue will eject the PR for these regardless of
127
139
  // what Shepherd does, so they always surface immediately. Only review threads/comments/
@@ -148,9 +160,36 @@ async function runIterateCore(opts) {
148
160
  surfacedApprovals,
149
161
  botUsernames,
150
162
  releasedCheckNames: blockerGate?.releasedNames,
163
+ ...((unreportedRepair.length > 0 || staleAncestry !== null) && {
164
+ repairInstructions: [...unreportedRepair, ...(staleAncestry?.instructions ?? [])],
165
+ }),
166
+ ruleAutoResolveThreadIds: report.threads.ruleAutoResolveIds,
167
+ });
168
+ }
169
+ if (unreportedRepair.length > 0 && staleAncestry) {
170
+ return handleFixCode({
171
+ base,
172
+ report: reportForWork,
173
+ opts: { ...opts, prNumber, neverCancelRuns },
174
+ headSha,
175
+ stallKey,
176
+ prNumber,
177
+ stallTimeoutSeconds,
178
+ repoOwner,
179
+ repoName,
180
+ reviewSummaryIds,
181
+ firstLookSummaries,
182
+ editedSummaries,
183
+ surfacedApprovals,
184
+ botUsernames,
185
+ releasedCheckNames: blockerGate?.releasedNames,
186
+ repairInstructions: [...unreportedRepair, ...staleAncestry.instructions],
151
187
  ruleAutoResolveThreadIds: report.threads.ruleAutoResolveIds,
152
188
  });
153
189
  }
190
+ if (unreportedRepair.length > 0) {
191
+ return annotateBlockedWait(await applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, buildUnreportedFixResult(base, report, unreportedRepair), reportForWork, reviewSummaryIds), blockerGate);
192
+ }
154
193
  const mergeStateResult = await handleActiveMergeState({
155
194
  enabled: opts.merge,
156
195
  active: activeMerge,
@@ -182,7 +221,7 @@ async function runIterateCore(opts) {
182
221
  surfacedApprovals,
183
222
  botUsernames,
184
223
  releasedCheckNames: blockerGate?.releasedNames,
185
- repairInstructions: staleAncestry.instructions,
224
+ repairInstructions: [...unreportedRepair, ...staleAncestry.instructions],
186
225
  ruleAutoResolveThreadIds: report.threads.ruleAutoResolveIds,
187
226
  });
188
227
  }
@@ -220,6 +259,17 @@ async function runIterateCore(opts) {
220
259
  log: `CANCEL: PR #${base.pr} ${cancelNote} — ready-delay elapsed, stopping`,
221
260
  };
222
261
  }
262
+ if (isCleanReadyState &&
263
+ readyState.isReady &&
264
+ !readyState.shouldCancel &&
265
+ readyState.remainingSeconds > 0) {
266
+ await clearStallState(stallKey);
267
+ return {
268
+ ...base,
269
+ action: "ready",
270
+ log: `READY: PR #${base.pr} is ready — ${readyState.remainingSeconds}s of ready-delay remaining — ${buildWaitLog(base).slice("WAIT: ".length)}`,
271
+ };
272
+ }
223
273
  const hold = stackDraftHold(report, autoMarkReady);
224
274
  const wait = {
225
275
  ...base,
@@ -238,14 +288,19 @@ async function recordReadyReceipt(key, report) {
238
288
  return false;
239
289
  try {
240
290
  const raw = await fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
241
- const fingerprint = fingerprintRawSummaryPr(raw);
242
- if (fingerprint === null ||
291
+ if (fingerprintRawSummaryPr(raw) === null ||
243
292
  raw.state !== "OPEN" ||
244
293
  raw.isDraft ||
245
294
  raw.headRefOid !== report.headSha ||
246
295
  raw.baseRefOid !== report.baseRefOid)
247
296
  return false;
297
+ // Summarize hydrates annotation totals onto the snapshot before the stored hash.
248
298
  const summary = await summarizePollSummaryPr(raw, { owner: key.owner, name: key.repo }, { stackPrNumber: report.pr });
299
+ if (annotationProbeUnavailable(raw))
300
+ return false;
301
+ const fingerprint = fingerprintRawSummaryPr(raw);
302
+ if (fingerprint === null)
303
+ return false;
249
304
  if (!isCurrentSummaryReady(raw, summary.checks ?? {}, summary.review ?? {}))
250
305
  return false;
251
306
  const removalEvent = currentQueueRemovalEvent(raw);
@@ -290,6 +345,17 @@ async function revalidateReadyReceipt(key, report, hasReadinessWork) {
290
345
  }
291
346
  try {
292
347
  const raw = await fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
348
+ const summary = await summarizePollSummaryPr(raw, { owner: key.owner, name: key.repo }, { stackPrNumber: report.pr });
349
+ if (annotationProbeUnavailable(raw)) {
350
+ const sameHead = raw.headRefOid === report.headSha;
351
+ const sameBase = retainQueuedReceipt || raw.baseRefOid === report.baseRefOid;
352
+ const stillReady = isCurrentSummaryReady(raw, summary.checks ?? {}, summary.review ?? {});
353
+ if (!sameHead || !sameBase || !stillReady) {
354
+ await clearReadyReceipt(key);
355
+ return false;
356
+ }
357
+ return true;
358
+ }
293
359
  // Queue predecessors can advance the target branch without changing this
294
360
  // PR's source. Compare against the receipt's base only while both fresh
295
361
  // views still place the PR in the queue.
@@ -1,6 +1,6 @@
1
1
  import { clearStallState } from "../../state/iterate-stall.mjs";
2
2
  import { buildEscalateHumanMessage, buildEscalateSuggestion } from "./escalate.mjs";
3
- import { buildMergeCommandPlan } from "./merge.mjs";
3
+ import { buildMergeCommandPlan, unavailableMergeResult } from "./merge.mjs";
4
4
  import { formatPrUrl } from "../../pr-reference.mjs";
5
5
  import { buildPrShepherdCommand } from "../../cli/runner.mjs";
6
6
  import { inlineCode } from "../../util/markdown.mjs";
@@ -49,17 +49,17 @@ export function buildReadyMergeOutcome(enabled, readyElapsed, base, report) {
49
49
  return buildStackedRouteResult(base, report, stack);
50
50
  const queue = Boolean(report.mergeStatus.mergeRequirements?.mergeQueue?.required ||
51
51
  report.mergeStatus.mergeRequirements?.mergeQueue?.enabled);
52
- return {
53
- ...base,
54
- action: "merge",
55
- merge: buildMergeCommandPlan({
56
- pr: report.pr,
57
- repo: report.repo,
58
- nodeId: report.nodeId,
59
- headSha: report.headSha ?? "unknown",
60
- queue,
61
- }),
62
- };
52
+ const plan = buildMergeCommandPlan({
53
+ pr: report.pr,
54
+ repo: report.repo,
55
+ nodeId: report.nodeId,
56
+ headSha: report.headSha ?? "unknown",
57
+ queue,
58
+ ...(report.allowedMergeMethods && { allowedMergeMethods: report.allowedMergeMethods }),
59
+ });
60
+ if ("unavailable" in plan)
61
+ return unavailableMergeResult(base, report, plan.unavailable);
62
+ return { ...base, action: "merge", merge: plan };
63
63
  }
64
64
  /** Raw counts of non-CI actionable work held back for one queued-PR wait tick. Omitted (all zero) when empty. */
65
65
  function buildDeferredWork(input) {
@@ -1,13 +1,23 @@
1
- import type { MergeCommandPlan } from "../../types.mts";
1
+ import { type MergeMethod } from "../../config/merge-method.mts";
2
+ import type { IterateResult, IterateResultBase, MergeCommandPlan } from "../../types.mts";
2
3
  interface MergePlanInput {
3
4
  pr: number;
4
5
  repo: string;
5
6
  nodeId: string;
6
7
  headSha: string;
7
8
  queue: boolean;
9
+ /** Omitted when the batch did not select repository merge settings. */
10
+ allowedMergeMethods?: readonly MergeMethod[];
8
11
  }
9
- export declare function buildMergeCommandPlan(input: MergePlanInput): MergeCommandPlan;
12
+ export interface MergeMethodUnavailable {
13
+ unavailable: string;
14
+ }
15
+ export declare function buildMergeCommandPlan(input: MergePlanInput): MergeCommandPlan | MergeMethodUnavailable;
10
16
  export declare function renderMergeCommand(command: {
11
17
  argv: string[];
12
18
  }): string;
19
+ export declare function unavailableMergeResult(base: IterateResultBase, report: {
20
+ pr: number;
21
+ repo: string;
22
+ }, unavailable: string): IterateResult;
13
23
  export {};
@@ -1,6 +1,9 @@
1
1
  import { loadConfig } from "../../config/load.mjs";
2
2
  import { findMergeStrategies } from "../../config/merge-command-args.mjs";
3
+ import { chooseMergeMethod, configuredMergeMethod, } from "../../config/merge-method.mjs";
4
+ import { formatPrUrl } from "../../pr-reference.mjs";
3
5
  import { renderShellCommand } from "../../cli/runner.mjs";
6
+ import { buildEscalateHumanMessage } from "./escalate.mjs";
4
7
  const ENQUEUE_MUTATION = "mutation EnqueuePullRequest($pullRequestId: ID!, $expectedHeadOid: GitObjectID!) { enqueuePullRequest(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid }) { mergeQueueEntry { id } } }";
5
8
  export function buildMergeCommandPlan(input) {
6
9
  const base = [
@@ -32,9 +35,17 @@ export function buildMergeCommandPlan(input) {
32
35
  },
33
36
  };
34
37
  }
35
- const configuredArgs = loadConfig().merge?.commandArgs ?? [];
38
+ const mergeConfig = loadConfig().merge;
39
+ const configuredArgs = mergeConfig?.commandArgs ?? [];
40
+ const decision = chooseMergeMethod({
41
+ allowed: input.allowedMergeMethods,
42
+ configured: configuredMergeMethod(mergeConfig ?? {}),
43
+ fallback: "merge",
44
+ });
45
+ if ("unavailable" in decision)
46
+ return decision;
36
47
  const hasStrategy = findMergeStrategies(configuredArgs).length > 0;
37
- const commandArgs = hasStrategy ? configuredArgs : [...configuredArgs, "--merge"];
48
+ const commandArgs = hasStrategy ? configuredArgs : [...configuredArgs, `--${decision.method}`];
38
49
  return {
39
50
  mode: "auto",
40
51
  command: { argv: [...base, "--auto", ...commandArgs] },
@@ -44,3 +55,22 @@ export function buildMergeCommandPlan(input) {
44
55
  export function renderMergeCommand(command) {
45
56
  return renderShellCommand(command.argv);
46
57
  }
58
+ export function unavailableMergeResult(base, report, unavailable) {
59
+ const escalateBase = {
60
+ triggers: ["merge-method-unavailable"],
61
+ unresolvedThreads: [],
62
+ ambiguousComments: [],
63
+ changesRequestedReviews: [],
64
+ suggestion: unavailable,
65
+ };
66
+ return {
67
+ ...base,
68
+ action: "escalate",
69
+ escalate: {
70
+ ...escalateBase,
71
+ humanMessage: buildEscalateHumanMessage(escalateBase, formatPrUrl(report.repo, report.pr), {
72
+ merge: true,
73
+ }),
74
+ },
75
+ };
76
+ }
@@ -7,6 +7,8 @@ export type NativeStackRebaseStart = {
7
7
  parentBranch: string;
8
8
  } | {
9
9
  bottomPr: number;
10
+ } | {
11
+ trunk: string;
10
12
  };
11
13
  /**
12
14
  * One gh-stack rebase step. A native stack layer must not be rebased or merged from its base
@@ -22,12 +24,16 @@ export declare function buildNativeStackRebaseInstruction(repo: string, stackNum
22
24
  * workflow keeps failing); undefined outside a stack.
23
25
  * A layer whose PR targets the stack's trunk (`stack.baseRefName`) is the bottom open layer —
24
26
  * position 1, or a higher layer GitHub retargeted after every layer below it merged. Any other
25
- * layer is an upper layer whose parent is its own PR base branch.
27
+ * layer is an upper layer whose parent is its own PR base branch. `trunkConflict` means that
28
+ * upper layer already contains its parent, so the rebase starts at the bottom open layer.
26
29
  */
27
30
  export declare function buildNativeStackLayerRebase(repo: string, pr: {
28
31
  number: number;
29
32
  baseBranch: string;
30
- }, stack: StackStatus | undefined): string | undefined;
33
+ }, stack: StackStatus | undefined, trunkConflict?: {
34
+ trunk: string;
35
+ bottomPr?: number;
36
+ }): string | undefined;
31
37
  /** Point at the conflicts, and at the stack rebase when the PR is a native stack layer. */
32
38
  export declare function buildConflictInstruction(stackRebase: string | undefined): string;
33
39
  /** Push a conflict resolution or branch refresh: one branch, or the whole rewritten native stack. */
@@ -12,7 +12,9 @@ export function buildNativeStackRebaseInstruction(repo, stackNumber, start) {
12
12
  `check out the parent stack branch \`${start.parentBranch}\``,
13
13
  "gh stack rebase --upstack --no-trunk",
14
14
  ]
15
- : [`check out the head branch of PR #${start.bottomPr}`, "gh stack rebase"];
15
+ : "bottomPr" in start
16
+ ? [`check out the head branch of PR #${start.bottomPr}`, "gh stack rebase"]
17
+ : [`check out the bottom open layer whose base is \`${start.trunk}\``, "gh stack rebase"];
16
18
  const prepare = `if \`gh stack\` does not track stack #${stackNumber} locally, import it with \`gh stack checkout ${stackNumber}\`, then confirm every layer's local branch is at its PR's head commit — a stale local layer would overwrite that PR's newer commits on push`;
17
19
  return `From a clean checkout of \`${repo}\`, ${prepare}. Then ${checkout} and run \`${command}\`; if it stops on a conflict, resolve it and run \`gh stack rebase --continue\`.`;
18
20
  }
@@ -21,12 +23,20 @@ export function buildNativeStackRebaseInstruction(repo, stackNumber, start) {
21
23
  * workflow keeps failing); undefined outside a stack.
22
24
  * A layer whose PR targets the stack's trunk (`stack.baseRefName`) is the bottom open layer —
23
25
  * position 1, or a higher layer GitHub retargeted after every layer below it merged. Any other
24
- * layer is an upper layer whose parent is its own PR base branch.
26
+ * layer is an upper layer whose parent is its own PR base branch. `trunkConflict` means that
27
+ * upper layer already contains its parent, so the rebase starts at the bottom open layer.
25
28
  */
26
- export function buildNativeStackLayerRebase(repo, pr, stack) {
29
+ export function buildNativeStackLayerRebase(repo, pr, stack, trunkConflict) {
27
30
  if (!stack)
28
31
  return undefined;
29
- return buildNativeStackRebaseInstruction(repo, stack.number, pr.baseBranch === stack.baseRefName ? { bottomPr: pr.number } : { parentBranch: pr.baseBranch });
32
+ const start = trunkConflict
33
+ ? trunkConflict.bottomPr !== undefined
34
+ ? { bottomPr: trunkConflict.bottomPr }
35
+ : { trunk: trunkConflict.trunk }
36
+ : pr.baseBranch === stack.baseRefName
37
+ ? { bottomPr: pr.number }
38
+ : { parentBranch: pr.baseBranch };
39
+ return buildNativeStackRebaseInstruction(repo, stack.number, start);
30
40
  }
31
41
  /** Point at the conflicts, and at the stack rebase when the PR is a native stack layer. */
32
42
  export function buildConflictInstruction(stackRebase) {
@@ -0,0 +1,13 @@
1
+ /** An upper layer already contains its parent, so the dirty state is against trunk. */
2
+ export interface UpperLayerTrunkConflict {
3
+ trunk: string;
4
+ /** Open layer whose PR base is the trunk. Omitted when that layer is not on this page. */
5
+ bottomPr?: number;
6
+ }
7
+ export declare function lookupUpperLayerTrunkConflict(input: {
8
+ owner: string;
9
+ name: string;
10
+ pr: number;
11
+ headRef: string;
12
+ trunk: string;
13
+ }): Promise<UpperLayerTrunkConflict | undefined>;