pr-shepherd 0.55.2 → 0.56.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 (86) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +6 -2
  3. package/bin/api.d.mts +5 -0
  4. package/bin/api.mjs +12 -1
  5. package/bin/checks/job-log.d.mts +9 -0
  6. package/bin/checks/job-log.mjs +30 -0
  7. package/bin/checks/jobs-types.d.mts +14 -0
  8. package/bin/checks/jobs-types.mjs +7 -0
  9. package/bin/checks/related-jobs.d.mts +11 -0
  10. package/bin/checks/related-jobs.mjs +32 -0
  11. package/bin/checks/triage-budget.d.mts +17 -0
  12. package/bin/checks/triage-budget.mjs +49 -0
  13. package/bin/checks/triage.d.mts +6 -3
  14. package/bin/checks/triage.mjs +89 -61
  15. package/bin/cli/api-usage-formatter.mjs +2 -2
  16. package/bin/cli/fix-formatter.mjs +2 -0
  17. package/bin/cli/iterate-checks-formatter.mjs +2 -0
  18. package/bin/cli/related-jobs-format.d.mts +3 -0
  19. package/bin/cli/related-jobs-format.mjs +16 -0
  20. package/bin/commands/check-execution-context.d.mts +12 -0
  21. package/bin/commands/check-execution-context.mjs +38 -0
  22. package/bin/commands/check-fingerprint.mjs +14 -8
  23. package/bin/commands/check-unreported.d.mts +3 -2
  24. package/bin/commands/check-unreported.mjs +4 -4
  25. package/bin/commands/check.d.mts +2 -1
  26. package/bin/commands/check.mjs +21 -6
  27. package/bin/commands/iterate/check-instructions.d.mts +6 -3
  28. package/bin/commands/iterate/check-instructions.mjs +6 -5
  29. package/bin/commands/iterate/escalate.mjs +3 -0
  30. package/bin/commands/iterate/fix-code.mjs +6 -2
  31. package/bin/commands/iterate/helpers.mjs +1 -0
  32. package/bin/commands/iterate/index.mjs +20 -8
  33. package/bin/commands/iterate/render.mjs +11 -3
  34. package/bin/commands/iterate/stale-ancestry.d.mts +2 -1
  35. package/bin/commands/iterate/stale-ancestry.mjs +3 -2
  36. package/bin/commands/poll-quota.d.mts +2 -0
  37. package/bin/commands/poll-quota.mjs +18 -25
  38. package/bin/commands/poll-rate-limit-wait.mjs +13 -8
  39. package/bin/commands/ready-delay.d.mts +2 -0
  40. package/bin/commands/ready-delay.mjs +18 -0
  41. package/bin/commands/resolve-mutate.mjs +8 -9
  42. package/bin/github/batch-raw-types.d.mts +2 -0
  43. package/bin/github/batch-receipt-evidence.d.mts +5 -0
  44. package/bin/github/batch-receipt-evidence.mjs +61 -0
  45. package/bin/github/batch.d.mts +4 -0
  46. package/bin/github/batch.mjs +42 -8
  47. package/bin/github/errors.d.mts +3 -0
  48. package/bin/github/errors.mjs +15 -6
  49. package/bin/github/gql/batch-pr-page.gql +1 -0
  50. package/bin/github/gql/batch-pr.gql +1 -0
  51. package/bin/github/gql/poll-summary-annotation-probe.gql +8 -0
  52. package/bin/github/gql/reply-thread-comments.gql +25 -0
  53. package/bin/github/gql/reply-thread-transcripts.gql +31 -0
  54. package/bin/github/merge-queue-checks.d.mts +2 -1
  55. package/bin/github/merge-queue-checks.mjs +18 -8
  56. package/bin/github/merge-target-rules.d.mts +2 -1
  57. package/bin/github/merge-target-rules.mjs +4 -4
  58. package/bin/github/poll-summary-annotation-probe.d.mts +2 -0
  59. package/bin/github/poll-summary-annotation-probe.mjs +7 -0
  60. package/bin/github/poll-summary-mergeability.d.mts +11 -0
  61. package/bin/github/poll-summary-mergeability.mjs +23 -0
  62. package/bin/github/poll-summary.mjs +7 -2
  63. package/bin/github/queries.d.mts +5 -0
  64. package/bin/github/queries.mjs +5 -0
  65. package/bin/github/rate-limit-kind.d.mts +13 -0
  66. package/bin/github/rate-limit-kind.mjs +25 -0
  67. package/bin/github/reply-thread-transcripts.d.mts +3 -0
  68. package/bin/github/reply-thread-transcripts.mjs +89 -0
  69. package/bin/github/rest-http.mjs +1 -0
  70. package/bin/github/rest-text.d.mts +2 -1
  71. package/bin/github/rest-text.mjs +5 -2
  72. package/bin/github/thread-comments.d.mts +5 -1
  73. package/bin/github/thread-comments.mjs +30 -6
  74. package/bin/mcp/server.mjs +32 -5
  75. package/bin/quota-warning.mjs +2 -2
  76. package/bin/reporters/agent.mjs +1 -0
  77. package/bin/threads/transcript.d.mts +1 -0
  78. package/bin/threads/transcript.mjs +4 -1
  79. package/bin/types/check-classification.d.mts +10 -0
  80. package/bin/types/report.d.mts +4 -1
  81. package/package.json +1 -1
  82. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  83. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  84. package/plugins/pr-shepherd/.mcp.json +1 -1
  85. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -1
  86. package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +2 -1
@@ -31,20 +31,26 @@ export async function tryReuseFingerprintReport(prNumber, repo, stateKey, config
31
31
  }
32
32
  if (!reportAllowsFingerprintSkip(cached.report))
