pr-shepherd 0.53.1 → 0.54.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 (88) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +1 -1
  3. package/bin/cli/args.mjs +3 -0
  4. package/bin/cli/check-blocker-handler.d.mts +2 -0
  5. package/bin/cli/check-blocker-handler.mjs +82 -0
  6. package/bin/cli/help-command-pages.d.mts +22 -3
  7. package/bin/cli/help-command-pages.mjs +21 -2
  8. package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
  9. package/bin/cli/help-iterate-poll-pages.mjs +1 -1
  10. package/bin/cli/help-top-page.d.mts +1 -1
  11. package/bin/cli/help-top-page.mjs +3 -1
  12. package/bin/cli/help.d.mts +23 -4
  13. package/bin/cli/help.mjs +2 -0
  14. package/bin/cli/iterate-branch-segment.d.mts +3 -0
  15. package/bin/cli/iterate-branch-segment.mjs +13 -0
  16. package/bin/cli/iterate-formatter.mjs +5 -13
  17. package/bin/cli/iterate-lean.mjs +1 -0
  18. package/bin/cli/poll-handler.mjs +5 -1
  19. package/bin/cli-parser.mjs +4 -0
  20. package/bin/commands/apply-check-blocker.d.mts +22 -0
  21. package/bin/commands/apply-check-blocker.mjs +36 -0
  22. package/bin/commands/check-annotations.d.mts +1 -1
  23. package/bin/commands/check-annotations.mjs +27 -17
  24. package/bin/commands/check-blocker-ref.d.mts +3 -0
  25. package/bin/commands/check-blocker-ref.mjs +50 -0
  26. package/bin/commands/check.mjs +1 -0
  27. package/bin/commands/iterate/check-blocker-gate.d.mts +18 -0
  28. package/bin/commands/iterate/check-blocker-gate.mjs +115 -0
  29. package/bin/commands/iterate/check-instructions.d.mts +2 -0
  30. package/bin/commands/iterate/check-instructions.mjs +4 -0
  31. package/bin/commands/iterate/conflicting-head-ci.d.mts +18 -0
  32. package/bin/commands/iterate/conflicting-head-ci.mjs +32 -0
  33. package/bin/commands/iterate/fix-code.d.mts +2 -0
  34. package/bin/commands/iterate/fix-code.mjs +61 -16
  35. package/bin/commands/iterate/index.mjs +22 -8
  36. package/bin/commands/iterate/native-stack-rebase.d.mts +8 -2
  37. package/bin/commands/iterate/native-stack-rebase.mjs +14 -4
  38. package/bin/commands/iterate/stack-trunk-conflict.d.mts +13 -0
  39. package/bin/commands/iterate/stack-trunk-conflict.mjs +51 -0
  40. package/bin/commands/poll-quota.d.mts +2 -0
  41. package/bin/commands/poll-quota.mjs +25 -12
  42. package/bin/commands/poll-rate-limit-cancel.d.mts +35 -0
  43. package/bin/commands/poll-rate-limit-cancel.mjs +101 -0
  44. package/bin/commands/poll-rate-limit-delay.d.mts +1 -0
  45. package/bin/commands/poll-rate-limit-delay.mjs +7 -0
  46. package/bin/commands/poll-rate-limit-wait.d.mts +14 -0
  47. package/bin/commands/poll-rate-limit-wait.mjs +125 -0
  48. package/bin/commands/poll-summary.mjs +21 -9
  49. package/bin/commands/poll.mjs +22 -17
  50. package/bin/config/load.d.mts +2 -0
  51. package/bin/config/load.mjs +24 -2
  52. package/bin/config.json +1 -0
  53. package/bin/github/batch-parse-suites.d.mts +2 -0
  54. package/bin/github/batch-parse-suites.mjs +5 -0
  55. package/bin/github/batch.d.mts +2 -0
  56. package/bin/github/batch.mjs +2 -1
  57. package/bin/github/check-annotation-cache.d.mts +8 -0
  58. package/bin/github/check-annotation-cache.mjs +23 -0
  59. package/bin/github/check-annotation-pages.d.mts +11 -0
  60. package/bin/github/check-annotation-pages.mjs +36 -0
  61. package/bin/github/check-annotation-shape.d.mts +21 -0
  62. package/bin/github/check-annotation-shape.mjs +50 -0
  63. package/bin/github/check-annotations-batch.d.mts +17 -0
  64. package/bin/github/check-annotations-batch.mjs +92 -0
  65. package/bin/github/check-annotations.d.mts +8 -11
  66. package/bin/github/check-annotations.mjs +14 -103
  67. package/bin/github/gql/batch-pr.gql +1 -1
  68. package/bin/github/gql/check-run-annotations-batch.gql +41 -0
  69. package/bin/github/gql/upper-layer-conflict-target.gql +36 -0
  70. package/bin/github/pagination.d.mts +1 -1
  71. package/bin/github/pagination.mjs +1 -1
  72. package/bin/github/poll-summary-check-blockers.d.mts +16 -0
  73. package/bin/github/poll-summary-check-blockers.mjs +25 -0
  74. package/bin/github/poll-summary-checks.d.mts +2 -0
  75. package/bin/github/poll-summary-checks.mjs +35 -20
  76. package/bin/github/poll-summary-projector.mjs +5 -0
  77. package/bin/github/queries.d.mts +11 -0
  78. package/bin/github/queries.mjs +11 -0
  79. package/bin/state/check-blockers.d.mts +30 -0
  80. package/bin/state/check-blockers.mjs +88 -0
  81. package/bin/state/conflicting-head-seen.d.mts +13 -0
  82. package/bin/state/conflicting-head-seen.mjs +44 -0
  83. package/bin/types/iterate.d.mts +5 -0
  84. package/bin/types/report.d.mts +2 -0
  85. package/package.json +1 -1
  86. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  87. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  88. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -0,0 +1,13 @@
