pr-shepherd 1.0.0 → 1.1.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 (103) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/checks/classify.d.mts +3 -1
  3. package/bin/checks/classify.mjs +8 -1
  4. package/bin/cli/poll-summary-formatter.mjs +3 -0
  5. package/bin/cli/rule-auto-resolve-lines.d.mts +3 -0
  6. package/bin/cli/rule-auto-resolve-lines.mjs +12 -0
  7. package/bin/cli/stack-layer-view.d.mts +2 -1
  8. package/bin/cli/stack-overview.mjs +3 -0
  9. package/bin/cli/triage-formatter.mjs +5 -1
  10. package/bin/commands/check-activity.d.mts +8 -0
  11. package/bin/commands/check-activity.mjs +26 -0
  12. package/bin/commands/check-annotations.mjs +2 -1
  13. package/bin/commands/check-status.d.mts +6 -1
  14. package/bin/commands/check-status.mjs +9 -4
  15. package/bin/commands/check-wait-detectors.mjs +11 -0
  16. package/bin/commands/check.mjs +10 -3
  17. package/bin/commands/iterate/escalate.mjs +4 -2
  18. package/bin/commands/poll-summary-explicit-instructions.d.mts +1 -1
  19. package/bin/commands/poll-summary-explicit-instructions.mjs +9 -7
  20. package/bin/commands/poll-summary-instructions.d.mts +2 -2
  21. package/bin/commands/poll-summary-instructions.mjs +16 -15
  22. package/bin/commands/poll-summary-signature.d.mts +6 -0
  23. package/bin/commands/poll-summary-signature.mjs +19 -2
  24. package/bin/commands/poll-summary.mjs +22 -14
  25. package/bin/commands/ready-delay.d.mts +1 -0
  26. package/bin/commands/ready-delay.mjs +8 -3
  27. package/bin/commands/ready-mergeability.d.mts +1 -1
  28. package/bin/commands/ready-mergeability.mjs +2 -2
  29. package/bin/commands/rule-auto-resolve-format.d.mts +7 -0
  30. package/bin/commands/rule-auto-resolve-format.mjs +11 -4
  31. package/bin/commands/rule-auto-resolve.d.mts +14 -2
  32. package/bin/commands/stack-drain.d.mts +1 -1
  33. package/bin/commands/stack-drain.mjs +3 -3
  34. package/bin/commands/stack-work.d.mts +4 -1
  35. package/bin/commands/stack-work.mjs +10 -2
  36. package/bin/commands/tick.mjs +1 -0
  37. package/bin/comments/thread-visibility.d.mts +6 -0
  38. package/bin/comments/thread-visibility.mjs +30 -15
  39. package/bin/github/batch-parse-suites.d.mts +14 -0
  40. package/bin/github/batch-parse-suites.mjs +4 -1
  41. package/bin/github/batch-parser-helpers.mjs +5 -0
  42. package/bin/github/batch-raw-types.d.mts +1 -0
  43. package/bin/github/check-annotation-cache.d.mts +5 -0
  44. package/bin/github/check-annotation-cache.mjs +6 -3
  45. package/bin/github/fingerprint.mjs +4 -0
  46. package/bin/github/github-fetch.d.mts +6 -2
  47. package/bin/github/github-fetch.mjs +37 -2
  48. package/bin/github/github-timeouts.d.mts +8 -0
  49. package/bin/github/github-timeouts.mjs +8 -0
  50. package/bin/github/gql/batch-pr-page.gql +1 -0
  51. package/bin/github/gql/batch-pr.gql +1 -0
  52. package/bin/github/gql/commit-check-contexts.gql +1 -0
  53. package/bin/github/gql/poll-summary-check-contexts.gql +1 -0
  54. package/bin/github/http-utils.mjs +3 -0
  55. package/bin/github/poll-summary-annotation-probe.d.mts +5 -0
  56. package/bin/github/poll-summary-annotation-probe.mjs +17 -3
  57. package/bin/github/poll-summary-annotations.d.mts +14 -0
  58. package/bin/github/poll-summary-annotations.mjs +75 -0
  59. package/bin/github/poll-summary-blocking-reviewer.d.mts +2 -0
  60. package/bin/github/poll-summary-blocking-reviewer.mjs +9 -0
  61. package/bin/github/poll-summary-checks.d.mts +1 -0
  62. package/bin/github/poll-summary-checks.mjs +21 -2
  63. package/bin/github/poll-summary-command.d.mts +7 -1
  64. package/bin/github/poll-summary-command.mjs +29 -0
  65. package/bin/github/poll-summary-fingerprint.d.mts +7 -0
  66. package/bin/github/poll-summary-fingerprint.mjs +7 -0
  67. package/bin/github/poll-summary-projector.d.mts +3 -1
  68. package/bin/github/poll-summary-projector.mjs +15 -25
  69. package/bin/github/poll-summary-queue-removal.d.mts +2 -0
  70. package/bin/github/poll-summary-queue-removal.mjs +11 -0
  71. package/bin/github/poll-summary-raw.d.mts +1 -0
  72. package/bin/github/poll-summary-readiness.mjs +3 -0
  73. package/bin/github/poll-summary-review-parity.d.mts +24 -0
  74. package/bin/github/poll-summary-review-parity.mjs +82 -0
  75. package/bin/github/poll-summary-review.d.mts +11 -2
  76. package/bin/github/poll-summary-review.mjs +87 -59
  77. package/bin/github/poll-summary-route.mjs +2 -0
  78. package/bin/github/poll-summary-row-error.d.mts +16 -0
  79. package/bin/github/poll-summary-row-error.mjs +74 -0
  80. package/bin/github/poll-summary-rule-auto-resolve.d.mts +32 -0
  81. package/bin/github/poll-summary-rule-auto-resolve.mjs +119 -0
  82. package/bin/github/poll-summary-rule-handoff.d.mts +7 -0
  83. package/bin/github/poll-summary-rule-handoff.mjs +20 -0
  84. package/bin/github/poll-summary-rule-items.d.mts +20 -0
  85. package/bin/github/poll-summary-rule-items.mjs +28 -0
  86. package/bin/github/poll-summary-unreported.d.mts +7 -0
  87. package/bin/github/poll-summary-unreported.mjs +16 -0
  88. package/bin/github/poll-summary.mjs +15 -7
  89. package/bin/github/rest-check-read.mjs +3 -1
  90. package/bin/github/rest-reader-core.mjs +13 -2
  91. package/bin/github/rest-summary-read.mjs +12 -1
  92. package/bin/github/rest-text.mjs +3 -2
  93. package/bin/github/stack-handoff-page.mjs +1 -0
  94. package/bin/reporters/agent.mjs +2 -0
  95. package/bin/types/github.d.mts +8 -0
  96. package/bin/types/poll-summary.d.mts +23 -0
  97. package/bin/types/report.d.mts +5 -1
  98. package/package.json +2 -2
  99. package/plugins/pr-shepherd/.claude-plugin/plugin.json +1 -1
  100. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  101. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  102. package/plugins/pr-shepherd/.mcp.json +1 -1
  103. package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +1 -0
