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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- 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/iterate-branch-segment.d.mts +3 -0
- package/bin/cli/iterate-branch-segment.mjs +13 -0
- package/bin/cli/iterate-formatter.mjs +5 -13
- package/bin/cli/iterate-lean.mjs +1 -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/check.mjs +1 -0
- 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/conflicting-head-ci.d.mts +18 -0
- package/bin/commands/iterate/conflicting-head-ci.mjs +32 -0
- package/bin/commands/iterate/fix-code.d.mts +2 -0
- package/bin/commands/iterate/fix-code.mjs +61 -16
- package/bin/commands/iterate/index.mjs +22 -8
- package/bin/commands/iterate/native-stack-rebase.d.mts +8 -2
- package/bin/commands/iterate/native-stack-rebase.mjs +14 -4
- package/bin/commands/iterate/stack-trunk-conflict.d.mts +13 -0
- package/bin/commands/iterate/stack-trunk-conflict.mjs +51 -0
- package/bin/commands/poll-quota.d.mts +2 -0
- package/bin/commands/poll-quota.mjs +25 -12
- 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 +21 -9
- package/bin/commands/poll.mjs +22 -17
- package/bin/config/load.d.mts +2 -0
- package/bin/config/load.mjs +24 -2
- package/bin/config.json +1 -0
- package/bin/github/batch-parse-suites.d.mts +2 -0
- package/bin/github/batch-parse-suites.mjs +5 -0
- package/bin/github/batch.d.mts +2 -0
- package/bin/github/batch.mjs +2 -1
- 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/gql/upper-layer-conflict-target.gql +36 -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 +11 -0
- package/bin/github/queries.mjs +11 -0
- package/bin/state/check-blockers.d.mts +30 -0
- package/bin/state/check-blockers.mjs +88 -0
- package/bin/state/conflicting-head-seen.d.mts +13 -0
- package/bin/state/conflicting-head-seen.mjs +44 -0
- package/bin/types/iterate.d.mts +5 -0
- package/bin/types/report.d.mts +2 -0
- 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
|
@@ -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:
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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,
|
|
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"
|
|
@@ -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);
|
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
|
@@ -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)
|
package/bin/github/batch.d.mts
CHANGED
|
@@ -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
|
/**
|
package/bin/github/batch.mjs
CHANGED
|
@@ -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
|
}
|