33
33
  return null;
34
- const live = await fetchPrFingerprint(prNumber, repo);
35
- if (live.isInMergeQueue || cached.fingerprint.isInMergeQueue)
34
+ if (cached.fingerprint.isInMergeQueue)
35
+ return null;
36
+ if (!cached.fingerprint.checkSuitesComplete)
37
+ return null;
38
+ if (cached.fingerprint.commentCount > 100)
36
39
  return null;
37
- if (!live.checkSuitesComplete || !cached.fingerprint.checkSuitesComplete)
40
+ if (cached.fingerprint.reviewCount > 100)
38
41
  return null;
39
- if (live.commentCount > 100 || cached.fingerprint.commentCount > 100)
42
+ if (cached.fingerprint.threadCount > 20)
40
43
  return null;
41
- if (live.reviewCount > 100 || cached.fingerprint.reviewCount > 100)
44
+ if (cached.fingerprint.hasMultiCommentThreads)
42
45
  return null;
43
- if (live.threadCount > 20 || cached.fingerprint.threadCount > 20)
46
+ if (!cached.fingerprint.rulesComplete)
47
+ return null;
48
+ const live = await fetchPrFingerprint(prNumber, repo);
49
+ if (live.isInMergeQueue || !live.checkSuitesComplete)
44
50
  return null;
45
- if (live.hasMultiCommentThreads || cached.fingerprint.hasMultiCommentThreads)
51
+ if (live.commentCount > 100 || live.reviewCount > 100 || live.threadCount > 20)
46
52
  return null;
47
- if (!live.rulesComplete || !cached.fingerprint.rulesComplete)
53
+ if (live.hasMultiCommentThreads || !live.rulesComplete)
48
54
  return null;
49
55
  if (!fingerprintsEqual(cached.fingerprint, live))
50
56
  return null;
@@ -1,6 +1,7 @@
1
1
  import { type WorkflowSuiteSnapshot } from "../checks/unreported-required.mts";
2
2
  import type { BatchPrData, CheckRun, ShepherdReport } from "../types.mts";
3
3
  import type { RepoInfo } from "../github/client.mts";
4
+ import type { CheckExecutionContext } from "./check-execution-context.mts";
4
5
  export interface UnreportedRequiredFields {
5
6
  unreportedRequiredChecks?: string[];
6
7
  trunkBehindBy?: number;
@@ -20,9 +21,9 @@ export declare function collectUnreportedRequired(input: {
20
21
  name: string;
21
22
  pr: number;
22
23
  relevantEvents: readonly string[];
23
- }): Promise<UnreportedRequiredFields>;
24
+ }, context?: CheckExecutionContext): Promise<UnreportedRequiredFields>;
24
25
  /**
25
26
  * A fingerprint hit can keep a stale trunk `behindBy`. Refresh the trunk compare
26
27
  * and the required-context diff without refetching the whole batch.
27
28
  */
28
- export declare function refreshCachedUnreported(report: ShepherdReport, repo: RepoInfo): Promise<ShepherdReport>;
29
+ export declare function refreshCachedUnreported(report: ShepherdReport, repo: RepoInfo, context?: CheckExecutionContext): Promise<ShepherdReport>;
@@ -1,7 +1,7 @@
1
1
  import { actionsWorkflowInProgress, reportedCheckNames, unreportedRequiredContexts, } from "../checks/unreported-required.mjs";
2
2
  import { loadBaseBehindBy, loadMergeTargetStatus } from "../github/merge-target-rules.mjs";
3
3
  /** Load trunk rules for a stack and diff them against the head's check names. */
4
- export async function collectUnreportedRequired(input) {
4
+ export async function collectUnreportedRequired(input, context) {
5
5
  const localContexts = input.batchData.branchRules?.requiredStatusCheckContexts ??
6
6
  input.batchData.branchProtection?.requiredStatusCheckContexts ??
7
7
  [];
@@ -13,7 +13,7 @@ export async function collectUnreportedRequired(input) {
13
13
  headRefName: input.batchData.headRefName,
14
14
  localContexts,
15
15
  stack: input.batchData.stack,
16
- });
16
+ }, context);
17
17
  const unreported = unreportedRequiredContexts(target.contexts, reportedCheckNames(input.checks));
18
18
  const actionsRunning = actionsWorkflowInProgress(input.suites, new Set(input.relevantEvents));
19
19
  const baseBehindBy = unreported.length > 0 && !input.batchData.stack
@@ -34,7 +34,7 @@ export async function collectUnreportedRequired(input) {
34
34
  * A fingerprint hit can keep a stale trunk `behindBy`. Refresh the trunk compare
35
35
  * and the required-context diff without refetching the whole batch.
36
36
  */
37
- export async function refreshCachedUnreported(report, repo) {
37
+ export async function refreshCachedUnreported(report, repo, context) {
38
38
  const stack = report.mergeStatus?.mergeRequirements?.stack;
39
39
  if (!report.headRefName)
40
40
  return report;
@@ -48,7 +48,7 @@ export async function refreshCachedUnreported(report, repo) {
48
48
  headRefName: report.headRefName,
49
49
  localContexts: report.mergeStatus.mergeRequirements?.requiredStatusChecks?.contexts ?? [],
50
50
  stack,
51
- });
51
+ }, context);
52
52
  const unreported = unreportedRequiredContexts(target.contexts, reportedCheckNames(reportChecks(report)));
53
53
  const next = { ...report };
54
54
  if (unreported.length > 0)
@@ -1,3 +1,4 @@
1
+ import type { CheckExecutionContext } from "./check-execution-context.mts";
1
2
  import type { GlobalOptions, ShepherdReport } from "../types.mts";