@@ -4,6 +4,7 @@ import { ShepherdError } from "../exit-codes.mjs";
4
4
  import { getRepoInfo } from "../github/client.mjs";
5
5
  import { getGithubTransport } from "../github/transport.mjs";
6
6
  import { withApiTelemetryScope, summarizeApiTelemetry } from "../github/api-telemetry.mjs";
7
+ import { aggregateRerunCommand } from "../github/poll-summary-command.mjs";
7
8
  import { fetchPollSummary } from "../github/poll-summary.mjs";
8
9
  import { writeStackHandoffs } from "../github/stack-handoff.mjs";
9
10
  import { sleep } from "../util/sleep.mjs";
@@ -19,7 +20,7 @@ export function runPollSummary(opts) {
19
20
  return withApiTelemetryScope(async () => {
20
21
  const tick = await readPollSummaryTick(opts);
21
22
  await handOffRoutedLayers(tick);
22
- return attachUsage(tick.result, opts.merge);
23
+ return attachUsage(tick.result, opts);
23
24
  });
24
25
  }
25
26
  export function runAggregatePoll(opts) {
@@ -60,7 +61,7 @@ async function readPollSummaryTick(opts) {
60
61
  prs: fetched.prs,
61
62
  ...(fetched.stackAncestry?.length && { stackAncestry: fetched.stackAncestry }),
62
63
  ...(fetched.allowedMergeMethods && { allowedMergeMethods: fetched.allowedMergeMethods }),
63
- }, opts.merge === true);
64
+ }, opts.merge === true, aggregateRerunCommand(`${repo.owner}/${repo.name}`, fetched.selection, opts));
64
65
  const result = await applyStackStallGuard(planned, repo, opts.stallTimeoutSeconds ?? loadConfig().iterate["stall-timeout-minutes"] * 60);
65
66
  return { result, repo, ...(fetched.handoffSource && { handoffSource: fetched.handoffSource }) };
66
67
  }
@@ -82,6 +83,7 @@ async function runAggregatePollCore(opts) {
82
83
  try {
83
84
  lastTick = await readPollSummaryTick(opts);
84
85
  last = lastTick.result;
86
+ writeAggregateRuleAutoResolve(last);
85
87
  rateLimitRetry.reset();
86
88
  }
87
89
  catch (error) {
@@ -103,7 +105,7 @@ async function runAggregatePollCore(opts) {
103
105
  stackMergeable: true,
104
106
  instructions: ["1. Stop — every stack layer is merged."],
105
107
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
106
- }, opts.merge);
108
+ }, opts);
107
109
  }
108
110
  return attachUsage({
109
111
  ...explicit,
@@ -115,7 +117,7 @@ async function runAggregatePollCore(opts) {
115
117
  `1. Native stack #${last.selection.stackNumber} disappeared before every tracked layer merged. Stop and ask the stack owner to reconcile the remaining PRs; do not claim the stack is complete.`,
116
118
  ],
117
119
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
118
- }, opts.merge);
120
+ }, opts);
119
121
  }
120
122
  const early = await rateLimitRetry.wait(error, {
121
123
  untilTerminal: opts.untilTerminal === true,
@@ -126,7 +128,7 @@ async function runAggregatePollCore(opts) {
126
128
  onAllTerminal: (pulls) => aggregateCancelFromPulls(last, opts.targetRepository, pulls),
127
129
  });
128
130
  if (early) {
129
- return presentProbedCancel(pendingQuotaWarning ? { ...early, quotaWarning: pendingQuotaWarning } : early, opts.merge);
131
+ return presentProbedCancel(pendingQuotaWarning ? { ...early, quotaWarning: pendingQuotaWarning } : early, opts);
130
132
  }
131
133
  continue;
132
134
  }
@@ -147,14 +149,14 @@ async function runAggregatePollCore(opts) {
147
149
  ...last,
148
150
  reason: "all_terminal",
149
151
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
150
- }, opts.merge);
152
+ }, opts);
151
153
  }
152
154
  if (immediate) {
153
155
  return attachUsage({
154
156
  ...last,
155
157
  reason: "actionable",
156
158
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
157
- }, opts.merge);
159
+ }, opts);
158
160
  }