1
+ /** An upper layer already contains its parent, so the dirty state is against trunk. */
2
+ export interface UpperLayerTrunkConflict {
3
+ trunk: string;
4
+ /** Open layer whose PR base is the trunk. Omitted when that layer is not on this page. */
5
+ bottomPr?: number;
6
+ }
7
+ export declare function lookupUpperLayerTrunkConflict(input: {
8
+ owner: string;
9
+ name: string;
10
+ pr: number;
11
+ headRef: string;
12
+ trunk: string;
13
+ }): Promise<UpperLayerTrunkConflict | undefined>;
@@ -0,0 +1,51 @@
1
+ import { graphql } from "../../github/client.mjs";
2
+ import { UPPER_LAYER_CONFLICT_TARGET_QUERY } from "../../github/queries.mjs";
3
+ import { pollRateLimitRetryAfterMs } from "../poll-quota.mjs";
4
+ /** Lowest-position open layer whose base is the stack trunk, if this page contains one. */
5
+ function selectBottomOpenLayer(nodes, trunk) {
6
+ const onTrunk = nodes.flatMap((node) => {
7
+ const pull = node?.pullRequest;
8
+ if (!node || !pull || pull.state !== "OPEN" || pull.baseRefName !== trunk)
9
+ return [];
10
+ return [{ position: node.position, number: pull.number }];
11
+ });
12
+ onTrunk.sort((a, b) => a.position - b.position);
13
+ return onTrunk[0]?.number;
14
+ }
15
+ export async function lookupUpperLayerTrunkConflict(input) {
16
+ let data;
17
+ try {
18
+ ({ data } = await graphql(UPPER_LAYER_CONFLICT_TARGET_QUERY, {
19
+ owner: input.owner,
20
+ name: input.name,
21
+ number: input.pr,
22
+ headRef: input.headRef,
23
+ }));
24
+ }
25
+ catch (err) {
26
+ if (pollRateLimitRetryAfterMs(err) !== null)
27
+ throw err;
28
+ ignore(input.pr, err instanceof Error ? err.message : String(err));
29
+ return undefined;
30
+ }
31
+ const pull = data.repository?.pullRequest;
32
+ const behindBy = pull?.baseRef?.compare?.behindBy;
33
+ if (!pull || behindBy == null) {
34
+ ignore(input.pr, pull ? "base comparison unavailable" : "pull request not found");
35
+ return undefined;
36
+ }
37
+ // behindBy > 0: the layer is still behind its parent. Keep the parent rebase.
38
+ if (behindBy !== 0)
39
+ return undefined;
40
+ const entries = pull.stack?.entries;
41
+ const bottomPr = entries ? selectBottomOpenLayer(entries.nodes, input.trunk) : undefined;
42
+ if (bottomPr === undefined) {
43
+ ignore(input.pr, entries?.pageInfo.hasNextPage
44
+ ? "stack entry page truncated before the trunk layer"
45
+ : "bottom open layer not found");
46
+ }
47
+ return { trunk: input.trunk, ...(bottomPr !== undefined && { bottomPr }) };
48
+ }
49
+ function ignore(pr, message) {
50
+ process.stderr.write(`pr-shepherd: upper-layer conflict target unavailable for PR #${pr} (ignored): ${message}\n`);
51
+ }
@@ -19,4 +19,6 @@ export interface RateLimitRetry {
19
19
  export declare function pollRateLimitRetryAfterMs(err: unknown): RateLimitRetry | null;
20
20
  /** Stderr line naming the exhausted budget and when the sleep ends. */
21
21
  export declare function formatRateLimitRetryLine(tickLabel: string, elapsedSeconds: number, retry: RateLimitRetry): string;
22
+ /** One stderr line before `--until-terminal` gives up and exits 75. */
23
+ export declare function formatRateLimitGiveUpLine(tickLabel: string, elapsedSeconds: number, retry: RateLimitRetry): string;
22
24
  export declare function aggregateQuotaWarning(result: PollSummaryResult, bands: GraphqlQuotaWarningBand[], intervalSeconds: number): Promise<PollSummaryResult["quotaWarning"]>;
@@ -1,6 +1,7 @@
1
1
  import { summarizeApiTelemetry } from "../github/api-telemetry.mjs";
2
2
  import { GitHubRequestError } from "../github/errors.mjs";
3
3
  import { isRateLimitMessage } from "../comments/rate-limit.mjs";
4
+ import { exhaustedPrimaryLimitDelayMs } from "./poll-rate-limit-delay.mjs";
4
5
  import { selectQuotaWarning } from "./quota-selection.mjs";
5
6
  const GRAPHQL_RETRY_AFTER_DEFAULT_MS = 60_000;
6
7
  /** Sleep at least `--interval`, and at least the active crossed quota band. */
@@ -45,7 +46,7 @@ export function pollRateLimitRetryAfterMs(err) {
45
46
  return { ...details, ms: Math.max(err.retryAfterSeconds, 0) * 1000 };
46
47
  }
47
48
  if (exhausted && rateLimit !== undefined) {
48
- return { ...details, ms: Math.max(rateLimit.resetAt * 1000 - Date.now(), 0) };
49
+ return { ...details, ms: exhaustedPrimaryLimitDelayMs(rateLimit.resetAt, Date.now()) };
49
50
  }
50
51
  return { ...details, ms: GRAPHQL_RETRY_AFTER_DEFAULT_MS };
51
52
  }
