pr-shepherd 0.53.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +1 -1
  3. package/bin/cli/api-usage-formatter.mjs +17 -3
  4. package/bin/cli/args.mjs +3 -0
  5. package/bin/cli/check-blocker-handler.d.mts +2 -0
  6. package/bin/cli/check-blocker-handler.mjs +82 -0
  7. package/bin/cli/help-command-pages.d.mts +22 -3
  8. package/bin/cli/help-command-pages.mjs +21 -2
  9. package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
  10. package/bin/cli/help-iterate-poll-pages.mjs +1 -1
  11. package/bin/cli/help-top-page.d.mts +1 -1
  12. package/bin/cli/help-top-page.mjs +3 -1
  13. package/bin/cli/help.d.mts +23 -4
  14. package/bin/cli/help.mjs +2 -0
  15. package/bin/cli/poll-handler.mjs +5 -1
  16. package/bin/cli-parser.mjs +4 -0
  17. package/bin/commands/apply-check-blocker.d.mts +22 -0
  18. package/bin/commands/apply-check-blocker.mjs +36 -0
  19. package/bin/commands/check-annotations.d.mts +1 -1
  20. package/bin/commands/check-annotations.mjs +27 -17
  21. package/bin/commands/check-blocker-ref.d.mts +3 -0
  22. package/bin/commands/check-blocker-ref.mjs +50 -0
  23. package/bin/commands/iterate/api-usage.mjs +5 -4
  24. package/bin/commands/iterate/check-blocker-gate.d.mts +18 -0
  25. package/bin/commands/iterate/check-blocker-gate.mjs +115 -0
  26. package/bin/commands/iterate/check-instructions.d.mts +2 -0
  27. package/bin/commands/iterate/check-instructions.mjs +4 -0
  28. package/bin/commands/iterate/fix-code.d.mts +2 -0
  29. package/bin/commands/iterate/fix-code.mjs +31 -15
  30. package/bin/commands/iterate/index.mjs +22 -8
  31. package/bin/commands/poll-quota.d.mts +20 -6
  32. package/bin/commands/poll-quota.mjs +70 -19
  33. package/bin/commands/poll-rate-limit-cancel.d.mts +35 -0
  34. package/bin/commands/poll-rate-limit-cancel.mjs +101 -0
  35. package/bin/commands/poll-rate-limit-delay.d.mts +1 -0
  36. package/bin/commands/poll-rate-limit-delay.mjs +7 -0
  37. package/bin/commands/poll-rate-limit-wait.d.mts +14 -0
  38. package/bin/commands/poll-rate-limit-wait.mjs +125 -0
  39. package/bin/commands/poll-summary.mjs +22 -10
  40. package/bin/commands/poll.mjs +24 -19
  41. package/bin/commands/quota-selection.d.mts +7 -0
  42. package/bin/commands/quota-selection.mjs +20 -0
  43. package/bin/config/load.d.mts +2 -0
  44. package/bin/config/load.mjs +24 -2
  45. package/bin/config.json +1 -0
  46. package/bin/github/check-annotation-cache.d.mts +8 -0
  47. package/bin/github/check-annotation-cache.mjs +23 -0
  48. package/bin/github/check-annotation-pages.d.mts +11 -0
  49. package/bin/github/check-annotation-pages.mjs +36 -0
  50. package/bin/github/check-annotation-shape.d.mts +21 -0
  51. package/bin/github/check-annotation-shape.mjs +50 -0
  52. package/bin/github/check-annotations-batch.d.mts +17 -0
  53. package/bin/github/check-annotations-batch.mjs +92 -0
  54. package/bin/github/check-annotations.d.mts +8 -11
  55. package/bin/github/check-annotations.mjs +14 -103
  56. package/bin/github/gql/batch-pr.gql +1 -1
  57. package/bin/github/gql/check-run-annotations-batch.gql +41 -0
  58. package/bin/github/pagination.d.mts +1 -1
  59. package/bin/github/pagination.mjs +1 -1
  60. package/bin/github/poll-summary-check-blockers.d.mts +16 -0
  61. package/bin/github/poll-summary-check-blockers.mjs +25 -0
  62. package/bin/github/poll-summary-checks.d.mts +2 -0
  63. package/bin/github/poll-summary-checks.mjs +35 -20
  64. package/bin/github/poll-summary-projector.mjs +5 -0
  65. package/bin/github/queries.d.mts +6 -0
  66. package/bin/github/queries.mjs +6 -0
  67. package/bin/quota-budgets.d.mts +16 -0
  68. package/bin/quota-budgets.mjs +65 -0
  69. package/bin/quota-warning.mjs +11 -1
  70. package/bin/state/check-blockers.d.mts +30 -0
  71. package/bin/state/check-blockers.mjs +88 -0
  72. package/bin/state/graphql-quota-policy.mjs +1 -1
  73. package/bin/state/graphql-quota-warnings.d.mts +2 -2
  74. package/bin/state/graphql-quota-warnings.mjs +5 -4
  75. package/bin/types/api-usage.d.mts +14 -3
  76. package/package.json +1 -1
  77. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  78. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  79. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -1,10 +1,11 @@