2
3
  export declare function runCheck(opts: GlobalOptions & {
3
4
  autoResolve?: boolean;
@@ -6,4 +7,4 @@ export declare function runCheck(opts: GlobalOptions & {
6
7
  persistSeen?: boolean;
7
8
  fingerprintCache?: boolean;
8
9
  merge?: boolean;
9
- }): Promise<ShepherdReport>;
10
+ }, context?: CheckExecutionContext): Promise<ShepherdReport>;
@@ -7,6 +7,7 @@ import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
7
7
  import { classifyChecks, getCiVerdict } from "../checks/classify.mjs";
8
8
  import { mergeStartupFailureChecks } from "../checks/startup-failures.mjs";
9
9
  import { fetchStartupFailureChecks, triageFailingChecks } from "../checks/triage.mjs";
10
+ import { TriageBudget } from "../checks/triage-budget.mjs";
10
11
  import { deriveMergeStatus } from "../merge-status/derive.mjs";
11
12
  import { loadConfig } from "../config/load.mjs";
12
13
  import { classifyVisibleComments } from "../comments/visible-comments.mjs";
@@ -26,7 +27,7 @@ import { discoverRuleFiles, loadRules } from "../classify/loader.mjs";
26
27
  import { buildClassifyIndex, partitionBatch } from "../classify/apply.mjs";
27
28
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
28
29
  import { getEffectiveCwd } from "../execution-context.mjs";
29
- export async function runCheck(opts) {
30
+ export async function runCheck(opts, context) {
30
31
  const repo = opts.targetRepository ?? (await getRepoInfo());
31
32
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
32
33
  if (prNumber === null) {
@@ -38,10 +39,15 @@ export async function runCheck(opts) {
38
39
  if (reuseFingerprint) {
39
40
  const cached = await tryReuseFingerprintReport(prNumber, repo, stateKey, config);
40
41
  if (cached)
41
- return refreshCachedUnreported(cached, repo);
42
+ return refreshCachedUnreported(cached, repo, context);
42
43
  }
43
44
  const paginateApprovedReviews = config.iterate.minimizeApprovals;
44
- const result = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews });
45
+ const includeReceiptSummary = (await context?.wantsReceiptSummary(prNumber, repo)) ?? false;
46
+ const result = await fetchPrBatch(prNumber, repo, {
47
+ paginateApprovedReviews,
48
+ ...(includeReceiptSummary && { includeReceiptSummary: true }),
49
+ });
50
+ context?.setReceiptSummary(result.receiptSummary ?? null);
45
51
  let batchData = result.data;
46
52
  const unknownRefresh = await refreshUnknownMergeability(prNumber, repo, batchData);
47
53
  batchData = unknownRefresh.batchData;
@@ -55,9 +61,10 @@ export async function runCheck(opts) {
55
61
  return terminal;
56
62
  }
57
63
  const startupFailuresNeedAttempt = batchData.checks.some((check) => check.source === "startup_failure" && check.runAttempt === undefined);
64
+ const triageBudget = new TriageBudget();
58
65
  const startupFailureChecks = result.checkSuitesComplete && !startupFailuresNeedAttempt
59
66
  ? []
60
- : await fetchStartupFailureChecks(repo, batchData.headRefOid, prNumber, stateKey);
67
+ : await fetchStartupFailureChecks(repo, batchData.headRefOid, prNumber, stateKey, triageBudget);
61
68
  const allChecks = mergeStartupFailureChecks(batchData.checks, startupFailureChecks);
62
69
  const classifiedPrChecks = classifyChecks(allChecks);
63
70
  const latestRemoval = batchData.latestMergeQueueRemoval;
@@ -103,8 +110,10 @@ export async function runCheck(opts) {
103
110
  const filtered = classifiedChecks.filter((c) => c.category === "filtered");
104
111
  const ignored = classifiedChecks.filter((c) => c.category === "ignored");
105
112
  const triagedBase = failing.length > 0 && !opts.skipTriage
106
- ? await triageFailingChecks(failing, repo, stateKey)
113
+ ? await triageFailingChecks(failing, repo, stateKey, triageBudget, classifiedChecks.filter((c) => c.category !== "failing"))
107
114
  : failing;
115
+ triageBudget.throwIfSecondary();
116
+ triageBudget.reportOmissionIfNeeded();
108
117
  const seenMap = await loadSeenMap(stateKey);
109
118
  const botUsernames = normalizeBotUsernames(config.botUsernames);
110
119
  const ruleSet = await loadRules(discoverRuleFiles(getEffectiveCwd()));
@@ -168,7 +177,7 @@ export async function runCheck(opts) {
168
177
  name: repo.name,
169
178
  pr: prNumber,
170
179
  relevantEvents: config.checks.ciTriggerEvents,
171
- });
180
+ }, context);
172
181
  let status = computeStatus(verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, changesRequestedReviewCount, unreported.hasUnreportedRequired);
173
182
  // Resolve any pending mergeability refresh (and the resulting MERGED/CLOSED short-circuit)
174
183
  // before deciding what to persist below — deferWhileQueued must see the same final,
@@ -251,6 +260,12 @@ export async function runCheck(opts) {
251
260
  ruleAutoResolveCommentIds: partition.ruleAutoResolveCommentIds.filter((id) => batchData.comments.find((comment) => comment.id === id)?.viewerCanMinimize === true),
252
261
  ruleAutoResolveReviewSummaryIds: partition.ruleAutoResolveReviewSummaryIds.filter((id) => batchData.reviewSummaries.find((review) => review.id === id)?.viewerCanMinimize === true),
253
262
  };