@@ -58,19 +59,31 @@ function retryResource(err) {
58
59
  (err.graphqlErrors?.some((error) => /secondary/i.test(error.message)) ?? false);
59
60
  return secondary ? "secondary" : "graphql";
60
61
  }
61
- /** Stderr line naming the exhausted budget and when the sleep ends. */
62
- export function formatRateLimitRetryLine(tickLabel, elapsedSeconds, retry) {
63
- const resetAt = retry.resetAt ?? Math.ceil((Date.now() + retry.ms) / 1000);
64
- const clock = `${new Date(resetAt * 1000).toISOString().slice(11, 19)}Z`;
65
- const label = retry.resource === "graphql"
66
- ? "GitHub GraphQL rate limit"
67
- : retry.resource === "secondary"
68
- ? "GitHub secondary rate limit"
69
- : `GitHub REST ${retry.resource} rate limit`;
70
- const counts = retry.remaining !== undefined && retry.limit !== undefined
62
+ function rateLimitResourceLabel(resource) {
63
+ if (resource === "graphql")
64
+ return "GitHub GraphQL rate limit";
65
+ if (resource === "secondary")
66
+ return "GitHub secondary rate limit";
67
+ return `GitHub REST ${resource} rate limit`;
68
+ }
69
+ function rateLimitCounts(retry) {
70
+ return retry.remaining !== undefined && retry.limit !== undefined
71
71
  ? ` (${retry.remaining}/${retry.limit})`
72
72
  : "";
73
- return `[${tickLabel} / +${elapsedSeconds}s] ${label}${counts} — retrying at ${clock} (in ${Math.round(retry.ms / 1000)}s)\n`;
73
+ }
74
+ function rateLimitClock(retry) {
75
+ const resetAt = retry.resetAt ?? Math.ceil((Date.now() + retry.ms) / 1000);
76
+ return `${new Date(resetAt * 1000).toISOString().slice(11, 19)}Z`;
77
+ }
78
+ /** Stderr line naming the exhausted budget and when the sleep ends. */
79
+ export function formatRateLimitRetryLine(tickLabel, elapsedSeconds, retry) {
80
+ const clock = rateLimitClock(retry);
81
+ return `[${tickLabel} / +${elapsedSeconds}s] ${rateLimitResourceLabel(retry.resource)}${rateLimitCounts(retry)} — retrying at ${clock} (in ${Math.round(retry.ms / 1000)}s)\n`;
82
+ }
83
+ /** One stderr line before `--until-terminal` gives up and exits 75. */
84
+ export function formatRateLimitGiveUpLine(tickLabel, elapsedSeconds, retry) {
85
+ const clock = rateLimitClock(retry);
86
+ return `[${tickLabel} / +${elapsedSeconds}s] ${rateLimitResourceLabel(retry.resource)}${rateLimitCounts(retry)} still exhausted at ${clock} after 5 attempts with no progress\n`;
74
87
  }
75
88
  export async function aggregateQuotaWarning(result, bands, intervalSeconds) {
76
89
  const usage = summarizeApiTelemetry();
@@ -0,0 +1,35 @@
1
+ import type { IterateResult, MergeStateStatus, MergeableState, PollSummaryResult } from "../types.mts";
2
+ export interface RateLimitPullTarget {
3
+ owner: string;
4
+ repo: string;
5
+ pr: number;
6
+ }
7
+ export interface RateLimitProbePull {
8
+ pr: number;
9
+ state: "MERGED" | "CLOSED";
10
+ title: string;
11
+ url: string;
12
+ draft: boolean;
13
+ mergeable: MergeableState;
14
+ mergeStateStatus: MergeStateStatus;
15
+ baseRefName: string;
16
+ headRefName: string;
17
+ headRefOid: string;
18
+ }
19
+ export declare function onePrRateLimitTargets(explicit: {
20
+ owner: string;
21
+ name: string;
22
+ } | undefined, pr: number | undefined, repoSlug: string | undefined): RateLimitPullTarget[];
23
+ /** Same fields as the merged/closed iterate cancel path, filled from the REST pull. */
24
+ export declare function onePrCancelFromPulls(explicit: {
25
+ owner: string;
26
+ name: string;
27
+ } | undefined, repoSlug: string | undefined, pulls: RateLimitProbePull[]): IterateResult | undefined;
28
+ export declare function aggregateRateLimitTargets(last: PollSummaryResult | undefined, repo: {
29
+ owner: string;
30
+ name: string;
31
+ } | undefined, prNumbers: number[] | undefined): RateLimitPullTarget[];
32
+ export declare function aggregateCancelFromPulls(last: PollSummaryResult | undefined, repo: {
33
+ owner: string;
34
+ name: string;
35
+ } | undefined, pulls: RateLimitProbePull[]): PollSummaryResult | undefined;
@@ -0,0 +1,101 @@
1
+ export function onePrRateLimitTargets(explicit, pr, repoSlug) {
2
+ if (pr === undefined)
3
+ return [];
4
+ const [slugOwner, slugName] = repoSlug?.split("/") ?? [];
5
+ const owner = explicit?.owner ?? slugOwner;
6
+ const repo = explicit?.name ?? slugName;
7
+ if (!owner || !repo)
8
+ return [];
9
+ return [{ owner, repo, pr }];
10
+ }
11
+ /** Same fields as the merged/closed iterate cancel path, filled from the REST pull. */
12
+ export function onePrCancelFromPulls(explicit, repoSlug, pulls) {
13
+ const pull = pulls[0];
14
+ const slug = explicit ? `${explicit.owner}/${explicit.name}` : repoSlug;
15
+ if (!pull || !slug)
16
+ return undefined;
17
+ const state = pull.state;
18
+ return {
19
+ pr: pull.pr,
20
+ repo: slug,
21
+ status: state,
22
+ state,
23
+ mergeStateStatus: pull.mergeStateStatus,
24
+ mergeStatus: "UNKNOWN",
25
+ reviewDecision: null,
26
+ blockingBotReviewInProgress: false,
27
+ isDraft: pull.draft,
28
+ shouldCancel: true,
29
+ remainingSeconds: 0,
30
+ summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 0, superseded: 0 },
31
+ baseBranch: pull.baseRefName,
32
+ branchProtection: null,
33
+ checks: [],
34
+ inProgressChecks: [],
35
+ action: "cancel",
36
+ reason: state === "MERGED" ? "merged" : "closed",
37
+ log: `CANCEL: PR #${pull.pr} is ${state.toLowerCase()} — stopping`,
38
+ };
39
+ }
40
+ export function aggregateRateLimitTargets(last, repo, prNumbers) {
41
+ if (last) {
42
+ return last.prs.flatMap((item) => {
43
+ if (item.state !== "OPEN")
44
+ return [];
45
+ const [owner, name] = item.repo.split("/");
46
+ if (!owner || !name)
47
+ return [];
48
+ return [{ owner, repo: name, pr: item.pr }];
49
+ });
50
+ }
51
+ if (!repo || !prNumbers?.length)
52
+ return [];
53
+ return prNumbers.map((pr) => ({ owner: repo.owner, repo: repo.name, pr }));
54
+ }
55
+ export function aggregateCancelFromPulls(last, repo, pulls) {
56
+ if (last) {
57
+ const byPr = new Map(pulls.map((pull) => [pull.pr, pull]));
58
+ const prs = last.prs.map((item) => applyProbe(item, byPr.get(item.pr)));
59
+ if (prs.some((item) => item.state !== "MERGED" && item.state !== "CLOSED"))
60
+ return undefined;
61
+ return { ...last, prs, reason: "all_terminal" };
62
+ }
63
+ if (!repo)
64
+ return undefined;
65
+ const slug = `${repo.owner}/${repo.name}`;
66
+ const prs = pulls.map((pull) => syntheticItem(slug, pull));
67
+ return {
68
+ mode: "summary",
69
+ repo: slug,
70
+ selection: { kind: "prs", requested: pulls.map((pull) => pull.pr) },
71
+ reason: "all_terminal",
72
+ prs,
73
+ };
74
+ }
75
+ function applyProbe(item, pull) {
76
+ if (!pull)
77
+ return item;
78
+ return {
79
+ ...item,
80
+ action: "cancel",
81
+ state: pull.state,
82
+ reasons: [pull.state === "MERGED" ? "merged" : "closed"],
83
+ };
84
+ }
85
+ function syntheticItem(repo, pull) {
86
+ return {
87
+ pr: pull.pr,
88
+ repo,
89
+ title: pull.title || `PR ${pull.pr}`,
90
+ url: pull.url || `https://github.com/${repo}/pull/${pull.pr}`,
91
+ action: "cancel",
92
+ reasons: [pull.state === "MERGED" ? "merged" : "closed"],
93
+ state: pull.state,
94
+ mergeable: pull.mergeable,
95
+ mergeStateStatus: pull.mergeStateStatus,
96
+ headRefName: pull.headRefName,
97
+ headRefOid: pull.headRefOid,
98
+ baseRefName: pull.baseRefName,
99
+ ...(pull.draft ? { isDraft: true } : {}),
100
+ };
101
+ }
@@ -0,0 +1 @@
1
+ export declare function exhaustedPrimaryLimitDelayMs(resetAtSeconds: number, nowMs: number): number;
@@ -0,0 +1,7 @@
1
+ /** Exhausted primary limit: time until resetAt, plus 5s, plus `resetAt % 5` seconds. */
2
+ const EXHAUSTED_LIMIT_MARGIN_MS = 5_000;
3
+ export function exhaustedPrimaryLimitDelayMs(resetAtSeconds, nowMs) {
4
+ return (Math.max(resetAtSeconds * 1000 - nowMs, 0) +
5
+ EXHAUSTED_LIMIT_MARGIN_MS +
6
+ (resetAtSeconds % 5) * 1000);
7
+ }
@@ -0,0 +1,14 @@
1
+ import type { RateLimitProbePull, RateLimitPullTarget } from "./poll-rate-limit-cancel.mts";
2
+ interface WaitOptions<T> {
3
+ untilTerminal: boolean;
4
+ intervalMs: number;
5
+ tickLabel: string;
6
+ startedAt: number;
7
+ targets: RateLimitPullTarget[];
8
+ onAllTerminal?: (pulls: RateLimitProbePull[]) => T | undefined;
9
+ }
10
+ export declare function createUntilTerminalRateLimitRetry(): {
11
+ reset(): void;
12
+ wait<T>(err: unknown, opts: WaitOptions<T>): Promise<T | undefined>;
13
+ };
14
+ export {};
@@ -0,0 +1,125 @@
1
+ import { rest } from "../github/rest-http.mjs";
2
+ import { sleep } from "../util/sleep.mjs";
3
+ import { formatRateLimitGiveUpLine, formatRateLimitRetryLine, pollRateLimitRetryAfterMs, } from "./poll-quota.mjs";
4
+ const MAX_NO_PROGRESS_ATTEMPTS = 5;
5
+ const NO_PROGRESS_BACKOFF_MS = [15_000, 30_000, 60_000];
6
+ const MERGE_STATES = new Set([
7
+ "BEHIND",
8
+ "BLOCKED",
9
+ "CLEAN",
10
+ "DIRTY",
11
+ "DRAFT",
12
+ "HAS_HOOKS",
13
+ "UNKNOWN",
14
+ "UNSTABLE",
15
+ ]);
16
+ export function createUntilTerminalRateLimitRetry() {
17
+ const state = { armed: false, noProgress: 0 };
18
+ return {
19
+ reset() {
20
+ state.armed = false;
21
+ state.noProgress = 0;
22
+ state.lastResetAt = undefined;
23
+ },
24
+ wait(err, opts) {
25
+ return waitForRateLimit(state, err, opts);
26
+ },
27
+ };
28
+ }
29
+ function backoffMs(attempt) {
30
+ return NO_PROGRESS_BACKOFF_MS[Math.min(attempt, NO_PROGRESS_BACKOFF_MS.length) - 1] ?? 60_000;
31
+ }
32
+ async function waitForRateLimit(state, err, opts) {
33
+ const retry = opts.untilTerminal ? pollRateLimitRetryAfterMs(err) : null;
34
+ if (!retry)
35
+ throw err;
36
+ const elapsed = Math.round((Date.now() - opts.startedAt) / 1000);
37
+ const progressed = state.armed &&
38
+ retry.resetAt !== undefined &&
39
+ (state.lastResetAt === undefined || retry.resetAt > state.lastResetAt);
40
+ let sleepMs = retry.ms;
41
+ if (!state.armed || progressed) {
42
+ state.armed = true;
43
+ state.noProgress = 0;
44
+ if (retry.resetAt !== undefined)
45
+ state.lastResetAt = retry.resetAt;
46
+ }
47
+ else {
48
+ state.noProgress += 1;
49
+ if (state.noProgress >= MAX_NO_PROGRESS_ATTEMPTS) {
50
+ process.stderr.write(formatRateLimitGiveUpLine(opts.tickLabel, elapsed, retry));
51
+ throw err;
52
+ }
53
+ // Never sleep less than GitHub asked for. A short no-progress backoff must
54
+ // not undercut an explicit Retry-After or the secondary-limit default.
55
+ sleepMs = Math.max(retry.ms, backoffMs(state.noProgress));
56
+ }
57
+ process.stderr.write(formatRateLimitRetryLine(opts.tickLabel, elapsed, { ...retry, ms: sleepMs }));
58
+ return sleepRateLimit(sleepMs, opts, retry.resource);
59
+ }
60
+ async function sleepRateLimit(ms, opts, resource) {
61
+ const canProbe = resource !== "core" && opts.targets.length > 0;
62
+ if (ms <= opts.intervalMs || opts.intervalMs <= 0) {
63
+ await sleep(ms);
64
+ return undefined;
65
+ }
66
+ let remaining = ms;
67
+ let probesDisabled = !canProbe;
68
+ while (remaining > 0) {
69
+ const chunk = Math.min(opts.intervalMs, remaining);
70
+ await sleep(chunk);
71
+ remaining -= chunk;
72
+ if (remaining <= 0 || probesDisabled)
73
+ continue;
74
+ try {
75
+ const found = await probeOpenPulls(opts.targets, opts.onAllTerminal);
76
+ if (found !== undefined)
77
+ return found;
78
+ }
79
+ catch {
80
+ // The probe only notices a merge or close early. A 5xx, a 404, or a
81
+ // secondary limit must not abort the rate-limit sleep.
82
+ probesDisabled = true;
83
+ }
84
+ }
85
+ return undefined;
86
+ }
87
+ async function probeOpenPulls(targets, onAllTerminal) {
88
+ const pulls = [];
89
+ for (const target of targets) {
90
+ // GraphQL is exhausted for this sleep. REST core is a separate budget, so
91
+ // GET /repos/{owner}/{repo}/pulls/{n} can still see a merge or close.
92
+ const data = await rest("GET", `/repos/${target.owner}/${target.repo}/pulls/${target.pr}`);
93
+ const state = terminalState(data);
94
+ if (!state)
95
+ return undefined;
96
+ pulls.push(toProbePull(target.pr, state, data));
97
+ }
98
+ return onAllTerminal?.(pulls);
99
+ }
100
+ function terminalState(pull) {
101
+ if (!pull)
102
+ return undefined;
103
+ if (pull.merged === true || pull.merged_at != null)
104
+ return "MERGED";
105
+ if (pull.state?.toLowerCase() === "closed")
106
+ return "CLOSED";
107
+ return undefined;
108
+ }
109
+ function toProbePull(pr, state, pull) {
110
+ const mergeState = pull.mergeable_state?.toUpperCase();
111
+ return {
112
+ pr,
113
+ state,
114
+ title: pull.title ?? "",
115
+ url: pull.html_url ?? "",
116
+ draft: pull.draft === true,
117
+ mergeable: pull.mergeable === true ? "MERGEABLE" : pull.mergeable === false ? "CONFLICTING" : "UNKNOWN",
118
+ mergeStateStatus: MERGE_STATES.has(mergeState ?? "")
119
+ ? mergeState
120
+ : "UNKNOWN",
121
+ baseRefName: pull.base?.ref ?? "",
122
+ headRefName: pull.head?.ref ?? "",
123
+ headRefOid: pull.head?.sha ?? "",
124
+ };
125
+ }
@@ -5,7 +5,9 @@ import { getRepoInfo } from "../github/client.mjs";
5
5
  import { withApiTelemetryScope, summarizeApiTelemetry } from "../github/api-telemetry.mjs";
6
6
  import { fetchPollSummary } from "../github/poll-summary.mjs";
7
7
  import { sleep } from "../util/sleep.mjs";
8
- import { aggregateQuotaWarning, formatRateLimitRetryLine, pollRateLimitRetryAfterMs, quotaPollIntervalMs, } from "./poll-quota.mjs";
8
+ import { aggregateQuotaWarning, quotaPollIntervalMs } from "./poll-quota.mjs";
9
+ import { aggregateCancelFromPulls, aggregateRateLimitTargets } from "./poll-rate-limit-cancel.mjs";
10
+ import { createUntilTerminalRateLimitRetry } from "./poll-rate-limit-wait.mjs";
9
11
  import { planPollSummary, withPollSummaryInstructions } from "./poll-summary-instructions.mjs";
10
12
  import { summaryStatusSignature } from "./poll-summary-signature.mjs";
11
13
  import { applyStackStallGuard } from "./stack-stall.mjs";
@@ -42,13 +44,13 @@ async function runAggregatePollCore(opts) {
42
44
  let debounceUntil = null;
43
45
  let last;
44
46
  let lastStatusSignature = null;
45
- let rateLimitRetries = 0;
47
+ const rateLimitRetry = createUntilTerminalRateLimitRetry();
46
48
  let pendingQuotaWarning;
47
49
  while (true) {
48
50
  tick += 1;
49
51
  try {
50
52
  last = await runPollSummaryCore(opts);
51
- rateLimitRetries = 0;
53
+ rateLimitRetry.reset();
52
54
  }
53
55
  catch (error) {
54
56
  if (last?.selection.kind === "stack" && isMissingStack(error)) {
@@ -83,12 +85,17 @@ async function runAggregatePollCore(opts) {
83
85
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
84
86
  }, opts.merge);
85
87
  }
86
- const retry = opts.untilTerminal ? pollRateLimitRetryAfterMs(error) : null;
87
- if (retry === null || rateLimitRetries >= 1)
88
- throw error;
89
- rateLimitRetries += 1;
90
- process.stderr.write(formatRateLimitRetryLine(`aggregate poll tick ${tick}`, Math.round((Date.now() - start) / 1000), retry));
91
- await sleep(retry.ms);
88
+ const early = await rateLimitRetry.wait(error, {
89
+ untilTerminal: opts.untilTerminal === true,
90
+ intervalMs,
91
+ tickLabel: `aggregate poll tick ${tick}`,
92
+ startedAt: start,
93
+ targets: aggregateRateLimitTargets(last, opts.targetRepository, opts.prNumbers),
94
+ onAllTerminal: (pulls) => aggregateCancelFromPulls(last, opts.targetRepository, pulls),
95
+ });
96
+ if (early) {
97
+ return presentProbedCancel(pendingQuotaWarning ? { ...early, quotaWarning: pendingQuotaWarning } : early, opts.merge);
98
+ }
92
99
  continue;
93
100
  }
94
101
  const allTerminal = last.selection.kind === "stack"
@@ -159,6 +166,11 @@ async function runAggregatePollCore(opts) {
159
166
  await sleep(sleepMs);
160
167
  }
161
168
  }
169
+ function presentProbedCancel(result, merge) {
170
+ // The stack planner already cancels an all-merged stack and escalates a
171
+ // closed-unmerged layer. Do not rewrite that into a successful stop.
172
+ return attachUsage({ ...result, reason: "all_terminal" }, merge);
173
+ }
162
174
  function isMissingStack(error) {
163
175
  return (error instanceof ShepherdError && error.message.includes("not part of a native GitHub stack"));
164
176
  }
@@ -2,7 +2,9 @@ import { runIterate } from "./iterate/index.mjs";
2
2
  import { sleep } from "../util/sleep.mjs";
3
3
  import { withPollApiUsage } from "./poll-run.mjs";
4
4
  import { loadConfig } from "../config/load.mjs";
5
- import { formatRateLimitRetryLine, pollRateLimitRetryAfterMs, quotaPollIntervalMs, } from "./poll-quota.mjs";
5
+ import { quotaPollIntervalMs } from "./poll-quota.mjs";
6
+ import { onePrCancelFromPulls, onePrRateLimitTargets } from "./poll-rate-limit-cancel.mjs";
7
+ import { createUntilTerminalRateLimitRetry } from "./poll-rate-limit-wait.mjs";
6
8
  import { writeDebounceProgress, writeWaitProgress } from "./poll-progress.mjs";
7
9
  const DEFAULT_POLL_DEBOUNCE_SECONDS = 60;
8
10
  const MAX_TIMER_MS = 2 ** 31 - 1;
@@ -29,7 +31,7 @@ async function runPollCore(opts) {
29
31
  // Pin the PR resolved by the first tick; branch inference only matches OPEN PRs.
30
32
  let prNumber = opts.prNumber;
31
33
  let debounceUntil = null;
32
- let rateLimitRetries = 0;
34
+ const rateLimitRetry = createUntilTerminalRateLimitRetry();
33
35
  while (true) {
34
36
  tick += 1;
35
37
  const pastDebounce = debounceUntil !== null && Date.now() >= debounceUntil;
@@ -48,21 +50,24 @@ async function runPollCore(opts) {
48
50
  quotaWarningMinimumPollIntervalMinutes: intervalSeconds / 60,
49
51
  });
50
52
  const runTick = async (fingerprintCache) => {
51
- try {
52
- const result = await iterateTick(fingerprintCache);
53
- rateLimitRetries = 0;
54
- return result;
55
- }
56
- catch (err) {
57
- const retry = untilTerminal ? pollRateLimitRetryAfterMs(err) : null;
58
- if (retry === null || rateLimitRetries >= 1)
59
- throw err;
60
- rateLimitRetries += 1;
61
- process.stderr.write(formatRateLimitRetryLine(`poll tick ${tick}`, Math.round((Date.now() - start) / 1000), retry));
62
- await sleep(retry.ms);
63
- const result = await iterateTick(fingerprintCache);
64
- rateLimitRetries = 0;
65
- return result;
53
+ while (true) {
54
+ try {
55
+ const result = await iterateTick(fingerprintCache);
56
+ rateLimitRetry.reset();
57
+ return result;
58
+ }
59
+ catch (err) {
60
+ const early = await rateLimitRetry.wait(err, {
61
+ untilTerminal,
62
+ intervalMs,
63
+ tickLabel: `poll tick ${tick}`,
64
+ startedAt: start,
65
+ targets: onePrRateLimitTargets(iterateOpts.targetRepository, prNumber, lastResult?.repo),
66
+ onAllTerminal: (pulls) => onePrCancelFromPulls(iterateOpts.targetRepository, lastResult?.repo, pulls),
67
+ });
68
+ if (early)
69
+ return early;
70
+ }
66
71
  }
67
72
  };
68
73
  lastResult = await runTick(allowCache);
@@ -8,6 +8,8 @@ export interface GraphqlQuotaWarningBand {
8
8
  }
9
9
  interface PollConfig {
10
10
  intervalSeconds: number;
11
+ /** Multiplier for aggregate polls when `--interval` is omitted. Finite and at least 1. */
12
+ stackIntervalFactor: number;
11
13
  timeoutSeconds: number;
12
14
  debounceSeconds: number;
13
15
  quietStatus: boolean;
@@ -153,11 +153,27 @@ function parsePollConfig(value) {
153
153
  const intervalSeconds = parsePollDuration(record["intervalSeconds"], "intervalSeconds");
154
154
  const timeoutSeconds = parsePollDuration(record["timeoutSeconds"], "timeoutSeconds");
155
155
  const debounceSeconds = parsePollDuration(record["debounceSeconds"], "debounceSeconds", true);
156
+ const stackIntervalFactor = parseStackIntervalFactor(record["stackIntervalFactor"]);
157
+ assertAggregateIntervalFits(intervalSeconds, stackIntervalFactor);
156
158
  const quietStatus = record["quietStatus"];
157
159
  if (typeof quietStatus !== "boolean") {
158
160
  throw new Error(`Invalid config: poll.quietStatus must be a boolean, got ${JSON.stringify(quietStatus)}`);
159
161
  }
160
- return { intervalSeconds, timeoutSeconds, debounceSeconds, quietStatus };
162
+ return { intervalSeconds, stackIntervalFactor, timeoutSeconds, debounceSeconds, quietStatus };
163
+ }
164
+ function parseStackIntervalFactor(value) {
165
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1) {
166
+ throw new Error(`Invalid config: poll.stackIntervalFactor must be a finite number greater than or equal to 1, got ${JSON.stringify(value)}`);
167
+ }
168
+ return value;
169
+ }
170
+ /** Largest `setTimeout` delay. A bigger aggregate sleep would clamp to ~24.8 days. */
171
+ const MAX_POLL_INTERVAL_MS = 2 ** 31 - 1;
172
+ function assertAggregateIntervalFits(intervalSeconds, factor) {
173
+ const ms = intervalSeconds * factor * 1000;
174
+ if (!Number.isFinite(ms) || ms > MAX_POLL_INTERVAL_MS) {
175
+ throw new Error(`Invalid config: poll.intervalSeconds * poll.stackIntervalFactor must be finite and at most ${MAX_POLL_INTERVAL_MS} milliseconds, got ${JSON.stringify(ms)}`);
176
+ }
161
177
  }
162
178
  function parsePollDuration(value, key, allowZero = false) {
163
179
  if (typeof value !== "number" ||
@@ -252,7 +268,13 @@ const KNOWN_NESTED_KEYS = {
252
268
  "behindBaseHint",
253
269
  "resolveOtherHumanThreads",
254
270
  ]),
255
- poll: new Set(["intervalSeconds", "timeoutSeconds", "debounceSeconds", "quietStatus"]),
271
+ poll: new Set([
272
+ "intervalSeconds",
273
+ "stackIntervalFactor",
274
+ "timeoutSeconds",
275
+ "debounceSeconds",
276
+ "quietStatus",
277
+ ]),
256
278
  watch: new Set(["readyDelayMinutes", "graphqlQuotaWarnings"]),
257
279
  resolve: new Set(["shaPoll"]),
258
280
  checks: new Set(["ciTriggerEvents", "ignoreLogLines"]),
package/bin/config.json CHANGED
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "poll": {
27
27
  "intervalSeconds": 60,
28
+ "stackIntervalFactor": 2,
28
29
  "timeoutSeconds": 270,
29
30
  "debounceSeconds": 60,
30
31
  "quietStatus": false
@@ -1,4 +1,6 @@
1
1
  import type { CheckRun } from "../types.mts";
2
2
  import type { RawPr } from "./batch-raw-types.mts";
3
3
  export declare function parseCheckSuitesComplete(raw: RawPr): boolean;
4
+ /** True only when the head commit's suite page finished and listed nothing. */
5
+ export declare function parseHeadCheckSuitesEmpty(raw: RawPr): boolean;
4
6
  export declare function parseSuiteStartupFailures(raw: RawPr): CheckRun[];
@@ -1,6 +1,11 @@
1
1
  export function parseCheckSuitesComplete(raw) {
2
2
  return raw.commits.nodes[0]?.commit.checkSuites?.pageInfo.hasNextPage === false;
3
3
  }
4
+ /** True only when the head commit's suite page finished and listed nothing. */
5
+ export function parseHeadCheckSuitesEmpty(raw) {
6
+ const suites = raw.commits.nodes[0]?.commit.checkSuites;
7
+ return suites?.pageInfo.hasNextPage === false && suites.nodes.length === 0;
8
+ }
4
9
  export function parseSuiteStartupFailures(raw) {
5
10
  const suites = raw.commits.nodes[0]?.commit.checkSuites;
6
11
  if (!suites)
@@ -7,6 +7,8 @@ interface BatchResult {
7
7
  rateLimit?: RateLimitInfo;
8
8
  /** True when GraphQL returned a complete CheckSuite page; skip REST startup-failure fetch. */
9
9
  checkSuitesComplete?: boolean;
10
+ /** True when that complete page listed no check suites on the head commit. */
11
+ headCheckSuitesEmpty?: true;
10
12
  }
11
13
  interface FetchPrBatchOptions {
12
14
  /**
@@ -2,7 +2,7 @@ import { graphqlWithRateLimit } from "./client.mjs";
2
2
  import { hydrateThreadCommentPages } from "./thread-comments.mjs";
3
3
  import { BATCH_PR_QUERY } from "./queries.mjs";
4
4
  import { parseRawPr } from "./batch-parsers.mjs";
5
- import { parseCheckSuitesComplete, parseSuiteStartupFailures } from "./batch-parse-suites.mjs";
5
+ import { parseCheckSuitesComplete, parseHeadCheckSuitesEmpty, parseSuiteStartupFailures, } from "./batch-parse-suites.mjs";
6
6
  import { mergeStartupFailureChecks } from "../checks/startup-failures.mjs";
7
7
  import { paginateBatchConnections } from "./batch-page.mjs";
8
8
  import { requireRawPr } from "./batch-response.mjs";
@@ -28,5 +28,6 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
28
28
  fingerprint: fingerprintFromRaw(raw, result.data.repository?.viewerPermission ?? null, result.data.viewer?.login ?? null),
29
29
  rateLimit: paged.rateLimit ?? result.rateLimit,
30
30
  ...(parseCheckSuitesComplete(raw) && { checkSuitesComplete: true }),
31
+ ...(parseHeadCheckSuitesEmpty(raw) && { headCheckSuitesEmpty: true }),
31
32
  };
32
33
  }