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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/bin/cli/api-usage-formatter.mjs +17 -3
- package/bin/cli/args.mjs +3 -0
- package/bin/cli/check-blocker-handler.d.mts +2 -0
- package/bin/cli/check-blocker-handler.mjs +82 -0
- package/bin/cli/help-command-pages.d.mts +22 -3
- package/bin/cli/help-command-pages.mjs +21 -2
- package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.mjs +1 -1
- package/bin/cli/help-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +3 -1
- package/bin/cli/help.d.mts +23 -4
- package/bin/cli/help.mjs +2 -0
- package/bin/cli/poll-handler.mjs +5 -1
- package/bin/cli-parser.mjs +4 -0
- package/bin/commands/apply-check-blocker.d.mts +22 -0
- package/bin/commands/apply-check-blocker.mjs +36 -0
- package/bin/commands/check-annotations.d.mts +1 -1
- package/bin/commands/check-annotations.mjs +27 -17
- package/bin/commands/check-blocker-ref.d.mts +3 -0
- package/bin/commands/check-blocker-ref.mjs +50 -0
- package/bin/commands/iterate/api-usage.mjs +5 -4
- package/bin/commands/iterate/check-blocker-gate.d.mts +18 -0
- package/bin/commands/iterate/check-blocker-gate.mjs +115 -0
- package/bin/commands/iterate/check-instructions.d.mts +2 -0
- package/bin/commands/iterate/check-instructions.mjs +4 -0
- package/bin/commands/iterate/fix-code.d.mts +2 -0
- package/bin/commands/iterate/fix-code.mjs +31 -15
- package/bin/commands/iterate/index.mjs +22 -8
- package/bin/commands/poll-quota.d.mts +20 -6
- package/bin/commands/poll-quota.mjs +70 -19
- package/bin/commands/poll-rate-limit-cancel.d.mts +35 -0
- package/bin/commands/poll-rate-limit-cancel.mjs +101 -0
- package/bin/commands/poll-rate-limit-delay.d.mts +1 -0
- package/bin/commands/poll-rate-limit-delay.mjs +7 -0
- package/bin/commands/poll-rate-limit-wait.d.mts +14 -0
- package/bin/commands/poll-rate-limit-wait.mjs +125 -0
- package/bin/commands/poll-summary.mjs +22 -10
- package/bin/commands/poll.mjs +24 -19
- package/bin/commands/quota-selection.d.mts +7 -0
- package/bin/commands/quota-selection.mjs +20 -0
- package/bin/config/load.d.mts +2 -0
- package/bin/config/load.mjs +24 -2
- package/bin/config.json +1 -0
- package/bin/github/check-annotation-cache.d.mts +8 -0
- package/bin/github/check-annotation-cache.mjs +23 -0
- package/bin/github/check-annotation-pages.d.mts +11 -0
- package/bin/github/check-annotation-pages.mjs +36 -0
- package/bin/github/check-annotation-shape.d.mts +21 -0
- package/bin/github/check-annotation-shape.mjs +50 -0
- package/bin/github/check-annotations-batch.d.mts +17 -0
- package/bin/github/check-annotations-batch.mjs +92 -0
- package/bin/github/check-annotations.d.mts +8 -11
- package/bin/github/check-annotations.mjs +14 -103
- package/bin/github/gql/batch-pr.gql +1 -1
- package/bin/github/gql/check-run-annotations-batch.gql +41 -0
- package/bin/github/pagination.d.mts +1 -1
- package/bin/github/pagination.mjs +1 -1
- package/bin/github/poll-summary-check-blockers.d.mts +16 -0
- package/bin/github/poll-summary-check-blockers.mjs +25 -0
- package/bin/github/poll-summary-checks.d.mts +2 -0
- package/bin/github/poll-summary-checks.mjs +35 -20
- package/bin/github/poll-summary-projector.mjs +5 -0
- package/bin/github/queries.d.mts +6 -0
- package/bin/github/queries.mjs +6 -0
- package/bin/quota-budgets.d.mts +16 -0
- package/bin/quota-budgets.mjs +65 -0
- package/bin/quota-warning.mjs +11 -1
- package/bin/state/check-blockers.d.mts +30 -0
- package/bin/state/check-blockers.mjs +88 -0
- package/bin/state/graphql-quota-policy.mjs +1 -1
- package/bin/state/graphql-quota-warnings.d.mts +2 -2
- package/bin/state/graphql-quota-warnings.mjs +5 -4
- package/bin/types/api-usage.d.mts +14 -3
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
|
28
|
+
export function pollRateLimitRetryAfterMs(err) {
|
|
23
29
|
if (!(err instanceof GitHubRequestError))
|
|
24
30
|
return null;
|
|
25
|
-
const
|
|
26
|
-
err.
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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()
|
|
89
|
+
const usage = summarizeApiTelemetry();
|
|
41
90
|
const [owner, repo] = result.repo.split("/");
|
|
42
91
|
if (!usage || !owner || !repo)
|
|
43
92
|
return undefined;
|
|
44
|
-
|
|
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
|
-
})),
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
:
|
|
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
|
}
|
package/bin/commands/poll.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|
package/bin/config/load.d.mts
CHANGED
|
@@ -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;
|
package/bin/config/load.mjs
CHANGED
|
@@ -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([
|
|
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
|
@@ -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>;
|