159
161
  if (hasFix) {
160
162
  if (debounceMs === 0 || Date.now() >= (debounceUntil ??= Date.now() + debounceMs)) {
@@ -163,13 +165,13 @@ async function runAggregatePollCore(opts) {
163
165
  ...last,
164
166
  reason: "actionable",
165
167
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
166
- }, opts.merge);
168
+ }, opts);
167
169
  }
168
170
  }
169
171
  else {
170
172
  debounceUntil = null;
171
173
  if (opts.untilTerminal && pendingQuotaWarning) {
172
- return attachUsage({ ...last, quotaWarning: pendingQuotaWarning }, opts.merge);
174
+ return attachUsage({ ...last, quotaWarning: pendingQuotaWarning }, opts);
173
175
  }
174
176
  }
175
177
  const elapsedMs = Date.now() - start;
@@ -179,7 +181,7 @@ async function runAggregatePollCore(opts) {
179
181
  if (!opts.untilTerminal && debounceUntil === null) {
180
182
  const remainingMs = timeoutMs - elapsedMs;
181
183
  if (remainingMs <= 0 || remainingMs + TIMER_DRIFT_TOLERANCE_MS < sleepMs) {
182
- return attachUsage({ ...last, reason: "timeout" }, opts.merge);
184
+ return attachUsage({ ...last, reason: "timeout" }, opts);
183
185
  }
184
186
  }
185
187
  const statusSignature = summaryStatusSignature(last);
@@ -192,15 +194,21 @@ async function runAggregatePollCore(opts) {
192
194
  await sleep(sleepMs);
193
195
  }
194
196
  }
