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
@@ -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
+ }
@@ -0,0 +1,18 @@
1
+ import type { IterateResult, IterateResultBase, ShepherdReport } from "../../types.mts";
2
+ interface UnreportedPlan {
3
+ escalate?: IterateResult;
4
+ repairInstructions?: string[];
5
+ }
6
+ export declare function planUnreportedRequired(input: {
7
+ report: ShepherdReport;
8
+ base: IterateResultBase;
9
+ stateKey: {
10
+ owner: string;
11
+ repo: string;
12
+ pr: number;
13
+ };
14
+ headSha: string;
15
+ otherAutonomousWork: boolean;
16
+ }): Promise<UnreportedPlan>;
17
+ export declare function buildUnreportedFixResult(base: IterateResultBase, report: ShepherdReport, instructions: string[]): IterateResult;
18
+ export {};
@@ -0,0 +1,122 @@
1
+ import { buildPrShepherdCommand } from "../../cli/runner.mjs";
2
+ import { formatPrUrl } from "../../pr-reference.mjs";
3
+ import { readCiRetrigger, sameCiRetrigger, writeCiRetrigger } from "../../state/ci-retrigger.mjs";
4
+ import { buildFixCompletionInstruction } from "./check-instructions.mjs";
5
+ import { buildEscalateHumanMessage, buildEscalateSuggestion } from "./escalate.mjs";
6
+ import { buildNativeStackRebaseInstruction } from "./native-stack-rebase.mjs";
7
+ /** Rebase when the trunk or the PR base is behind; otherwise close and reopen once. */
8
+ function decideUnreportedRequired(input) {
9
+ if (input.behind)
10
+ return "rebase";
11
+ if (input.alreadyRetriggered)
12
+ return input.otherAutonomousWork ? "none" : "escalate";
13
+ return "reopen";
14
+ }
15
+ function buildUnreportedRequiredInstruction(input) {
16
+ const listed = input.names.map((name) => `\`${name}\``).join(", ");
17
+ const observed = input.trunkBehindBy !== undefined && input.trunkBehindBy > 0
18
+ ? `The stack trunk compare is behind by ${input.trunkBehindBy}.`
19
+ : input.behind
20
+ ? "This PR's derived merge status is BEHIND."
21
+ : "The stack trunk compare is not behind and this PR's derived merge status is not BEHIND.";
22
+ const update = input.stackRebase
23
+ ? `${input.stackRebase} Then push the rewritten stack with \`gh stack push\`.`
24
+ : `Rebase or otherwise update the PR branch from \`${input.baseBranch}\` according to repository conventions, then push.`;
25
+ return [
26
+ `Required status checks have no check run and no status context on this head, and no Actions workflow is running: ${listed}.`,
27
+ observed,
28
+ `If the stack trunk is behind or this PR is behind its base, update the branch so a pull_request synchronize event runs: ${update}`,
29
+ `Otherwise retrigger workflows once for this head with \`gh pr close ${input.pr} -R ${input.repo}\` then \`gh pr reopen ${input.pr} -R ${input.repo}\`.`,
30
+ "Do not close the PR again if those checks are still missing on the next poll.",
31
+ ].join(" ");
32
+ }
33
+ export async function planUnreportedRequired(input) {
34
+ const names = input.report.unreportedRequiredChecks ?? [];
35
+ const actionsRunning = input.report.actionsWorkflowInProgress === true || input.report.checks.inProgress.length > 0;
36
+ const failing = input.report.checks.failing.length > 0;
37
+ const conflicts = input.report.mergeStatus.status === "CONFLICTS";
38
+ const inQueue = input.report.mergeQueue?.inQueue === true;
39
+ if (names.length === 0 || actionsRunning || failing || conflicts || inQueue)
40
+ return {};
41
+ const behind = (input.report.trunkBehindBy ?? 0) > 0 || input.report.mergeStatus.status === "BEHIND";
42
+ const decision = decideUnreportedRequired({
43
+ behind,
44
+ alreadyRetriggered: sameCiRetrigger(await readCiRetrigger(input.stateKey), input.headSha, names),
45
+ otherAutonomousWork: input.otherAutonomousWork,
46
+ });
47
+ if (decision === "none")
48
+ return {};
49
+ if (decision === "escalate")
50
+ return { escalate: escalateUnreported(input.base, input.report, names) };
51
+ const rebase = stackRebase(input.report);
52
+ const instruction = buildUnreportedRequiredInstruction({
53
+ names,
54
+ repo: input.report.repo,
55
+ pr: input.report.pr,
56
+ baseBranch: input.report.baseBranch,
57
+ behind,
58
+ ...(input.report.trunkBehindBy !== undefined && { trunkBehindBy: input.report.trunkBehindBy }),
59
+ ...(rebase && { stackRebase: rebase }),
60
+ });
61
+ if (decision === "reopen") {
62
+ await writeCiRetrigger(input.stateKey, { headSha: input.headSha, contexts: names });
63
+ }
64
+ return { repairInstructions: [instruction] };
65
+ }
66
+ export function buildUnreportedFixResult(base, report, instructions) {
67
+ const prUrl = formatPrUrl(report.repo, report.pr);
68
+ return {
69
+ ...base,
70
+ action: "fix_code",
71
+ fix: {
72
+ threads: [],
73
+ resolutionOnlyThreads: [],
74
+ actionableComments: [],
75
+ reviewSummaryIds: [],
76
+ firstLookSummaries: [],
77
+ editedSummaries: [],
78
+ surfacedApprovals: [],
79
+ checks: [],
80
+ changesRequestedReviews: [],
81
+ resolveCommand: {
82
+ argv: buildPrShepherdCommand(["apply", "review", prUrl]).argv,
83
+ requiresHeadSha: false,
84
+ requiresDismissMessage: false,
85
+ hasMutations: false,
86
+ },
87
+ instructions: [...instructions, buildFixCompletionInstruction([])],
88
+ inProgressRunIds: [],
89
+ protectedRuns: [],
90
+ firstLookThreads: [],
91
+ firstLookComments: [],
92
+ },
93
+ cancelled: [],
94
+ };
95
+ }
96
+ function stackRebase(report) {
97
+ const stack = report.mergeStatus.mergeRequirements?.stack;
98
+ if (!stack)
99
+ return undefined;
100
+ const start = report.stackBottomPr !== undefined
101
+ ? { bottomPr: report.stackBottomPr }
102
+ : { trunk: stack.baseRefName };
103
+ return buildNativeStackRebaseInstruction(report.repo, stack.number, start);
104
+ }
105
+ function escalateUnreported(base, report, names) {
106
+ const detail = names.map((name) => `\`${name}\``).join(", ");
107
+ const escalateBase = {
108
+ triggers: ["required-checks-unreported"],
109
+ unresolvedThreads: [],
110
+ ambiguousComments: [],
111
+ changesRequestedReviews: [],
112
+ suggestion: buildEscalateSuggestion(["required-checks-unreported"], detail),
113
+ };
114
+ return {
115
+ ...base,
116
+ action: "escalate",
117
+ escalate: {
118
+ ...escalateBase,
119
+ humanMessage: buildEscalateHumanMessage(escalateBase, report.pr),
120
+ },
121
+ };
122
+ }
@@ -31,6 +31,7 @@ async function runPollSummaryCore(opts) {
31
31
  reason: allTerminal ? "all_terminal" : actionable ? "actionable" : "waiting",
32
32
  prs: fetched.prs,
33
33
  ...(fetched.stackAncestry?.length && { stackAncestry: fetched.stackAncestry }),
34
+ ...(fetched.allowedMergeMethods && { allowedMergeMethods: fetched.allowedMergeMethods }),
34
35
  }, opts.merge === true);