263
+ if (opts.autoMinimizeSuppressed === true &&
264
+ (authorizedPartition.ruleAutoResolveThreadIds.length > 0 ||
265
+ authorizedPartition.ruleAutoResolveCommentIds.length > 0 ||
266
+ authorizedPartition.ruleAutoResolveReviewSummaryIds.length > 0)) {
267
+ context?.invalidateReceiptSummary();
268
+ }
254
269
  const { threadIds: authorizedRuleAutoResolveThreadIds, commentIds: ruleAutoResolveCommentIds, reviewSummaryIds: ruleAutoResolveReviewSummaryIds, autoResolved, autoMinimized, autoResolveErrors, errorReasons, } = await applySuppressedRuleAutoResolve({
255
270
  enabled: opts.autoMinimizeSuppressed === true,
256
271
  partition: authorizedPartition,
@@ -2,15 +2,18 @@ import type { AgentCheck, ResolveCommand, Review } from "../../types.mts";
2
2
  /** Build the stale-CR clause appended to the `## Changes-requested reviews` instruction. */
3
3
  export declare function buildCrStaleClause(reviews: Review[]): string;
4
4
  /**
5
- * Build the optional behind-base push hint. Empty unless the branch is actually behind its base
6
- * and the user configured a non-blank `iterate.behindBaseHint` — the CLI never prescribes
5
+ * Build the optional branch-update hint. Empty unless the branch is behind or conflicts with its
6
+ * base and the user configured a non-blank `iterate.behindBaseHint` — the CLI never prescribes
7
7
  * rebase/merge mechanics itself (see "Keep skills and loop prompts minimal" in AGENTS.md); this
8
8
  * only echoes back the caller's own configured pointer. `hint` is trimmed and type-checked at the
9
9
  * point of use (rather than at config load) so a malformed rc file value (non-string, or
10
10
  * whitespace-only) degrades to "no hint" instead of rendering garbage into agent-facing text or
11
11
  * discarding the rest of the user's config.
12
12
  */
13
- export declare function buildBehindBaseHintInstruction(baseBranch: string, hint: string, isBehind: boolean): string[];
13
+ export declare function buildBehindBaseHintInstruction(baseBranch: string, hint: string, branch: {
14
+ isBehind: boolean;
15
+ hasConflicts: boolean;
16
+ }): string[];
14
17
  /**
15
18
  * Give one branch-refresh recovery path after Shepherd's single workflow rerun has failed.
16
19
  * The fetched PR base branch is raw context; the caller still owns repository-specific git
@@ -8,19 +8,20 @@ export function buildCrStaleClause(reviews) {
8
8
  return human;
9
9
  }
10
10
  /**
11
- * Build the optional behind-base push hint. Empty unless the branch is actually behind its base
12
- * and the user configured a non-blank `iterate.behindBaseHint` — the CLI never prescribes
11
+ * Build the optional branch-update hint. Empty unless the branch is behind or conflicts with its
12
+ * base and the user configured a non-blank `iterate.behindBaseHint` — the CLI never prescribes
13
13
  * rebase/merge mechanics itself (see "Keep skills and loop prompts minimal" in AGENTS.md); this
14
14
  * only echoes back the caller's own configured pointer. `hint` is trimmed and type-checked at the
15
15
  * point of use (rather than at config load) so a malformed rc file value (non-string, or
16
16
  * whitespace-only) degrades to "no hint" instead of rendering garbage into agent-facing text or
17
17
  * discarding the rest of the user's config.
18
18
  */
19
- export function buildBehindBaseHintInstruction(baseBranch, hint, isBehind) {
19
+ export function buildBehindBaseHintInstruction(baseBranch, hint, branch) {
20
20
  const trimmedHint = typeof hint === "string" ? hint.trim() : "";
21
- if (!isBehind || trimmedHint === "")
21
+ if ((!branch.isBehind && !branch.hasConflicts) || trimmedHint === "")
22
22
  return [];
23
- return [`The branch is behind PR base branch \`${baseBranch}\`. ${trimmedHint} before pushing.`];
23
+ const state = branch.hasConflicts ? "conflicts with" : "is behind";
24
+ return [`The branch ${state} PR base branch \`${baseBranch}\`. ${trimmedHint} before pushing.`];
24
25
  }
25
26
  /**
26
27
  * Give one branch-refresh recovery path after Shepherd's single workflow rerun has failed.
@@ -1,3 +1,5 @@
1
+ /* eslint-disable max-lines */
2
+ import { renderRelatedJobLines } from "../../cli/related-jobs-format.mjs";
1
3
  import { loadConfig } from "../../config/load.mjs";
2
4
  import { renderResolveCommand } from "./render.mjs";
3
5
  function renderEscalateAuthor(item) {
@@ -52,6 +54,7 @@ function renderEscalateCheck(check) {
52
54
  lines.push(` > ${check.summary}`);
53
55
  if (check.logExcerpt)
54
56
  lines.push(...renderBlockquoteLines(check.logExcerpt, " "));
57
+ lines.push(...renderRelatedJobLines(check.relatedJobs));
55
58
  if (check.rerunCommand)
56
59
  lines.push(` rerun: \`${check.rerunCommand}\``);
57
60
  for (const annotation of check.annotations ?? []) {
@@ -19,6 +19,10 @@ import { canRerunWorkflows } from "../../checks/conclusions.mjs";
19
19
  import { loadConfig } from "../../config/load.mjs";
20
20
  import { formatPrUrl } from "../../pr-reference.mjs";
21
21
  const EMPTY_RELEASED = new Set();
22
+ function hasLogEvidence(check) {
23
+ return (Boolean(check.logExcerpt?.trim()) ||
24
+ (check.relatedJobs ?? []).some((job) => Boolean(job.logExcerpt?.trim())));
25
+ }
22
26
  function checkRequiresHumanFollowUp(check) {
23
27
  if (check.rerunCommand)
24
28
  return false;
@@ -30,13 +34,13 @@ function checkRequiresHumanFollowUp(check) {
30
34
  // identify a code or configuration fix for the agent. Only a later failure with
31
35
  // no actionable evidence needs a human handoff.
32
36
  if (check.runAttempt !== undefined && check.runAttempt > 1)
33
- return !check.logExcerpt?.trim();
37
+ return !hasLogEvidence(check);
34
38
  // An external check's direct URL is actionable evidence: the agent can inspect the
35
39
  // provider and/or reproduce the reported failure locally. Only a truly bare check
36
40
  // has no autonomous investigation path.
37
41
  if (check.runId === null)
38
42
  return !check.detailsUrl?.trim();
39
- return !check.logExcerpt?.trim();
43
+ return !hasLogEvidence(check);
40
44
  }
41
45
  function nextFixAttempts(stored, threads, countAttempt) {
42
46
  const threadAttempts = stored ? { ...stored.threadAttempts } : {};
@@ -80,6 +80,7 @@ export function buildRelevantChecks(report) {
80
80
  ...(c.failedStep !== undefined && { failedStep: c.failedStep }),
81
81
  ...(c.summary !== undefined && { summary: c.summary }),
82
82
  ...(c.logExcerpt !== undefined && { logExcerpt: c.logExcerpt }),
83
+ ...(c.relatedJobs !== undefined && { relatedJobs: c.relatedJobs }),
83
84
  ...(c.annotations !== undefined && { annotations: c.annotations }),
84
85
  ...(c.scope !== undefined && { scope: c.scope }),
85
86
  ...(c.commitOid !== undefined && { commitOid: c.commitOid }),
@@ -27,6 +27,7 @@ import { stackDraftHold } from "./parent-first.mjs";
27
27
  import { findStaleNativeStackAncestry } from "./stale-ancestry.mjs";
28
28
  import { annotateBlockedWait, resolveCheckBlockerGate } from "./check-blocker-gate.mjs";
29
29
  import { buildUnreportedFixResult, planUnreportedRequired } from "./unreported-required.mjs";
30
+ import { createCheckExecutionContext } from "../check-execution-context.mjs";
30
31
  export function runIterate(opts) {
31
32
  return withIterateApiUsage(opts, () => runIterateCore(opts));
32
33
  }
@@ -40,6 +41,7 @@ async function runIterateCore(opts) {
40
41
  throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
41
42
  }
42
43
  const neverCancelRuns = opts.neverCancelRuns ?? config.actions.neverCancelRuns;
44
+ const checkContext = createCheckExecutionContext(readyDelaySeconds);
43
45
  const report = await runCheck({
44
46
  ...opts,
45
47
  prNumber,
@@ -48,7 +50,7 @@ async function runIterateCore(opts) {
48
50
  // compatibility key in the config loader.
49
51
  autoResolve: true,
50
52
  autoMinimizeSuppressed: config.actions.autoMinimizeSuppressed,
51
- });
53
+ }, checkContext);
52
54
  const [repoOwner, repoName] = report.repo.split("/");
53
55
  if (!repoOwner || !repoName) {
54
56
  throw new ShepherdError(`Unexpected repo format: "${report.repo}" (expected "owner/name")`, EXIT.DATAERR);
@@ -84,6 +86,7 @@ async function runIterateCore(opts) {
84
86
  // the apply command remains a working fallback instead of silently dropping it.
85
87
  let reviewSummaryIds = minimizeIds;
86
88
  if (selfMinimizeIds.length > 0) {
89
+ checkContext.invalidateReceiptSummary();
87
90
  const { minimized } = await autoMinimizeComments(selfMinimizeIds);
88
91
  const minimizedIds = new Set(minimized);
89
92
  const unminimized = selfMinimizeIds.filter((id) => !minimizedIds.has(id));
@@ -110,13 +113,13 @@ async function runIterateCore(opts) {
110
113
  const staleAncestry = await findStaleNativeStackAncestry(report, {
111
114
  owner: repoOwner,
112
115
  name: repoName,
113
- });
116
+ }, checkContext);
114
117
  const isCleanReadyState = report.status === "READY" &&
115
118
  !report.mergeStatus.isDraft &&
116
119
  !hasReadinessWork &&
117
120
  !activeMerge &&
118
121
  staleAncestry === null;
119
- const receiptCurrent = await revalidateReadyReceipt(receiptKey, report, hasReadinessWork);
122
+ const receiptCurrent = await revalidateReadyReceipt(receiptKey, report, hasReadinessWork, checkContext);
120
123
  const headSha = report.headSha ?? "unknown";
121
124
  // The elapsed marker survives a hidden-comment acknowledgement tick and is
122
125
  // consumed only when this tick cancels or merges. A receipt that is still
@@ -233,7 +236,7 @@ async function runIterateCore(opts) {
233
236
  if (markReadyResult)
234
237
  return markReadyResult;
235
238
  if (readyState.shouldCancel && !report.mergeStatus.isDraft) {
236
- const receiptWritten = receiptCurrent || (await recordReadyReceipt(receiptKey, report));
239
+ const receiptWritten = receiptCurrent || (await recordReadyReceipt(receiptKey, report, checkContext));
237
240
  // Aggregate --stack routing trusts only a layer's receipt, so an unwritten
238
241
  // one keeps the elapsed marker and retries next tick. A one-PR receipt
239
242
  // only lets a rerun skip the wait, so its failure never changes the action.
@@ -279,7 +282,7 @@ async function runIterateCore(opts) {
279
282
  };
280
283
  return annotateBlockedWait(await applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, wait, reportForWork, reviewSummaryIds), blockerGate);
281
284
  }
282
- async function recordReadyReceipt(key, report) {
285
+ async function recordReadyReceipt(key, report, context) {
283
286
  if (report.status !== "READY" ||
284
287
  report.mergeStatus.state !== "OPEN" ||
285
288
  report.mergeStatus.isDraft ||
@@ -287,7 +290,7 @@ async function recordReadyReceipt(key, report) {
287
290
  !report.baseRefOid)
288
291
  return false;
289
292
  try {
290
- const raw = await fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
293
+ const raw = await receiptSummaryForReport(key, report, context);
291
294
  if (fingerprintRawSummaryPr(raw) === null ||
292
295
  raw.state !== "OPEN" ||
293
296
  raw.isDraft ||
@@ -327,7 +330,7 @@ async function recordReadyReceipt(key, report) {
327
330
  }
328
331
  }
329
332
  /** Clear a stale READY receipt; return whether a current receipt remains. */
330
- async function revalidateReadyReceipt(key, report, hasReadinessWork) {
333
+ async function revalidateReadyReceipt(key, report, hasReadinessWork, context) {
331
334
  const receipt = await readReadyReceipt(key);
332
335
  if (!receipt)
333
336
  return false;
@@ -344,7 +347,7 @@ async function revalidateReadyReceipt(key, report, hasReadinessWork) {
344
347
  return false;
345
348
  }
346
349
  try {
347
- const raw = await fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
350
+ const raw = await receiptSummaryForReport(key, report, context);
348
351
  const summary = await summarizePollSummaryPr(raw, { owner: key.owner, name: key.repo }, { stackPrNumber: report.pr });
349
352
  if (annotationProbeUnavailable(raw)) {
350
353
  const sameHead = raw.headRefOid === report.headSha;
@@ -385,3 +388,12 @@ async function revalidateReadyReceipt(key, report, hasReadinessWork) {
385
388
  return false;
386
389
  }
387
390
  }
391
+ /** A mismatched or invalidated same-request hint falls back to the original fresh read. */
392
+ async function receiptSummaryForReport(key, report, context) {
393
+ const candidate = context?.getReceiptSummary();
394
+ if (candidate?.number === report.pr &&
395
+ candidate.headRefOid === report.headSha &&
396
+ candidate.baseRefOid === report.baseRefOid)
397
+ return candidate;
398
+ return fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
399
+ }
@@ -38,8 +38,15 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false, st
38
38
  const sectionRef = actionableSections.length > 0 ? `under ${actionableSections.join(", ")}` : "above";
39
39
  instructions.push(`Review each item ${sectionRef} and decide whether it needs a code change.`);
40
40
  }
41
- if (hasConflicts && !hasRepeatedWorkflowBranchRecovery) {
42
- instructions.push(buildConflictInstruction(stackRebase));
41
+ // A conflicting native stack layer follows the printed gh-stack route, so it omits the hint.
42
+ const branchUpdateHint = buildBehindBaseHintInstruction(baseBranch, behindBaseHint, {
43
+ isBehind,
44
+ hasConflicts: hasConflicts && !stackRebase,
45
+ });
46
+ // The conflict hint belongs with the conflict step; otherwise it precedes the push step.
47
+ const hintWithConflictStep = hasConflicts && !hasRepeatedWorkflowBranchRecovery;
48
+ if (hintWithConflictStep) {
49
+ instructions.push(buildConflictInstruction(stackRebase), ...branchUpdateHint);
43
50
  }
44
51
  const firstLookTotal = firstLookThreads.length + firstLookComments.length;
45
52
  if (firstLookTotal > 0) {
@@ -77,7 +84,8 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false, st
77
84
  const staleClause = buildCrStaleClause(changesRequestedReviews);
78
85
  instructions.push(`Read every body under \`## Changes-requested reviews\` and apply any warranted change.${staleClause}`);
79
86
  }
80
- instructions.push(...buildBehindBaseHintInstruction(baseBranch, behindBaseHint, isBehind));
87
+ if (!hintWithConflictStep)
88
+ instructions.push(...branchUpdateHint);
81
89
  const hasReviewMutations = resolveCommand.hasMutations || resolveOnlyCommand?.hasMutations === true;
82
90
  const mutationSuffix = hasReviewMutations ? " before review mutations" : "";
83
91
  if (hasConflicts || hasRepeatedWorkflowBranchRecovery) {
@@ -1,6 +1,7 @@
1
1
  import type { RepoInfo } from "../../github/client.mts";
2
2
  import type { PollSummaryStackAncestry } from "../../types.mts";
3
3
  import type { ShepherdReport } from "../../types/report.mts";
4
+ import type { CheckExecutionContext } from "../check-execution-context.mts";
4
5
  /**
5
6
  * A verified stale boundary for the PR being shepherded.
6
7
  *
@@ -19,4 +20,4 @@ export interface StaleNativeStackAncestry extends PollSummaryStackAncestry {
19
20
  * could not be verified. Callers must not emit a repair command from an
20
21
  * unverified snapshot.
21
22
  */
22
- export declare function findStaleNativeStackAncestry(report: Pick<ShepherdReport, "pr" | "mergeStatus">, repo: RepoInfo): Promise<StaleNativeStackAncestry | null>;
23
+ export declare function findStaleNativeStackAncestry(report: Pick<ShepherdReport, "pr" | "mergeStatus">, repo: RepoInfo, context?: CheckExecutionContext): Promise<StaleNativeStackAncestry | null>;
@@ -8,12 +8,13 @@ import { buildNativeStackRebaseInstruction } from "./native-stack-rebase.mjs";
8
8
  * could not be verified. Callers must not emit a repair command from an
9
9
  * unverified snapshot.
10
10
  */
11
- export async function findStaleNativeStackAncestry(report, repo) {
11
+ export async function findStaleNativeStackAncestry(report, repo, context) {
12
12
  const stack = report.mergeStatus.mergeRequirements?.stack;
13
13
  if (!stack || stack.position <= 1)
14
14
  return null;
15
15
  try {
16
- const topology = await readStackTopology(report.pr, repo);
16
+ const topology = await (context?.readStackTopology(report.pr, repo) ??
17
+ readStackTopology(report.pr, repo));
17
18
  const ancestry = stackAncestryGaps(topology.ordered).find((gap) => gap.childPr === report.pr);
18
19
  if (!ancestry)
19
20
  return null;
@@ -1,4 +1,5 @@
1
1
  import type { GraphqlQuotaWarningBand } from "../config/load.mts";
2
+ import { type RateLimitKind } from "../github/rate-limit-kind.mts";
2
3
  import type { ApiResourceUsage, GraphqlApiUsage, PollSummaryResult } from "../types.mts";
3
4
  /** Slow the poll for whichever of GraphQL or REST core is in a tighter band. */
4
5
  export declare function quotaPollIntervalMs(bands: GraphqlQuotaWarningBand[], usage: {
@@ -8,6 +9,7 @@ export declare function quotaPollIntervalMs(bands: GraphqlQuotaWarningBand[], us
8
9
  export interface RateLimitRetry {
9
10
  ms: number;
10
11
  resource: string;
12
+ kind: RateLimitKind;
11
13
  remaining?: number;
12
14
  limit?: number;
13
15
  resetAt?: number;
@@ -1,6 +1,6 @@
1
1
  import { summarizeApiTelemetry } from "../github/api-telemetry.mjs";
2
2
  import { GitHubRequestError } from "../github/errors.mjs";
3
- import { isRateLimitMessage } from "../comments/rate-limit.mjs";
3
+ import { rateLimitKind } from "../github/rate-limit-kind.mjs";
4
4
  import { exhaustedPrimaryLimitDelayMs } from "./poll-rate-limit-delay.mjs";
5
5
  import { selectQuotaWarning } from "./quota-selection.mjs";
6
6
  const GRAPHQL_RETRY_AFTER_DEFAULT_MS = 60_000;
@@ -28,36 +28,29 @@ export function quotaPollIntervalMs(bands, usage, fallbackMs, maxMs) {
28
28
  export function pollRateLimitRetryAfterMs(err) {
29
29
  if (!(err instanceof GitHubRequestError))
30
30
  return null;
31
- const rateLimitMessage = isRateLimitMessage(err.message) ||
32
- (err.graphqlErrors?.some((error) => isRateLimitMessage(error.message)) ?? false);
33
- const exhausted = err.rateLimit !== undefined && err.rateLimit.remaining <= 0;
34
- const retryable = err.status === 429 || err.retryAfterSeconds !== undefined || rateLimitMessage || exhausted;
35
- if (!retryable)
31
+ const kind = rateLimitKind(err);
32
+ if (kind === null)
36
33
  return null;
37
- const resource = retryResource(err);
34
+ const resource = kind === "secondary" ? "secondary" : (err.rateLimit?.resource ?? "graphql");
38
35
  const rateLimit = err.rateLimit;
39
36
  const details = {
37
+ kind,
40
38
  resource,
41
- ...(rateLimit?.remaining !== undefined && { remaining: rateLimit.remaining }),
42
- ...(rateLimit?.limit !== undefined && { limit: rateLimit.limit }),
43
- ...(rateLimit?.resetAt !== undefined && { resetAt: rateLimit.resetAt }),
39
+ ...(kind === "primary" &&
40
+ rateLimit?.remaining !== undefined && { remaining: rateLimit.remaining }),
41
+ ...(kind === "primary" && rateLimit?.limit !== undefined && { limit: rateLimit.limit }),
42
+ ...(kind === "primary" && rateLimit?.resetAt !== undefined && { resetAt: rateLimit.resetAt }),
44
43
  };
45
- if (err.retryAfterSeconds !== undefined) {
46
- return { ...details, ms: Math.max(err.retryAfterSeconds, 0) * 1000 };
44
+ if (kind === "primary" && rateLimit !== undefined) {
45
+ return {
46
+ ...details,
47
+ ms: Math.max(exhaustedPrimaryLimitDelayMs(rateLimit.resetAt, Date.now()), Math.max(err.retryAfterSeconds ?? 0, 0) * 1000),
48
+ };
47
49
  }
48
- if (exhausted && rateLimit !== undefined) {
49
- return { ...details, ms: exhaustedPrimaryLimitDelayMs(rateLimit.resetAt, Date.now()) };
50
- }
51
- return { ...details, ms: GRAPHQL_RETRY_AFTER_DEFAULT_MS };
52
- }
53
- function retryResource(err) {
54
- if (err.rateLimit?.resource)
55
- return err.rateLimit.resource;
56
- const secondary = err.status === 429 ||
57
- err.retryAfterSeconds !== undefined ||
58
- /secondary/i.test(err.message) ||
59
- (err.graphqlErrors?.some((error) => /secondary/i.test(error.message)) ?? false);
60
- return secondary ? "secondary" : "graphql";
50
+ return {
51
+ ...details,
52
+ ms: Math.max(GRAPHQL_RETRY_AFTER_DEFAULT_MS, Math.max(err.retryAfterSeconds ?? 0, 0) * 1000),
53
+ };
61
54
  }
62
55
  function rateLimitResourceLabel(resource) {
63
56
  if (resource === "graphql")
@@ -2,7 +2,8 @@ import { rest } from "../github/rest-http.mjs";
2
2
  import { sleep } from "../util/sleep.mjs";
3
3
  import { formatRateLimitGiveUpLine, formatRateLimitRetryLine, pollRateLimitRetryAfterMs, } from "./poll-quota.mjs";
4
4
  const MAX_NO_PROGRESS_ATTEMPTS = 5;
5
- const NO_PROGRESS_BACKOFF_MS = [15_000, 30_000, 60_000];
5
+ const PRIMARY_NO_PROGRESS_BACKOFF_MS = [15_000, 30_000, 60_000];
6
+ const SECONDARY_NO_PROGRESS_BACKOFF_MS = [60_000, 120_000, 240_000, 480_000, 960_000];
6
7
  const MERGE_STATES = new Set([
7
8
  "BEHIND",
8
9
  "BLOCKED",
@@ -26,8 +27,10 @@ export function createUntilTerminalRateLimitRetry() {
26
27
  },
27
28
  };
28
29
  }
29
- function backoffMs(attempt) {
30
- return NO_PROGRESS_BACKOFF_MS[Math.min(attempt, NO_PROGRESS_BACKOFF_MS.length) - 1] ?? 60_000;
30
+ function backoffMs(attempt, kind) {
31
+ const backoff = kind === "secondary" ? SECONDARY_NO_PROGRESS_BACKOFF_MS : PRIMARY_NO_PROGRESS_BACKOFF_MS;
32
+ const index = kind === "secondary" ? attempt : attempt - 1;
33
+ return backoff[Math.min(index, backoff.length - 1)] ?? backoff.at(-1);
31
34
  }
32
35
  async function waitForRateLimit(state, err, opts) {
33
36
  const retry = opts.untilTerminal ? pollRateLimitRetryAfterMs(err) : null;
@@ -35,14 +38,16 @@ async function waitForRateLimit(state, err, opts) {
35
38
  throw err;
36
39
  const elapsed = Math.round((Date.now() - opts.startedAt) / 1000);
37
40
  const progressed = state.armed &&
41
+ retry.kind === "primary" &&
38
42
  retry.resetAt !== undefined &&
39
43
  (state.lastResetAt === undefined || retry.resetAt > state.lastResetAt);
40
44
  let sleepMs = retry.ms;
41
45
  if (!state.armed || progressed) {
42
46
  state.armed = true;
43
47
  state.noProgress = 0;
44
- if (retry.resetAt !== undefined)
48
+ if (retry.kind === "primary" && retry.resetAt !== undefined) {
45
49
  state.lastResetAt = retry.resetAt;
50
+ }
46
51
  }
47
52
  else {
48
53
  state.noProgress += 1;
@@ -52,13 +57,13 @@ async function waitForRateLimit(state, err, opts) {
52
57
  }
53
58
  // Never sleep less than GitHub asked for. A short no-progress backoff must
54
59
  // not undercut an explicit Retry-After or the secondary-limit default.
55
- sleepMs = Math.max(retry.ms, backoffMs(state.noProgress));
60
+ sleepMs = Math.max(retry.ms, backoffMs(state.noProgress, retry.kind));
56
61
  }
57
62
  process.stderr.write(formatRateLimitRetryLine(opts.tickLabel, elapsed, { ...retry, ms: sleepMs }));
58
- return sleepRateLimit(sleepMs, opts, retry.resource);
63
+ return sleepRateLimit(sleepMs, opts, retry.kind, retry.resource);
59
64
  }
60
- async function sleepRateLimit(ms, opts, resource) {
61
- const canProbe = resource !== "core" && opts.targets.length > 0;
65
+ async function sleepRateLimit(ms, opts, kind, resource) {
66
+ const canProbe = kind === "primary" && resource === "graphql" && opts.targets.length > 0;
62
67
  if (ms <= opts.intervalMs || opts.intervalMs <= 0) {
63
68
  await sleep(ms);
64
69
  return undefined;
@@ -33,4 +33,6 @@ export declare function updateReadyDelay(prNumber: number, isReady: boolean, rea
33
33
  }): Promise<ReadyDelayState>;
34
34
  /** Delete the ready-delay marker once an elapsed delay has been consumed. */
35
35
  export declare function clearReadyDelay(prNumber: number, owner: string, repo: string): Promise<void>;
36
+ /** Read-only hint for including compact receipt evidence in the next full PR snapshot. */
37
+ export declare function readyDelayElapsed(prNumber: number, owner: string, repo: string, readyDelaySeconds: number): Promise<boolean>;
36
38
  export {};
@@ -54,6 +54,24 @@ export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, own
54
54
  export async function clearReadyDelay(prNumber, owner, repo) {
55
55
  await safeUnlink(readySincePath(prNumber, owner, repo));
56
56
  }
57
+ /** Read-only hint for including compact receipt evidence in the next full PR snapshot. */
58
+ export async function readyDelayElapsed(prNumber, owner, repo, readyDelaySeconds) {
59
+ const markerPath = readySincePath(prNumber, owner, repo);
60
+ let raw;
61
+ try {
62
+ raw = await readFile(markerPath, "utf8");
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ const [since, head] = raw.trim().split(" ");
68
+ const readySince = Number(since);
69
+ const now = Math.floor(Date.now() / 1000);
70
+ return Boolean(head &&
71
+ Number.isSafeInteger(readySince) &&
72
+ readySince <= now &&
73
+ now - readySince >= readyDelaySeconds);
74
+ }
57
75
  // ---------------------------------------------------------------------------
58
76
  // Helpers
59
77
  // ---------------------------------------------------------------------------