195
- function presentProbedCancel(result, merge) {
197
+ /** An auto-resolve is a one-tick event; forward it so a swallowed tick still records it. */
198
+ function writeAggregateRuleAutoResolve(result) {
199
+ for (const item of result.prs)
200
+ if (item.ruleAutoResolve?.summary)
201
+ process.stderr.write(`#${item.pr} ${item.ruleAutoResolve.summary}\n`);
202
+ }
203
+ function presentProbedCancel(result, opts) {
196
204
  // The stack planner already cancels an all-merged stack and escalates a
197
205
  // closed-unmerged layer. Do not rewrite that into a successful stop.
198
- return attachUsage({ ...result, reason: "all_terminal" }, merge);
206
+ return attachUsage({ ...result, reason: "all_terminal" }, opts);
199
207
  }
200
208
  function isMissingStack(error) {
201
209
  return (error instanceof ShepherdError && error.message.includes("not part of a native GitHub stack"));
202
210
  }
203
- function attachUsage(result, mergeRequested) {
211
+ function attachUsage(result, opts) {
204
212
  const apiUsage = summarizeApiTelemetry();
205
213
  const enriched = apiUsage ? { ...result, apiUsage } : result;
206
214
  // A disappeared native stack is an aggregate-level terminal handoff. Keep
@@ -209,5 +217,5 @@ function attachUsage(result, mergeRequested) {
209
217
  if (enriched.selection.kind === "stack" && enriched.nextAction === "escalate") {
210
218
  return enriched;
211
219
  }
212
- return withPollSummaryInstructions(enriched, mergeRequested === true);
220
+ return withPollSummaryInstructions(enriched, opts.merge === true, aggregateRerunCommand(enriched.repo, enriched.selection, opts));
213
221
  }
@@ -30,6 +30,7 @@ interface ReadyDelayState {
30
30
  export declare function updateReadyDelay(prNumber: number, isReady: boolean, readyDelaySeconds: number, owner: string, repo: string, options: {
31
31
  headSha: string;
32
32
  alreadyElapsed?: boolean;
33
+ summary?: boolean;
33
34
  }): Promise<ReadyDelayState>;
34
35
  /** Delete the ready-delay marker once an elapsed delay has been consumed. */
35
36
  export declare function clearReadyDelay(prNumber: number, owner: string, repo: string): Promise<void>;
@@ -21,7 +21,7 @@ import { resolvePrStatePath } from "../state/base.mjs";
21
21
  * the loop and tells one-shot agents to stop.
22
22
  */
23
23
  export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, owner, repo, options) {
24
- const markerPath = readySincePath(prNumber, owner, repo);
24
+ const markerPath = readySincePath(prNumber, owner, repo, options.summary);
25
25
  if (!isReady) {
26
26
  // Reset the timer.
27
27
  await safeUnlink(markerPath);
@@ -75,8 +75,13 @@ export async function readyDelayElapsed(prNumber, owner, repo, readyDelaySeconds
75
75
  // ---------------------------------------------------------------------------
76
76
  // Helpers
77
77
  // ---------------------------------------------------------------------------
78
- function readySincePath(pr, owner, repo) {
79
- return resolvePrStatePath({ owner, repo, pr }, "ready-since.txt");
78
+ /**
79
+ * The one-PR session owns `ready-since.txt`. The aggregate summary's coarser
80
+ * view keeps its own `ready-since-summary.txt` so it can never reset or start
81
+ * the authoritative timer.
82
+ */
83
+ function readySincePath(pr, owner, repo, summary = false) {
84
+ return resolvePrStatePath({ owner, repo, pr }, summary ? "ready-since-summary.txt" : "ready-since.txt");
80
85
  }
81
86
  /** The marker's start time, or null when it is missing, malformed, or bound to another head. */
82
87
  async function readReadySince(path, headSha) {
@@ -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, hasUnreportedRequired?: boolean): Promise<ReadyMergeabilityRefresh>;
13
+ export declare function refreshReadyMergeability(prNumber: number, repo: RepoInfo, batchData: BatchPrData, verdict: CiVerdict, unresolvedThreads: number, unresolvedComments: number, changesRequestedCount: number, awaitingChecks?: 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, hasUnreportedRequired = false) {
11
+ export async function refreshReadyMergeability(prNumber, repo, batchData, verdict, unresolvedThreads, unresolvedComments, changesRequestedCount, awaitingChecks = 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, hasUnreportedRequired);
14
+ const status = computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStatus, changesRequestedCount, awaitingChecks);
15
15
  return { batchData: refreshedBatchData, mergeStatus, status };
16
16
  }
17
17
  export function isBlockedByFilteredCheck(mergeStatus, verdict) {
@@ -3,6 +3,13 @@ export declare function uniqueReasons(reasons: readonly string[]): string[];
3
3
  export declare function decorateAutoResolveError(error: string, reasons: readonly string[]): string;
4
4
  export declare function reasonsForError(error: string, ids: readonly string[], ruleReasons: ReadonlyMap<string, readonly string[]>): string[];
5
5
  export declare function ruleAutoResolveFromReport(report: ShepherdReport): RuleAutoResolveReport | undefined;
6
+ /** The one report shape iterate and the aggregate summary both surface. */
7
+ export declare function buildRuleAutoResolveReport(input: {
8
+ threads: AutoResolvedThread[];
9
+ minimized: AutoMinimizedItem[];
10
+ errors: string[];
11
+ errorReasons: string[];
12
+ }): RuleAutoResolveReport | undefined;
6
13
  export declare function projectRuleAutoResolve(event: RuleAutoResolveReport): Record<string, unknown>;
7
14
  export declare function insertRuleAutoResolveSection(text: string, event: RuleAutoResolveReport | undefined): string;
8
15
  export declare function stripReplayedRuleAutoResolve(report: ShepherdReport): ShepherdReport;
@@ -57,9 +57,16 @@ function splitJoined(reason) {
57
57
  return reason.split("; ").filter(Boolean);
58
58
  }
59
59
  export function ruleAutoResolveFromReport(report) {
60
- const threads = report.threads.autoResolved;
61
- const minimized = report.comments.autoMinimized ?? [];
62
- const errors = report.threads.autoResolveErrors;
60
+ return buildRuleAutoResolveReport({
61
+ threads: report.threads.autoResolved,
62
+ minimized: report.comments.autoMinimized ?? [],
63
+ errors: report.threads.autoResolveErrors,
64
+ errorReasons: report.threads.autoResolveErrorReasons ?? [],
65
+ });
66
+ }
67
+ /** The one report shape iterate and the aggregate summary both surface. */
68
+ export function buildRuleAutoResolveReport(input) {
69
+ const { threads, minimized, errors } = input;
63
70
  if (threads.length === 0 && minimized.length === 0 && errors.length === 0)
64
71
  return undefined;
65
72
  const comments = minimized.filter((item) => item.kind === "pr-comment").length;
@@ -70,7 +77,7 @@ export function ruleAutoResolveFromReport(report) {
70
77
  ...threads.flatMap((thread) => splitJoined(thread.ruleReason)),
71
78
  ...minimized.flatMap((item) => splitJoined(item.ruleReason)),
72
79
  ])
73
- : uniqueReasons(report.threads.autoResolveErrorReasons ?? []);
80
+ : uniqueReasons(input.errorReasons);
74
81
  const body = ruleAutoResolveBody({ threads: threads.length, comments, reviewSummaries }) ??
75
82
  `auto-resolve failed for ${plural(errors.length, "mutation")}`;
76
83
  return {
@@ -1,5 +1,5 @@
1
1
  import type { BatchPartition } from "../classify/apply.mts";
2
- import type { AutoMinimizedItem, AutoResolvedThread, BatchPrData } from "../types.mts";
2
+ import type { AutoMinimizedItem, AutoResolvedThread, ReviewThread } from "../types.mts";
3
3
  export interface RuleAutoResolveApplied {
4
4
  threadIds: string[];
5
5
  commentIds: string[];
@@ -9,8 +9,20 @@ export interface RuleAutoResolveApplied {
9
9
  autoResolveErrors: string[];
10
10
  errorReasons: string[];
11
11
  }
12
+ interface MinimizableItem {
13
+ id: string;
14
+ url?: string;
15
+ viewerCanMinimize?: boolean;
16
+ }
17
+ /** The part of a PR read the mutations need. A full batch and a compact summary both satisfy it. */
18
+ export interface RuleAutoResolveBatch {
19
+ comments: readonly MinimizableItem[];
20
+ reviewSummaries: readonly MinimizableItem[];
21
+ reviewThreads: readonly ReviewThread[];
22
+ }
12
23
  export declare function applySuppressedRuleAutoResolve(input: {
13
24
  enabled: boolean;
14
25
  partition: BatchPartition;
15
- batch: BatchPrData;
26
+ batch: RuleAutoResolveBatch;
16
27
  }): Promise<RuleAutoResolveApplied>;
28
+ export {};
@@ -8,7 +8,7 @@ export interface StackPlan {
8
8
  idle?: PollSummaryItem[];
9
9
  instructions: string[];
10
10
  }
11
- export declare function planPrefixDrain(result: PollSummaryResult, mergeRequested: boolean): StackPlan | undefined;
11
+ export declare function planPrefixDrain(result: PollSummaryResult, mergeRequested: boolean, rerunCommand?: string): StackPlan | undefined;
12
12
  /**
13
13
  * A merge-requested, fully ready stack whose bottom open layer still targets
14
14
  * the merged layer below it until GitHub retargets it onto the stack base.
@@ -2,7 +2,7 @@ import { findHumanHandoffs, appendHumanHandoffInstructions } from "./stack-hando
2
2
  export { findHumanHandoffs, appendHumanHandoffInstructions } from "./stack-handoffs.mjs";
3
3
  import { stackMergeFlag } from "./stack-merge-flag.mjs";
4
4
  import { stackLayerBlockReason } from "./stack-layer-readiness.mjs";
5
- import { splitStackWork } from "./stack-work.mjs";
5
+ import { splitStackWork, stackRerunTarget } from "./stack-work.mjs";
6
6
  import { getGithubTransport } from "../github/transport.mjs";
7
7
  import { buildMergeCommandPlan, renderMergeCommand } from "./iterate/merge.mjs";
8
8
  function readyPrefixTop(result) {
@@ -28,7 +28,7 @@ function readyPrefixTop(result) {
28
28
  }
29
29
  return top;
30
30
  }
31
- export function planPrefixDrain(result, mergeRequested) {
31
+ export function planPrefixDrain(result, mergeRequested, rerunCommand) {
32
32
  if (!mergeRequested)
33
33
  return undefined;
34
34
  const top = readyPrefixTop(result);
@@ -92,7 +92,7 @@ export function planPrefixDrain(result, mergeRequested) {
92
92
  const handoffs = findHumanHandoffs(result);
93
93
  if (handoffs)
94
94
  appendHumanHandoffInstructions(instructions, handoffs, false);
95
- instructions.push(`${instructions.length + 1}. After the merge attempt, rerun this same \`stack --merge\` command; GitHub retargets the next layer onto \`${top.stack.baseRefName}\`. Shepherd any layer that GitHub rejects or ejects.`);
95
+ instructions.push(`${instructions.length + 1}. After the merge attempt, rerun ${rerunCommand ? stackRerunTarget(rerunCommand) : "this same `stack --merge` command"}; GitHub retargets the next layer onto \`${top.stack.baseRefName}\`. Shepherd any layer that GitHub rejects or ejects.`);
96
96
  return {
97
97
  action: "merge",
98
98
  stackMergeable: gaps.length === 0 && open.every(isStackLayerReady),
@@ -17,8 +17,11 @@ export declare function onlyDraftReadinessVerification(sessions: PollSummaryItem
17
17
  * A stale-ancestry child keeps its session, which returns the ancestry repair.
18
18
  */
19
19
  export declare function splitStackWork(candidates: PollSummaryItem[], staleChildren: ReadonlySet<number>): StackWork;
20
+ /** The literal stack command to rerun, or the generic name when the caller's command is unknown. */
21
+ export declare function stackRerunTarget(command?: string): string;
20
22
  /**
21
23
  * The closing step after routed sessions: rerun the stack command, or stop when nothing is owned.
22
24
  * Returns the plan action: a stop is a handoff, so no rerun, quota cadence, or wake-up follows it.
25
+ * `keepWatching` is false when a human handoff may end the run before every layer is terminal.
23
26
  */
24
- export declare function appendStackRerunInstruction(instructions: string[], layers: PollSummaryItem[], rerun: string): "shepherd" | "escalate";
27
+ export declare function appendStackRerunInstruction(instructions: string[], layers: PollSummaryItem[], rerun: string, keepWatching?: boolean): "shepherd" | "escalate";
@@ -35,12 +35,20 @@ function isProbed(item) {
35
35
  function onlyUnownedWork(layers) {
36
36
  return layers.length > 0 && layers.every((item) => !item.owned);
37
37
  }
38
+ /** The turn must outlive every non-terminal layer; a one-PR session ending does not end the stack. */
39
+ const STACK_KEEP_WATCHING = "Keep watching until every layer is terminal.";
40
+ /** The literal stack command to rerun, or the generic name when the caller's command is unknown. */
41
+ export function stackRerunTarget(command) {
42
+ return command ? `\`${command}\`` : "this same `stack` command";
43
+ }
38
44
  /**
39
45
  * The closing step after routed sessions: rerun the stack command, or stop when nothing is owned.
40
46
  * Returns the plan action: a stop is a handoff, so no rerun, quota cadence, or wake-up follows it.
47
+ * `keepWatching` is false when a human handoff may end the run before every layer is terminal.
41
48
  */
42
- export function appendStackRerunInstruction(instructions, layers, rerun) {
49
+ export function appendStackRerunInstruction(instructions, layers, rerun, keepWatching = true) {
43
50
  const stop = onlyUnownedWork(layers);
44
- instructions.push(`${instructions.length + 1}. ${stop ? "No owned layer needs a session. Report this overview and stop." : rerun}`);
51
+ const step = keepWatching ? `${rerun} ${STACK_KEEP_WATCHING}` : rerun;
52
+ instructions.push(`${instructions.length + 1}. ${stop ? "No owned layer needs a session. Report this overview and stop." : step}`);
45
53
  return stop ? "escalate" : "shepherd";
46
54
  }
@@ -86,6 +86,7 @@ async function withAggregateNextCheck(result, opts) {
86
86
  function withoutPollingCadence(step) {
87
87
  return step
88
88
  .replace(/ Recheck(?: them)? at the configured polling cadence\./g, "")
89
+ .replace(/Do not end the turn while any selected row is non-terminal: /g, "Keep watching every non-terminal selected row through the wake-up below: ")
89
90
  .replace("rerun that command at the configured cadence to resume its UUID", "rerun that command after the wake-up below to resume its UUID");
90
91
  }
91
92
  function aggregateNextCheck(result, stallDeadlineSeconds) {
@@ -8,5 +8,11 @@ interface ThreadVisibility {
8
8
  firstLookThreads: FirstLookThread[];
9
9
  toMarkSeen: ReviewThread[];
10
10
  }
11
+ /**
12
+ * Whether an unresolved thread with a known outdated state keeps returning every tick once seen,
13
+ * given an authorized mutation. The compact summary calls this so its count cannot drift from
14
+ * `classifyThreadVisibility`.
15
+ */
16
+ export declare function unresolvedThreadRepeats(thread: ReviewThread, ended: boolean, botUsernames: NormalizedBotUsernames, policy: ResolveOtherHumanThreads): boolean;
11
17
  export declare function classifyThreadVisibility(threads: ReviewThread[], seenMap: Map<string, SeenMarker>, botUsernames?: NormalizedBotUsernames, repeatableThreadIds?: ReadonlySet<string>, resolveOtherHumanThreads?: ResolveOtherHumanThreads): ThreadVisibility;
12
18
  export {};
@@ -3,6 +3,9 @@ import { threadTranscriptBody } from "../threads/transcript.mjs";
3
3
  import { isConfiguredBotAuthor, isHumanAuthor, isViewerAuthoredHuman, } from "./authors.mjs";
4
4
  import { threadEndedByShepherd } from "./marker.mjs";
5
5
  import { shouldResolveOtherHumanThread } from "./thread-resolve-policy.mjs";
6
+ function isOrdinaryHuman(thread, botUsernames) {
7
+ return isHumanAuthor(thread) && !isConfiguredBotAuthor(thread, botUsernames);
8
+ }
6
9
  function withEdited(thread, edited) {
7
10
  return edited ? { ...thread, edited: true } : thread;
8
11
  }
@@ -20,9 +23,33 @@ function classifyFirstLookThread(thread, seenMap, firstLookStatus) {
20
23
  return null;
21
24
  return { ...visible, firstLookStatus };
22
25
  }
26
+ /** An unresolved, current thread whose author class makes it repeat while a mutation is authorized. */
27
+ function repeatsAsActive(thread, ended, botUsernames, policy) {
28
+ if (thread.isOutdated !== false || thread.isMinimized === true || ended)
29
+ return false;
30
+ return (isConfiguredBotAuthor(thread, botUsernames) ||
31
+ isViewerAuthoredHuman(thread, botUsernames) ||
32
+ (isOrdinaryHuman(thread, botUsernames) && policy === "always"));
33
+ }
34
+ /** An unresolved thread that is resolution-only work: outdated, minimized or ended by Shepherd. */
35
+ function isResolutionOnlyCandidate(thread, ended, botUsernames, policy) {
36
+ if (!ended)
37
+ return thread.isOutdated === true || thread.isMinimized === true;
38
+ if (!isOrdinaryHuman(thread, botUsernames))
39
+ return true;
40
+ return (isViewerAuthoredHuman(thread, botUsernames) || shouldResolveOtherHumanThread(thread, policy));
41
+ }
42
+ /**
43
+ * Whether an unresolved thread with a known outdated state keeps returning every tick once seen,
44
+ * given an authorized mutation. The compact summary calls this so its count cannot drift from
45
+ * `classifyThreadVisibility`.
46
+ */
47
+ export function unresolvedThreadRepeats(thread, ended, botUsernames, policy) {
48
+ return (repeatsAsActive(thread, ended, botUsernames, policy) ||
49
+ isResolutionOnlyCandidate(thread, ended, botUsernames, policy));
50
+ }
23
51
  export function classifyThreadVisibility(threads, seenMap, botUsernames = new Set(), repeatableThreadIds, resolveOtherHumanThreads = "none") {
24
52
  const shouldRepeat = (thread) => repeatableThreadIds?.has(thread.id) ?? true;
25
- const isOrdinaryHuman = (thread) => isHumanAuthor(thread) && !isConfiguredBotAuthor(thread, botUsernames);
26
53
  const unknownStatusThreads = threads.filter((thread) => thread.isResolved === undefined || thread.isOutdated === undefined);
27
54
  const unresolvedThreads = threads.filter((thread) => thread.isResolved === false && thread.isOutdated !== undefined);
28
55
  const activeThreads = unresolvedThreads
@@ -30,25 +57,13 @@ export function classifyThreadVisibility(threads, seenMap, botUsernames = new Se
30
57
  .flatMap((t) => {
31
58
  if (threadEndedByShepherd(t))
32
59
  return [];
33
- const repeatAuthor = isConfiguredBotAuthor(t, botUsernames) ||
34
- isViewerAuthoredHuman(t, botUsernames) ||
35
- (isOrdinaryHuman(t) && resolveOtherHumanThreads === "always");
36
- if (repeatAuthor && shouldRepeat(t))
60
+ if (repeatsAsActive(t, false, botUsernames, resolveOtherHumanThreads) && shouldRepeat(t))
37
61
  return [t];
38
62
  const visible = classifyVisibleThread(t, seenMap);
39
63
  return visible ? [visible] : [];
40
64
  });
41
65
  const resolutionOnlyThreads = unresolvedThreads
42
- .filter((t) => {
43
- const endedByShepherd = threadEndedByShepherd(t);
44
- if (endedByShepherd) {
45
- if (!isOrdinaryHuman(t))
46
- return true;
47
- return (isViewerAuthoredHuman(t, botUsernames) ||
48
- shouldResolveOtherHumanThread(t, resolveOtherHumanThreads));
49
- }
50
- return t.isOutdated || t.isMinimized;
51
- })
66
+ .filter((t) => isResolutionOnlyCandidate(t, threadEndedByShepherd(t), botUsernames, resolveOtherHumanThreads))
52
67
  .flatMap((t) => {
53
68
  const visible = classifyVisibleThread(t, seenMap);
54
69
  if (visible)
@@ -5,6 +5,20 @@ export declare function parseCheckSuitesComplete(raw: RawPr): boolean;
5
5
  /** True only when the head commit's suite page finished and listed nothing. */
6
6
  export declare function parseHeadCheckSuitesEmpty(raw: RawPr): boolean;
7
7
  export declare function parseSuiteStartupFailures(raw: RawPr): CheckRun[];
8
+ /** One failing check per STARTUP_FAILURE workflow-run suite; shared by the one-PR and summary reads. */
9
+ export declare function startupFailureChecksFromSuites(suites: {
10
+ nodes: Array<{
11
+ conclusion: string | null;
12
+ workflowRun: {
13
+ databaseId?: number | null;
14
+ event: string | null;
15
+ url?: string | null;
16
+ workflow?: {
17
+ name: string | null;
18
+ } | null;
19
+ } | null;
20
+ }>;
21
+ } | null | undefined): CheckRun[];
8
22
  /** `headWorkflowSuites`, omitted when the head has none. */
9
23
  export declare function workflowSuites(raw: RawPr): {
10
24
  headWorkflowSuites?: WorkflowSuiteSnapshot[];
@@ -22,7 +22,10 @@ function parseHeadWorkflowSuites(raw) {
22
22
  });
23
23
  }
24
24
  export function parseSuiteStartupFailures(raw) {
25
- const suites = raw.commits.nodes[0]?.commit.checkSuites;
25
+ return startupFailureChecksFromSuites(raw.commits.nodes[0]?.commit.checkSuites);
26
+ }
27
+ /** One failing check per STARTUP_FAILURE workflow-run suite; shared by the one-PR and summary reads. */
28
+ export function startupFailureChecksFromSuites(suites) {
26
29
  if (!suites)
27
30
  return [];
28
31
  return suites.nodes.flatMap((node) => {
@@ -28,6 +28,7 @@ export function mapCheckRunNode(node) {
28
28
  const summary = extractCheckRunSummary(node.title, node.summary);
29
29
  const rawCreatedAt = node.checkSuite?.workflowRun?.createdAt ?? node.checkSuite?.createdAt;
30
30
  const rawUpdatedAt = node.checkSuite?.workflowRun?.updatedAt ?? node.checkSuite?.updatedAt;
31
+ const suiteStatus = node.checkSuite?.status || undefined;
31
32
  const createdAtUnix = rawCreatedAt ? parseCreatedAt(rawCreatedAt) : undefined;
32
33
  const startedAtUnix = node.startedAt ? parseCreatedAt(node.startedAt) : undefined;
33
34
  const completedAtUnix = node.completedAt ? parseCreatedAt(node.completedAt) : undefined;
@@ -43,11 +44,15 @@ export function mapCheckRunNode(node) {
43
44
  runId,
44
45
  ...(workflowName !== undefined && { workflowName }),
45
46
  ...(workflowId !== undefined && { workflowId }),
47
+ ...(suiteStatus !== undefined && { suiteStatus }),
46
48
  ...(createdAtUnix !== undefined && { createdAtUnix }),
47
49
  ...(startedAtUnix !== undefined && { startedAtUnix }),
48
50
  ...(completedAtUnix !== undefined && { completedAtUnix }),
49
51
  ...(updatedAtUnix !== undefined && { updatedAtUnix }),
50
52
  ...(summary !== undefined && { summary }),
53
+ ...((node.annotations?.totalCount ?? 0) > 0 && {
54
+ annotationCount: node.annotations.totalCount,
55
+ }),
51
56
  ...(((node.annotations?.totalCount ?? 0) > 0 || (node.annotations?.nodes.length ?? 0) > 0) && {
52
57
  hasAnnotations: true,
53
58
  }),
@@ -234,6 +234,7 @@ export type RawContextNode = {
234
234
  }>;
235
235
  };
236
236
  checkSuite: {
237
+ status?: string | null;
237
238
  createdAt?: string;
238
239
  updatedAt?: string;
239
240
  workflowRun: {
@@ -3,6 +3,11 @@ import type { CheckAnnotation } from "../types.mts";
3
3
  export interface AnnotationCacheOptions {
4
4
  stateKey: StateKey;
5
5
  headSha?: string;
6
+ /**
7
+ * Annotation total GitHub reports per check run right now. A cached list whose length
8
+ * differs is stale (a publisher added annotations to a completed run) and is not reused.
9
+ */
10
+ expectedCounts?: ReadonlyMap<string, number>;
6
11
  }
7
12
  export declare function readFreshCheckAnnotations(checkRunId: string, cacheOpts: AnnotationCacheOptions | undefined): Promise<CheckAnnotation[] | undefined>;
8
13
  export declare function storeCheckAnnotations(checkRunId: string, annotations: CheckAnnotation[], cacheOpts: AnnotationCacheOptions | undefined): Promise<void>;
@@ -12,9 +12,12 @@ export async function readFreshCheckAnnotations(checkRunId, cacheOpts) {
12
12
  if (!cacheOpts)
13
13
  return undefined;
14
14
  const cached = await loadDerived(cacheOpts.stateKey, cacheName(checkRunId));
15
- if (cached && Date.now() - cached.storedAt < ANNOTATION_CACHE_MAX_AGE_MS)
16
- return cached.value;
17
- return undefined;
15
+ if (!cached || Date.now() - cached.storedAt >= ANNOTATION_CACHE_MAX_AGE_MS)
16
+ return undefined;
17
+ const expected = cacheOpts.expectedCounts?.get(checkRunId);
18
+ if (expected !== undefined && cached.value.length !== expected)
19
+ return undefined;
20
+ return cached.value;
18
21
  }
19
22
  export async function storeCheckAnnotations(checkRunId, annotations, cacheOpts) {
20
23
  if (!cacheOpts)
@@ -12,6 +12,10 @@ export function fingerprintFromRaw(raw, viewerPermission = null, viewerLogin = n
12
12
  isMergeQueueEnabled: Boolean(raw.isMergeQueueEnabled),
13
13
  mergePolicy: mergePolicyFingerprint(raw),
14
14
  commentCount: raw.comments.totalCount ?? raw.comments.nodes.length,
15
+ // Raw `updatedAt` on purpose, even for bot or rule-suppressed comments (#635 gap 7). A miss
16
+ // continues the same BatchPr request into the full snapshot, so an edited bot comment costs
17
+ // no extra request over a hit, while excluding it would need rule evaluation here and would
18
+ // risk hiding an edit that un-suppresses the comment or edits a minimized human comment.
15
19
  commentRevisions: commentRevisions(raw.comments.nodes),
16
20
  threadCount: raw.reviewThreads.totalCount ?? raw.reviewThreads.nodes.length,
17
21
  reviewCount: raw.allReviews?.totalCount ?? 0,
@@ -1,2 +1,6 @@
1
- /** Attach proxy routing to GitHub requests without changing the host's dispatcher. */
2
- export declare function githubFetch(input: string | URL, init?: RequestInit): Promise<Response>;
1
+ /**
2
+ * Attach proxy routing to GitHub requests without changing the host's dispatcher.
3
+ * The request, including reading its body, is bounded by `timeoutMs`; a stall
4
+ * rejects with an ETIMEDOUT error that the transport retry treats as transient.
5
+ */
6
+ export declare function githubFetch(input: string | URL, init?: RequestInit, timeoutMs?: number): Promise<Response>;
@@ -1,5 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { createCapturedProxyAgent } from "./captured-proxy-agent.mjs";
3
+ import { GITHUB_API_TIMEOUT_MS } from "./github-timeouts.mjs";
4
+ import { redactUrl } from "./http-utils.mjs";
3
5
  import { loadUndiciKeepingLegacyDispatcher } from "./legacy-dispatcher.mjs";
4
6
  const require = createRequire(import.meta.url);
5
7
  let proxyAgent;
@@ -9,8 +11,41 @@ function loadUndici() {
9
11
  undiciModule ??= loadUndiciKeepingLegacyDispatcher(() => require("undici"));
10
12
  return undiciModule;
11
13
  }
12
- /** Attach proxy routing to GitHub requests without changing the host's dispatcher. */
13
- export async function githubFetch(input, init) {
14
+ /**
15
+ * Attach proxy routing to GitHub requests without changing the host's dispatcher.
16
+ * The request, including reading its body, is bounded by `timeoutMs`; a stall
17
+ * rejects with an ETIMEDOUT error that the transport retry treats as transient.
18
+ */
19
+ export async function githubFetch(input, init, timeoutMs = GITHUB_API_TIMEOUT_MS) {
20
+ const deadline = AbortSignal.timeout(timeoutMs);
21
+ const signal = init?.signal ? AbortSignal.any([init.signal, deadline]) : deadline;
22
+ try {
23
+ return await bufferBody(await routedFetch(input, { ...init, signal }));
24
+ }
25
+ catch (err) {
26
+ if (deadline.aborted && !init?.signal?.aborted) {
27
+ const method = init?.method ?? "GET";
28
+ throw Object.assign(new Error(`GitHub request timed out after ${timeoutMs / 1000}s: ${method} ${redactUrl(String(input))}`, { cause: err }), { code: "ETIMEDOUT" });
29
+ }
30
+ throw err;
31
+ }
32
+ }
33
+ /**
34
+ * Read a streamed body inside the deadline and the callers' transport retry, so a
35
+ * body that stalls after the headers is retried like a stalled request. Callers
36
+ * read every body in full anyway.
37
+ */
38
+ async function bufferBody(response) {
39
+ if (typeof response.body?.getReader !== "function")
40
+ return response;
41
+ const bytes = await response.arrayBuffer();
42
+ return new Response(bytes.byteLength > 0 ? bytes : null, {
43
+ status: response.status,
44
+ statusText: response.statusText,
45
+ headers: response.headers,
46
+ });
47
+ }
48
+ async function routedFetch(input, init) {
14
49
  const httpProxy = process.env["http_proxy"] ?? process.env["HTTP_PROXY"] ?? "";
15
50
  const httpsProxy = process.env["https_proxy"] ?? process.env["HTTPS_PROXY"] ?? "";
16
51
  if (!httpProxy && !httpsProxy)
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Deadline for one GitHub REST or GraphQL request, headers and body included.
3
+ * Without it a half-open connection waits for undici's 300s headers/body
4
+ * defaults (10s for connect) and the tick prints nothing meanwhile.
5
+ */
6
+ export declare const GITHUB_API_TIMEOUT_MS = 60000;
7
+ /** Deadline for Actions log downloads, which can be several megabytes. */
8
+ export declare const GITHUB_LOG_TIMEOUT_MS = 180000;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Deadline for one GitHub REST or GraphQL request, headers and body included.
3
+ * Without it a half-open connection waits for undici's 300s headers/body
4
+ * defaults (10s for connect) and the tick prints nothing meanwhile.
5
+ */
6
+ export const GITHUB_API_TIMEOUT_MS = 60_000;
7
+ /** Deadline for Actions log downloads, which can be several megabytes. */
8
+ export const GITHUB_LOG_TIMEOUT_MS = 180_000;
@@ -196,6 +196,7 @@ query BatchPrPage(
196
196
  }
197
197
  }
198
198
  checkSuite {
199
+ status
199
200
  createdAt
200
201
  updatedAt
201
202
  workflowRun {