35
36
  return applyStackStallGuard(planned, repo, opts.stallTimeoutSeconds ?? loadConfig().iterate.stallTimeoutMinutes * 60);
36
37
  }
@@ -6,6 +6,10 @@ import { quotaPollIntervalMs } from "./poll-quota.mjs";
6
6
  import { onePrCancelFromPulls, onePrRateLimitTargets } from "./poll-rate-limit-cancel.mjs";
7
7
  import { createUntilTerminalRateLimitRetry } from "./poll-rate-limit-wait.mjs";
8
8
  import { writeDebounceProgress, writeWaitProgress } from "./poll-progress.mjs";
9
+ function noteRuleAutoResolve(result) {
10
+ if (result.ruleAutoResolve?.summary)
11
+ process.stderr.write(`${result.ruleAutoResolve.summary}\n`);
12
+ }
9
13
  const DEFAULT_POLL_DEBOUNCE_SECONDS = 60;
10
14
  const MAX_TIMER_MS = 2 ** 31 - 1;
11
15
  const TIMER_DRIFT_TOLERANCE_MS = 500;
@@ -38,8 +42,7 @@ async function runPollCore(opts) {
38
42
  const remainingBefore = untilTerminal
39
43
  ? Number.POSITIVE_INFINITY
40
44
  : timeoutMs - (Date.now() - start);
41
- // Cache only internal continuation ticks. Last bounded tick, FIX_CODE debounce,
42
- // and any tick we return to the caller must fetch BatchPr.
45
+ // Cache only internal continuation ticks; returned ticks must fetch BatchPr.
43
46
  const allowCache = debounceUntil === null && remainingBefore + TIMER_DRIFT_TOLERANCE_MS >= intervalMs;
44
47
  const iterateTick = (fingerprintCache) => runIterate({
45
48
  ...iterateOpts,
@@ -89,6 +92,7 @@ async function runPollCore(opts) {
89
92
  if (lastResult.action === "fix_code" && debounceSeconds > 0 && !pastDebounce) {
90
93
  debounceUntil ??= Date.now() + debounceMs;
91
94
  const remainingMs = Math.max(debounceUntil - Date.now(), 0);
95
+ noteRuleAutoResolve(lastResult);
92
96
  writeDebounceProgress(tick, Date.now() - start, remainingMs);
93
97
  await sleep(Math.min(intervalMs, remainingMs));
94
98
  continue;
@@ -123,6 +127,7 @@ async function runPollCore(opts) {
123
127
  verbose,
124
128
  lastWaitSignature,
125
129
  });
130
+ noteRuleAutoResolve(lastResult);
126
131
  await sleep(sleepMs);
127
132
  continue;
128
133
  }
@@ -139,10 +144,12 @@ async function runPollCore(opts) {
139
144
  break;
140
145
  }
141
146
  }
147
+ noteRuleAutoResolve(lastResult);
142
148
  await sleep(sleepMs);
143
149
  continue;
144
150
  }
145
151
  if (lastResult.action === "fix_code" && debounceSeconds > 0 && !pastDebounce) {
152
+ noteRuleAutoResolve(lastResult);
146
153
  debounceUntil ??= Date.now() + debounceMs;
147
154
  const remainingMs = debounceUntil - Date.now();
148
155
  if (remainingMs > 0) {
@@ -10,6 +10,6 @@ export declare function refreshUnknownMergeability(prNumber: number, repo: RepoI
10
10
  batchData: BatchPrData;
11
11
  didRefresh: boolean;
12
12
  }>;
13
- export declare function refreshReadyMergeability(prNumber: number, repo: RepoInfo, batchData: BatchPrData, verdict: CiVerdict, unresolvedThreads: number, unresolvedComments: number, changesRequestedCount: number): Promise<ReadyMergeabilityRefresh>;
13
+ export declare function refreshReadyMergeability(prNumber: number, repo: RepoInfo, batchData: BatchPrData, verdict: CiVerdict, unresolvedThreads: number, unresolvedComments: number, changesRequestedCount: number, hasUnreportedRequired?: boolean): Promise<ReadyMergeabilityRefresh>;
14
14
  export declare function isBlockedByFilteredCheck(mergeStatus: MergeStatusResult, verdict: CiVerdict): boolean;
15
15
  export {};
@@ -8,10 +8,10 @@ export async function refreshUnknownMergeability(prNumber, repo, batchData) {
8
8
  }
9
9
  return { batchData: await readMergeability(prNumber, repo, batchData), didRefresh: true };
10
10
  }
11
- export async function refreshReadyMergeability(prNumber, repo, batchData, verdict, unresolvedThreads, unresolvedComments, changesRequestedCount) {
11
+ export async function refreshReadyMergeability(prNumber, repo, batchData, verdict, unresolvedThreads, unresolvedComments, changesRequestedCount, hasUnreportedRequired = false) {
12
12
  const refreshedBatchData = await readMergeability(prNumber, repo, batchData);
13
13
  const mergeStatus = deriveMergeStatus(refreshedBatchData);
14
- const status = computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStatus, changesRequestedCount);
14
+ const status = computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStatus, changesRequestedCount, hasUnreportedRequired);
15
15
  return { batchData: refreshedBatchData, mergeStatus, status };
16
16
  }
17
17
  export function isBlockedByFilteredCheck(mergeStatus, verdict) {
@@ -0,0 +1,22 @@
1
+ import type { AutoMinimizedItem, AutoResolvedThread, RuleAutoResolveReport, ShepherdReport } from "../types.mts";
2
+ export declare function uniqueReasons(reasons: readonly string[]): string[];
3
+ export declare function reasonClause(reasons: readonly string[]): string | undefined;
4
+ export declare function ruleAutoResolveBody(counts: {
5
+ threads: number;
6
+ comments: number;
7
+ reviewSummaries: number;
8
+ }): string | undefined;
9
+ export declare function formatRuleAutoResolveJournalItem(input: {
10
+ body: string;
11
+ reasons: readonly string[];
12
+ viewerLogin?: string;
13
+ urls: readonly string[];
14
+ }): string;
15
+ export declare function decorateAutoResolveError(error: string, reasons: readonly string[]): string;
16
+ export declare function reasonsForError(error: string, ids: readonly string[], ruleReasons: ReadonlyMap<string, readonly string[]>): string[];
17
+ export declare function ruleAutoResolveFromReport(report: ShepherdReport): RuleAutoResolveReport | undefined;
18
+ export declare function projectRuleAutoResolve(event: RuleAutoResolveReport): Record<string, unknown>;
19
+ export declare function insertRuleAutoResolveSection(text: string, event: RuleAutoResolveReport | undefined): string;
20
+ export declare function stripReplayedRuleAutoResolve(report: ShepherdReport): ShepherdReport;
21
+ export declare function minimizedItem(kind: AutoMinimizedItem["kind"], id: string, url: string | undefined, reasons: readonly string[]): AutoMinimizedItem;
22
+ export declare function resolvedThread(thread: AutoResolvedThread, reasons: readonly string[]): AutoResolvedThread;
@@ -0,0 +1,151 @@
1
+ export function uniqueReasons(reasons) {
2
+ const unique = [];
3
+ for (const reason of reasons) {
4
+ const cleaned = reason.trim();
5
+ if (cleaned && !unique.includes(cleaned))
6
+ unique.push(cleaned);
7
+ }
8
+ return unique;
9
+ }
10
+ export function reasonClause(reasons) {
11
+ const unique = uniqueReasons(reasons);
12
+ if (unique.length === 0)
13
+ return undefined;
14
+ const label = unique.length === 1 ? "rule" : "rules";
15
+ return `(${label}: ${unique.join("; ")})`;
16
+ }
17
+ function joinedReason(reasons) {
18
+ const unique = uniqueReasons(reasons);
19
+ return unique.length > 0 ? unique.join("; ") : undefined;
20
+ }
21
+ function plural(count, singular, pluralForm = `${singular}s`) {
22
+ return `${count} ${count === 1 ? singular : pluralForm}`;
23
+ }
24
+ export function ruleAutoResolveBody(counts) {
25
+ const parts = [];
26
+ if (counts.threads > 0)
27
+ parts.push(`auto-resolved ${plural(counts.threads, "thread")}`);
28
+ const minimized = [];
29
+ if (counts.comments > 0)
30
+ minimized.push(plural(counts.comments, "comment"));
31
+ if (counts.reviewSummaries > 0) {
32
+ minimized.push(plural(counts.reviewSummaries, "review summary", "review summaries"));
33
+ }
34
+ if (minimized.length > 0)
35
+ parts.push(`minimized ${minimized.join(" and ")}`);
36
+ return parts.length > 0 ? parts.join(", ") : undefined;
37
+ }
38
+ function withReasonClause(body, reasons) {
39
+ const clause = reasonClause(reasons);
40
+ return clause ? `${body} ${clause}` : body;
41
+ }
42
+ export function formatRuleAutoResolveJournalItem(input) {
43
+ const login = input.viewerLogin?.trim();
44
+ const actor = login ? `as @${login}` : "(token login unavailable)";
45
+ const clause = reasonClause(input.reasons);
46
+ const links = input.urls.map((url) => url.trim()).filter(Boolean);
47
+ const reasonSuffix = clause ? ` ${clause}` : "";
48
+ const linkSuffix = links.length > 0 ? `: ${links.join(", ")}` : "";
49
+ return `- ${input.body} ${actor}${reasonSuffix}${linkSuffix}`;
50
+ }
51
+ export function decorateAutoResolveError(error, reasons) {
52
+ const clause = reasonClause(reasons);
53
+ return clause ? `${error} ${clause}` : error;
54
+ }
55
+ export function reasonsForError(error, ids, ruleReasons) {
56
+ const id = ids.find((candidate) => error.startsWith(`${candidate}:`));
57
+ if (id)
58
+ return [...(ruleReasons.get(id) ?? [])];
59
+ if (!error.startsWith("rate limit:"))
60
+ return [];
61
+ return uniqueReasons(ids.flatMap((candidate) => [...(ruleReasons.get(candidate) ?? [])]));
62
+ }
63
+ function splitJoined(reason) {
64
+ if (!reason)
65
+ return [];
66
+ return reason.split("; ").filter(Boolean);
67
+ }
68
+ export function ruleAutoResolveFromReport(report) {
69
+ const threads = report.threads.autoResolved;
70
+ const minimized = report.comments.autoMinimized ?? [];
71
+ const errors = report.threads.autoResolveErrors;
72
+ if (threads.length === 0 && minimized.length === 0 && errors.length === 0)
73
+ return undefined;
74
+ const comments = minimized.filter((item) => item.kind === "pr-comment").length;
75
+ const reviewSummaries = minimized.filter((item) => item.kind === "review-summary").length;
76
+ const success = threads.length + minimized.length > 0;
77
+ const reasons = success
78
+ ? uniqueReasons([
79
+ ...threads.flatMap((thread) => splitJoined(thread.ruleReason)),
80
+ ...minimized.flatMap((item) => splitJoined(item.ruleReason)),
81
+ ])
82
+ : uniqueReasons(report.threads.autoResolveErrorReasons ?? []);
83
+ const body = ruleAutoResolveBody({ threads: threads.length, comments, reviewSummaries }) ??
84
+ `auto-resolve failed for ${plural(errors.length, "mutation")}`;
85
+ return {
86
+ summary: withReasonClause(body, reasons),
87
+ ...(threads.length > 0 && { threads }),
88
+ ...(minimized.length > 0 && { minimized }),
89
+ ...(errors.length > 0 && { errors }),
90
+ };
91
+ }
92
+ function formatRuleAutoResolveSection(event) {
93
+ if (!event)
94
+ return null;
95
+ const lines = ["## Classification auto-resolve", "", event.summary];
96
+ const links = [
97
+ ...(event.threads ?? []).map((thread) => thread.url.trim() || `\`${thread.id}\``),
98
+ ...(event.minimized ?? []).map((item) => item.url?.trim() || `\`${item.id}\``),
99
+ ];
100
+ if (links.length > 0)
101
+ lines.push("", ...links.map((link) => `- ${link}`));
102
+ if ((event.errors?.length ?? 0) > 0) {
103
+ lines.push("", ...(event.errors ?? []).map((error) => `- ${error}`));
104
+ }
105
+ return lines.join("\n");
106
+ }
107
+ export function projectRuleAutoResolve(event) {
108
+ return {
109
+ summary: event.summary,
110
+ ...((event.threads?.length ?? 0) > 0 && { threads: event.threads }),
111
+ ...((event.minimized?.length ?? 0) > 0 && { minimized: event.minimized }),
112
+ ...((event.errors?.length ?? 0) > 0 && { errors: event.errors }),
113
+ };
114
+ }
115
+ export function insertRuleAutoResolveSection(text, event) {
116
+ const section = formatRuleAutoResolveSection(event);
117
+ if (!section)
118
+ return text;
119
+ const index = text.lastIndexOf("## Instructions");
120
+ if (index === -1)
121
+ return `${text}\n\n${section}`;
122
+ return `${text.slice(0, index)}${section}\n\n${text.slice(index)}`;
123
+ }
124
+ export function stripReplayedRuleAutoResolve(report) {
125
+ const hasEvent = report.threads.autoResolved.length > 0 ||
126
+ report.threads.autoResolveErrors.length > 0 ||
127
+ (report.threads.autoResolveErrorReasons?.length ?? 0) > 0 ||
128
+ (report.comments.autoMinimized?.length ?? 0) > 0;
129
+ if (!hasEvent)
130
+ return report;
131
+ const { autoMinimized: _autoMinimized, ...comments } = report.comments;
132
+ const { autoResolveErrorReasons: _autoResolveErrorReasons, ...threads } = report.threads;
133
+ return {
134
+ ...report,
135
+ threads: { ...threads, autoResolved: [], autoResolveErrors: [] },
136
+ comments,
137
+ };
138
+ }
139
+ export function minimizedItem(kind, id, url, reasons) {
140
+ const ruleReason = joinedReason(reasons);
141
+ return {
142
+ id,
143
+ kind,
144
+ ...(url?.trim() ? { url: url.trim() } : {}),
145
+ ...(ruleReason ? { ruleReason } : {}),
146
+ };
147
+ }
148
+ export function resolvedThread(thread, reasons) {
149
+ const ruleReason = joinedReason(reasons);
150
+ return { ...thread, isResolved: true, ...(ruleReason ? { ruleReason } : {}) };
151
+ }