1
- import { evaluateWorktreeGraphqlQuotaWarning } from "../state/graphql-quota-warnings.mjs";
2
- import { summarizeApiTelemetry, withGraphqlCredentialFingerprint, } from "../github/api-telemetry.mjs";
1
+ import { summarizeApiTelemetry } from "../github/api-telemetry.mjs";
3
2
  import { GitHubRequestError } from "../github/errors.mjs";
4
3
  import { isRateLimitMessage } from "../comments/rate-limit.mjs";
4
+ import { exhaustedPrimaryLimitDelayMs } from "./poll-rate-limit-delay.mjs";
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. */
7
- export function graphqlQuotaPollIntervalMs(bands, usage, fallbackMs, maxMs) {
8
+ function graphqlQuotaPollIntervalMs(bands, usage, fallbackMs, maxMs) {
8
9
  if (usage === undefined || usage.limit <= 0 || bands.length === 0) {
9
10
  return Math.min(fallbackMs, maxMs);
10
11
  }
@@ -15,34 +16,84 @@ export function graphqlQuotaPollIntervalMs(bands, usage, fallbackMs, maxMs) {
15
16
  const bandMs = active.pollIntervalMinutes * 60_000;
16
17
  return Math.min(Math.max(fallbackMs, bandMs), maxMs);
17
18
  }
19
+ /** Slow the poll for whichever of GraphQL or REST core is in a tighter band. */
20
+ export function quotaPollIntervalMs(bands, usage, fallbackMs, maxMs) {
21
+ const core = usage?.rest?.find((item) => item.resource === "core");
22
+ return Math.max(graphqlQuotaPollIntervalMs(bands, usage?.graphql, fallbackMs, maxMs), graphqlQuotaPollIntervalMs(bands, core, fallbackMs, maxMs));
23
+ }
18
24
  /**
19
- * Retry delay for `--until-terminal` when GitHub returns a GraphQL 429 / secondary
20
- * limit. `null` means the error is not a retryable rate limit.
25
+ * Retry delay for `--until-terminal` when GitHub exhausts a primary quota or
26
+ * returns a secondary limit. `null` means the error is not a retryable rate limit.
21
27
  */
22
- export function pollGraphQlRetryAfterMs(err) {
28
+ export function pollRateLimitRetryAfterMs(err) {
23
29
  if (!(err instanceof GitHubRequestError))
24
30
  return null;
25
- const retryable = err.status === 429 ||
26
- err.retryAfterSeconds !== undefined ||
27
- isRateLimitMessage(err.message) ||
28
- (err.graphqlErrors?.some((error) => isRateLimitMessage(error.message)) ?? false) ||
29
- (err.rateLimit !== undefined && err.rateLimit.remaining <= 0);
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;
30
35
  if (!retryable)
31
36
  return null;
32
- if (err.retryAfterSeconds !== undefined)
33
- return Math.max(err.retryAfterSeconds, 0) * 1000;
34
- if (err.rateLimit !== undefined && err.rateLimit.remaining <= 0) {
35
- return Math.max(err.rateLimit.resetAt * 1000 - Date.now(), 0);
37
+ const resource = retryResource(err);
38
+ const rateLimit = err.rateLimit;
39
+ const details = {
40
+ resource,
41
+ ...(rateLimit?.remaining !== undefined && { remaining: rateLimit.remaining }),
42
+ ...(rateLimit?.limit !== undefined && { limit: rateLimit.limit }),
43
+ ...(rateLimit?.resetAt !== undefined && { resetAt: rateLimit.resetAt }),
44
+ };
45
+ if (err.retryAfterSeconds !== undefined) {
46
+ return { ...details, ms: Math.max(err.retryAfterSeconds, 0) * 1000 };
47
+ }
48
+ if (exhausted && rateLimit !== undefined) {
49
+ return { ...details, ms: exhaustedPrimaryLimitDelayMs(rateLimit.resetAt, Date.now()) };
36
50
  }
37
- return GRAPHQL_RETRY_AFTER_DEFAULT_MS;
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";
61
+ }
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
+ ? ` (${retry.remaining}/${retry.limit})`
72
+ : "";
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`;
38
87
  }
39
88
  export async function aggregateQuotaWarning(result, bands, intervalSeconds) {
40
- const usage = summarizeApiTelemetry()?.graphql;
89
+ const usage = summarizeApiTelemetry();
41
90
  const [owner, repo] = result.repo.split("/");
42
91
  if (!usage || !owner || !repo)
43
92
  return undefined;
44
- return evaluateWorktreeGraphqlQuotaWarning({ owner, repo }, bands.map((band) => ({
93
+ if (!usage.graphql && !usage.rest?.some((item) => item.resource === "core"))
94
+ return undefined;
95
+ return selectQuotaWarning({ owner, repo }, bands.map((band) => ({
45
96
  ...band,
46
97
  pollIntervalMinutes: Math.max(band.pollIntervalMinutes, intervalSeconds / 60),
47
- })), withGraphqlCredentialFingerprint(usage), true);
98
+ })), usage, true);
48
99
  }
@@ -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, graphqlQuotaPollIntervalMs, pollGraphQlRetryAfterMs, } 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 retryMs = opts.untilTerminal ? pollGraphQlRetryAfterMs(error) : null;
87
- if (retryMs === null || rateLimitRetries >= 1)
88
- throw error;
89
- rateLimitRetries += 1;
90
- process.stderr.write(`[aggregate poll tick ${tick} / +${Math.round((Date.now() - start) / 1000)}s] GraphQL rate limit — retrying in ${Math.round(retryMs / 1000)}s\n`);
91
- await sleep(retryMs);
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"
@@ -142,7 +149,7 @@ async function runAggregatePollCore(opts) {
142
149
  const elapsedMs = Date.now() - start;
143
150
  const sleepMs = debounceUntil
144
151
  ? Math.min(intervalMs, Math.max(debounceUntil - Date.now(), 0))
145
- : graphqlQuotaPollIntervalMs(quotaBands, summarizeApiTelemetry()?.graphql, intervalMs, MAX_TIMER_MS);
152
+ : quotaPollIntervalMs(quotaBands, summarizeApiTelemetry(), intervalMs, MAX_TIMER_MS);
146
153
  if (!opts.untilTerminal && debounceUntil === null) {
147
154
  const remainingMs = timeoutMs - elapsedMs;
148
155
  if (remainingMs <= 0 || remainingMs + TIMER_DRIFT_TOLERANCE_MS < sleepMs) {
@@ -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 { graphqlQuotaPollIntervalMs, pollGraphQlRetryAfterMs } 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 retryMs = untilTerminal ? pollGraphQlRetryAfterMs(err) : null;
58
- if (retryMs === null || rateLimitRetries >= 1)
59
- throw err;
60
- rateLimitRetries += 1;
61
- process.stderr.write(`[poll tick ${tick} / +${Math.round((Date.now() - start) / 1000)}s] GraphQL rate limit — retrying in ${Math.round(retryMs / 1000)}s\n`);
62
- await sleep(retryMs);
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);
@@ -101,7 +106,7 @@ async function runPollCore(opts) {
101
106
  if (pendingQuotaWarning === undefined)
102
107
  debounceUntil = null;
103
108
  const elapsedMs = Date.now() - start;
104
- const sleepMs = graphqlQuotaPollIntervalMs(quotaBands, lastResult.apiUsage?.graphql, intervalMs, MAX_TIMER_MS);
109
+ const sleepMs = quotaPollIntervalMs(quotaBands, lastResult.apiUsage, intervalMs, MAX_TIMER_MS);
105
110
  if (!untilTerminal) {
106
111
  const remainingMs = timeoutMs - elapsedMs;
107
112
  if (remainingMs <= 0 || remainingMs + TIMER_DRIFT_TOLERANCE_MS < sleepMs) {
@@ -126,7 +131,7 @@ async function runPollCore(opts) {
126
131
  !pastDebounce) {
127
132
  debounceUntil = null;
128
133
  const elapsedMs = Date.now() - start;
129
- const sleepMs = graphqlQuotaPollIntervalMs(quotaBands, lastResult.apiUsage?.graphql, intervalMs, MAX_TIMER_MS);
134
+ const sleepMs = quotaPollIntervalMs(quotaBands, lastResult.apiUsage, intervalMs, MAX_TIMER_MS);
130
135
  if (!untilTerminal) {
131
136
  const remainingMs = timeoutMs - elapsedMs;
132
137
  if (remainingMs <= 0 || remainingMs + TIMER_DRIFT_TOLERANCE_MS < sleepMs) {
@@ -0,0 +1,7 @@
1
+ import type { GraphqlQuotaWarningBand } from "../config/load.mts";
2
+ import type { ApiUsage, GraphqlQuotaWarning } from "../types.mts";
3
+ /** Claim GraphQL and REST core warnings separately, then present one result. */
4
+ export declare function selectQuotaWarning(key: {
5
+ owner: string;
6
+ repo: string;
7
+ }, bands: GraphqlQuotaWarningBand[], usage: ApiUsage, persist: boolean): Promise<GraphqlQuotaWarning | undefined>;
@@ -0,0 +1,20 @@
1
+ import { withGraphqlCredentialFingerprint } from "../github/api-telemetry.mjs";
2
+ import { composeQuotaWarning } from "../quota-budgets.mjs";
3
+ import { evaluateWorktreeGraphqlQuotaWarning } from "../state/graphql-quota-warnings.mjs";
4
+ /** Claim GraphQL and REST core warnings separately, then present one result. */
5
+ export async function selectQuotaWarning(key, bands, usage, persist) {
6
+ const core = usage.rest?.find((item) => item.resource === "core");
7
+ const graphqlWarning = usage.graphql
8
+ ? await evaluateWorktreeGraphqlQuotaWarning(key, bands, withGraphqlCredentialFingerprint(usage.graphql), persist)
9
+ : undefined;
10
+ const coreWarning = core
11
+ ? await evaluateWorktreeGraphqlQuotaWarning(key, bands, withGraphqlCredentialFingerprint(core), persist)
12
+ : undefined;
13
+ return composeQuotaWarning({
14
+ graphqlWarning,
15
+ coreWarning,
16
+ graphql: usage.graphql,
17
+ core,
18
+ bands,
19
+ });
20
+ }
@@ -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
@@ -0,0 +1,8 @@
1
+ import { type StateKey } from "../state/rest-cache.mts";
2
+ import type { CheckAnnotation } from "../types.mts";
3
+ export interface AnnotationCacheOptions {
4
+ stateKey: StateKey;
5
+ headSha?: string;
6
+ }
7
+ export declare function readFreshCheckAnnotations(checkRunId: string, cacheOpts: AnnotationCacheOptions | undefined): Promise<CheckAnnotation[] | undefined>;
8
+ export declare function storeCheckAnnotations(checkRunId: string, annotations: CheckAnnotation[], cacheOpts: AnnotationCacheOptions | undefined): Promise<void>;