mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 01:45:00 +00:00
Stop PR status polling from rate-limiting your entire GitHub account (#3825)
* Batch GitHub PR status polls into aliased GraphQL requests The PR status poller ran one `gh pr view` (GraphQL-backed) plus a per-PR facts query for every workspace on its own timer, so cost scaled linearly with workspace count and exhausted the 5,000 pts/hour GraphQL quota machine-wide past ~50 workspaces (#3587, #2470). Replace the per-target timers with a global scheduler that snaps poll targets to a shared 5s grid and flushes all due targets together: - One aliased discovery query per batch of up to 25 targets (grouped by GH host) resolves PR identity, state, mergeability, and the merge facts that previously required a second GraphQL call per PR. - A second aliased query fetches statusCheckRollup contexts only for the PRs that matched. Checks for merged/closed PRs are frozen and cached by repo#number@headOid, so idle workspaces settle to the discovery query alone. - A failed batch backs off every target in it with one request spent; healthy aliases are recovered from the body gh still prints on stdout when a single alias errors (ForgeCommandError now carries stdout for this). - Targets whose origin slug/host is still resolving, and fork repos whose PR lives in the parent, fall back to the legacy per-target path for that cycle. Unowned lookups filter out other forks' PRs that share a branch name. Fixes #3587. Addresses the API budget in #2470. * fix(server): reserve the shared GitHub polling budget --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
3d69b0f458
commit
a1ed5a8bad
@@ -218,3 +218,8 @@ are stale, run `npm run build:server`.
|
||||
(`manual`) from blocking jobs (`manual` plus `action_required`). A failed
|
||||
allowed-failure job carries the `warning` trait, and `canceling` remains active
|
||||
until GitLab reports a terminal status.
|
||||
- GitHub PR polling owns one account-wide GraphQL budget. Coordinate retained
|
||||
targets per host, batch their reads, and stop until GitHub's reset time when
|
||||
the reserve is exhausted. Never add a per-target GitHub request to the poll
|
||||
path. Resolve fork PRs through their parent repository without abandoning the
|
||||
shared batch.
|
||||
|
||||
@@ -151,6 +151,12 @@ export interface ForgeCommandFailureParams {
|
||||
cwd: string;
|
||||
exitCode: number | null;
|
||||
stderr: string;
|
||||
/**
|
||||
* Whatever the failing command printed to stdout. GraphQL batch callers use
|
||||
* this to salvage partial `data` when one aliased sub-query errors but the
|
||||
* others succeeded (the CLI exits non-zero yet still prints the body).
|
||||
*/
|
||||
stdout?: string;
|
||||
}
|
||||
|
||||
export class ForgeCommandError extends Error {
|
||||
@@ -159,6 +165,7 @@ export class ForgeCommandError extends Error {
|
||||
readonly cwd: string;
|
||||
readonly exitCode: number | null;
|
||||
readonly stderr: string;
|
||||
readonly stdout?: string;
|
||||
|
||||
constructor(label: { brand: string; binary: string }, params: ForgeCommandFailureParams) {
|
||||
super(`${label.brand} CLI command failed: ${label.binary} ${params.args.join(" ")}`);
|
||||
@@ -166,6 +173,9 @@ export class ForgeCommandError extends Error {
|
||||
this.cwd = params.cwd;
|
||||
this.exitCode = params.exitCode;
|
||||
this.stderr = params.stderr;
|
||||
if (params.stdout !== undefined) {
|
||||
this.stdout = params.stdout;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +237,7 @@ export function normalizeCliCommandError(options: NormalizeCliCommandErrorOption
|
||||
return options.createMissingError();
|
||||
}
|
||||
const stderr = bufferOrStringToString(failure.stderr);
|
||||
const stdout = bufferOrStringToString(failure.stdout);
|
||||
const message = failure.message ?? "";
|
||||
if (options.isAuthFailureText(stderr) || options.isAuthFailureText(message)) {
|
||||
return options.createAuthError(stderr);
|
||||
@@ -239,6 +250,7 @@ export function normalizeCliCommandError(options: NormalizeCliCommandErrorOption
|
||||
stderr:
|
||||
stderr ||
|
||||
`${options.commandName} was terminated before completing (timed out after ${options.timeoutMs}ms or exceeded the output limit)`,
|
||||
...(stdout ? { stdout } : {}),
|
||||
});
|
||||
}
|
||||
return options.createCommandError({
|
||||
@@ -246,6 +258,7 @@ export function normalizeCliCommandError(options: NormalizeCliCommandErrorOption
|
||||
cwd: options.cwd,
|
||||
exitCode: typeof failure.code === "number" ? failure.code : null,
|
||||
stderr: stderr || message,
|
||||
...(stdout ? { stdout } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -241,6 +241,76 @@ function currentPullRequestGithubFactsJson(overrides: Record<string, unknown> =
|
||||
});
|
||||
}
|
||||
|
||||
function batchPollPrNodeJson(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
number: 42,
|
||||
url: "https://github.com/acme/widgets/pull/42",
|
||||
title: "Widget PR",
|
||||
state: "MERGED",
|
||||
isDraft: false,
|
||||
baseRefName: "main",
|
||||
headRefName: "feat-a",
|
||||
headRefOid: "oid-42",
|
||||
mergedAt: "2026-08-01T00:00:00Z",
|
||||
mergeable: "UNKNOWN",
|
||||
reviewDecision: null,
|
||||
headRepositoryOwner: { login: "acme" },
|
||||
mergeStateStatus: "CLEAN",
|
||||
autoMergeRequest: null,
|
||||
viewerCanEnableAutoMerge: false,
|
||||
viewerCanDisableAutoMerge: false,
|
||||
viewerCanMergeAsAdmin: false,
|
||||
viewerCanUpdateBranch: false,
|
||||
isMergeQueueEnabled: false,
|
||||
isInMergeQueue: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function batchPollRepositoryJson(
|
||||
nodes: Array<Record<string, unknown>>,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
isFork: false,
|
||||
autoMergeAllowed: true,
|
||||
mergeCommitAllowed: true,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: true,
|
||||
viewerDefaultMergeMethod: "SQUASH",
|
||||
pullRequests: { nodes },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function batchPollChecksAliasJson(
|
||||
contexts: Array<Record<string, unknown>>,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
pullRequest: {
|
||||
commits: { nodes: [{ commit: { statusCheckRollup: { contexts: { nodes: contexts } } } }] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function batchPollStatusJson(aliases: Record<string, unknown>): string {
|
||||
return JSON.stringify({ data: aliases });
|
||||
}
|
||||
|
||||
function batchPollStatusWithRateLimitJson(input: {
|
||||
aliases: Record<string, unknown>;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
resetAt: string;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
data: {
|
||||
...input.aliases,
|
||||
rateLimit: { limit: input.limit, remaining: input.remaining, resetAt: input.resetAt },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createCurrentPullRequestStatus(
|
||||
overrides: Partial<CurrentPullRequestStatus> = {},
|
||||
): CurrentPullRequestStatus {
|
||||
@@ -976,6 +1046,677 @@ describe("ForgeService", () => {
|
||||
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("batches PR status polls across workspaces into one GraphQL request", async () => {
|
||||
let now = 0;
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusJson({
|
||||
t0: batchPollRepositoryJson([
|
||||
batchPollPrNodeJson({
|
||||
number: 41,
|
||||
url: "https://github.com/acme/widgets/pull/41",
|
||||
headRefName: "feat-a",
|
||||
headRefOid: "oid-a",
|
||||
}),
|
||||
]),
|
||||
t1: batchPollRepositoryJson([
|
||||
batchPollPrNodeJson({
|
||||
number: 52,
|
||||
url: "https://github.com/acme/gadgets/pull/52",
|
||||
headRefName: "feat-b",
|
||||
headRefOid: "oid-b",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
batchPollStatusJson({
|
||||
t0: batchPollChecksAliasJson([
|
||||
{ __typename: "StatusContext", context: "ci", state: "SUCCESS" },
|
||||
]),
|
||||
t1: batchPollChecksAliasJson([
|
||||
{ __typename: "StatusContext", context: "ci", state: "SUCCESS" },
|
||||
]),
|
||||
}),
|
||||
batchPollStatusJson({
|
||||
t0: batchPollRepositoryJson([
|
||||
batchPollPrNodeJson({
|
||||
number: 41,
|
||||
url: "https://github.com/acme/widgets/pull/41",
|
||||
headRefName: "feat-a",
|
||||
headRefOid: "oid-a",
|
||||
}),
|
||||
]),
|
||||
t1: batchPollRepositoryJson([
|
||||
batchPollPrNodeJson({
|
||||
number: 52,
|
||||
url: "https://github.com/acme/gadgets/pull/52",
|
||||
headRefName: "feat-b",
|
||||
headRefOid: "oid-b",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]);
|
||||
const slugs: Record<string, string> = { "/ws-a": "acme/widgets", "/ws-b": "acme/gadgets" };
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async (cwd) => slugs[cwd] ?? null,
|
||||
now: () => now,
|
||||
});
|
||||
const statusesA: Array<CurrentPullRequestStatus | null> = [];
|
||||
const statusesB: Array<CurrentPullRequestStatus | null> = [];
|
||||
|
||||
const subscriptionA = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
headSha: "oid-a",
|
||||
onStatus: (status) => statusesA.push(status),
|
||||
});
|
||||
const subscriptionB = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-b",
|
||||
headRef: "feat-b",
|
||||
headSha: "oid-b",
|
||||
onStatus: (status) => statusesB.push(status),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(0);
|
||||
expect(runner.calls).toHaveLength(2);
|
||||
expect(runner.calls[0]?.args.slice(0, 2)).toEqual(["api", "graphql"]);
|
||||
expect(runner.calls[0]?.args[3]).toContain('t0: repository(owner: "acme", name: "widgets")');
|
||||
expect(runner.calls[0]?.args[3]).toContain('t1: repository(owner: "acme", name: "gadgets")');
|
||||
expect(runner.calls[0]?.args[3]).toContain('headRefName: "feat-a"');
|
||||
expect(runner.calls[0]?.args[3]).toContain('headRefName: "feat-b"');
|
||||
expect(runner.calls[1]?.args[3]).toContain("pullRequest(number: 41)");
|
||||
expect(runner.calls[1]?.args[3]).toContain("pullRequest(number: 52)");
|
||||
expect(statusesA).toEqual([
|
||||
expect.objectContaining({
|
||||
number: 41,
|
||||
state: "merged",
|
||||
isMerged: true,
|
||||
checksStatus: "success",
|
||||
forgeSpecific: expect.objectContaining({ forge: "github", mergeStateStatus: "CLEAN" }),
|
||||
}),
|
||||
]);
|
||||
expect(statusesB).toEqual([
|
||||
expect.objectContaining({ number: 52, state: "merged", checksStatus: "success" }),
|
||||
]);
|
||||
|
||||
// Merged PR checks are frozen: the second cycle reuses the cached rollup
|
||||
// and issues only the discovery request.
|
||||
now = EXPECTED_GITHUB_SLOW_POLL_MS;
|
||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls).toHaveLength(3);
|
||||
expect(statusesA).toHaveLength(2);
|
||||
expect(statusesA[1]).toEqual(expect.objectContaining({ number: 41, checksStatus: "success" }));
|
||||
|
||||
subscriptionA?.unsubscribe();
|
||||
subscriptionB?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("refetches checks for open PRs on every batched poll at fast cadence", async () => {
|
||||
let now = 0;
|
||||
const openNode = batchPollPrNodeJson({
|
||||
number: 7,
|
||||
url: "https://github.com/acme/widgets/pull/7",
|
||||
state: "OPEN",
|
||||
mergedAt: null,
|
||||
headRefName: "feat-a",
|
||||
headRefOid: "oid-open",
|
||||
});
|
||||
const pendingChecks = batchPollChecksAliasJson([
|
||||
{ __typename: "StatusContext", context: "ci", state: "PENDING" },
|
||||
]);
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson([openNode]) }),
|
||||
batchPollStatusJson({ t0: pendingChecks }),
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson([openNode]) }),
|
||||
batchPollStatusJson({ t0: pendingChecks }),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "acme/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const statuses: Array<CurrentPullRequestStatus | null> = [];
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls).toHaveLength(2);
|
||||
expect(statuses).toEqual([
|
||||
expect.objectContaining({ number: 7, state: "open", checksStatus: "pending" }),
|
||||
]);
|
||||
|
||||
now = EXPECTED_GITHUB_FAST_POLL_MS;
|
||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls).toHaveLength(4);
|
||||
expect(statuses).toHaveLength(2);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("backs off all batched targets on a failed batch without per-target fallback calls", async () => {
|
||||
let now = 0;
|
||||
const runner = createScriptedRunner([{ error: new Error("network down") }]);
|
||||
const slugs: Record<string, string> = { "/ws-a": "acme/widgets", "/ws-b": "acme/gadgets" };
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async (cwd) => slugs[cwd] ?? null,
|
||||
now: () => now,
|
||||
});
|
||||
const errors: unknown[] = [];
|
||||
|
||||
const subscriptionA = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
const subscriptionB = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-b",
|
||||
headRef: "feat-b",
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls).toHaveLength(1);
|
||||
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(0);
|
||||
expect(errors).toHaveLength(2);
|
||||
|
||||
subscriptionA?.unsubscribe();
|
||||
subscriptionB?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("falls back to the per-target lookup when a fork repository has no matching PR", async () => {
|
||||
let now = 0;
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson([], { isFork: true }) }),
|
||||
currentPullRequestJson({ headRefName: "feat-a" }),
|
||||
currentPullRequestGithubFactsJson(),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "forkowner/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const statuses: Array<CurrentPullRequestStatus | null> = [];
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls[0]?.args.slice(0, 2)).toEqual(["api", "graphql"]);
|
||||
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
|
||||
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("redirects a fork poll to its parent and reuses that batch address", async () => {
|
||||
let now = 0;
|
||||
const parent = { owner: { login: "upstream" }, name: "widgets" };
|
||||
const forkPr = batchPollPrNodeJson({
|
||||
url: "https://github.com/upstream/widgets/pull/42",
|
||||
headRefName: "feat-a",
|
||||
headRefOid: "oid-a",
|
||||
headRepositoryOwner: { login: "forkowner" },
|
||||
});
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusJson({
|
||||
t0: batchPollRepositoryJson([], { isFork: true, parent }),
|
||||
}),
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson([forkPr]) }),
|
||||
batchPollStatusJson({
|
||||
t0: batchPollChecksAliasJson([
|
||||
{ __typename: "StatusContext", context: "ci", state: "SUCCESS" },
|
||||
]),
|
||||
}),
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson([forkPr]) }),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "forkowner/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const statuses: Array<CurrentPullRequestStatus | null> = [];
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
headSha: "oid-a",
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(0);
|
||||
expect(runner.calls[0]?.args[3]).toContain(
|
||||
't0: repository(owner: "forkowner", name: "widgets")',
|
||||
);
|
||||
expect(runner.calls[1]?.args[3]).toContain(
|
||||
't0: repository(owner: "upstream", name: "widgets")',
|
||||
);
|
||||
expect(statuses).toEqual([
|
||||
expect.objectContaining({ number: 42, repoOwner: "upstream", checksStatus: "success" }),
|
||||
]);
|
||||
|
||||
now = EXPECTED_GITHUB_SLOW_POLL_MS;
|
||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls[3]?.args[3]).toContain(
|
||||
't0: repository(owner: "upstream", name: "widgets")',
|
||||
);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("treats a missing pullRequests connection as a failed alias", async () => {
|
||||
let now = 0;
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusJson({
|
||||
t0: { isFork: false, autoMergeAllowed: true },
|
||||
}),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "acme/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const statuses: Array<CurrentPullRequestStatus | null> = [];
|
||||
const errors: unknown[] = [];
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
onStatus: (status) => statuses.push(status),
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(statuses).toEqual([]);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toBeInstanceOf(GitHubCommandError);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("treats a malformed checks connection as a failed alias", async () => {
|
||||
let now = 0;
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusJson({
|
||||
t0: batchPollRepositoryJson([batchPollPrNodeJson({ state: "OPEN", mergedAt: null })]),
|
||||
}),
|
||||
batchPollStatusJson({ t0: { pullRequest: {} } }),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "acme/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const statuses: Array<CurrentPullRequestStatus | null> = [];
|
||||
const errors: unknown[] = [];
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
onStatus: (status) => statuses.push(status),
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(statuses).toEqual([]);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toBeInstanceOf(GitHubCommandError);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("pauses a host until reset when a batch reaches the GraphQL reserve", async () => {
|
||||
let now = 0;
|
||||
const resetAt = new Date(EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS).toISOString();
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusWithRateLimitJson({
|
||||
aliases: { t0: batchPollRepositoryJson([batchPollPrNodeJson()]) },
|
||||
limit: 5_000,
|
||||
remaining: 2_000,
|
||||
resetAt,
|
||||
}),
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson([]) }),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "acme/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const errors: unknown[] = [];
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls).toHaveLength(1);
|
||||
expect(runner.calls[0]?.args[3]).toContain("rateLimit");
|
||||
expect(errors).toHaveLength(1);
|
||||
|
||||
now = EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS;
|
||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS);
|
||||
await flushMicrotasks();
|
||||
expect(runner.calls).toHaveLength(1);
|
||||
|
||||
now += 1_000;
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await flushMicrotasks();
|
||||
expect(runner.calls).toHaveLength(2);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("loads the reset time once when GitHub rejects a batch for rate limiting", async () => {
|
||||
let now = 0;
|
||||
const rateLimitError = Object.assign(new Error("gh exited 1"), {
|
||||
code: 1,
|
||||
stderr: "GraphQL: API rate limit already exceeded for user ID 123",
|
||||
});
|
||||
const runner = createScriptedRunner([
|
||||
{ error: rateLimitError },
|
||||
JSON.stringify({ resources: { graphql: { remaining: 0, reset: 300 } } }),
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson([]) }),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "acme/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls.map((call) => call.args.slice(0, 2))).toEqual([
|
||||
["api", "graphql"],
|
||||
["api", "rate_limit"],
|
||||
]);
|
||||
|
||||
now = EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS;
|
||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS);
|
||||
await flushMicrotasks();
|
||||
expect(runner.calls).toHaveLength(2);
|
||||
|
||||
now += 1_000;
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await flushMicrotasks();
|
||||
expect(runner.calls).toHaveLength(3);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("keeps refetching checks for a closed PR while its checks are still pending", async () => {
|
||||
let now = 0;
|
||||
const closedNode = batchPollPrNodeJson({
|
||||
number: 9,
|
||||
url: "https://github.com/acme/widgets/pull/9",
|
||||
state: "CLOSED",
|
||||
mergedAt: null,
|
||||
headRefName: "feat-a",
|
||||
headRefOid: "oid-closed",
|
||||
});
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson([closedNode]) }),
|
||||
batchPollStatusJson({
|
||||
t0: batchPollChecksAliasJson([
|
||||
{ __typename: "StatusContext", context: "ci", state: "PENDING" },
|
||||
]),
|
||||
}),
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson([closedNode]) }),
|
||||
batchPollStatusJson({
|
||||
t0: batchPollChecksAliasJson([
|
||||
{ __typename: "StatusContext", context: "ci", state: "SUCCESS" },
|
||||
]),
|
||||
}),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "acme/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const statuses: Array<CurrentPullRequestStatus | null> = [];
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
headSha: "oid-closed",
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls).toHaveLength(2);
|
||||
expect(statuses).toEqual([expect.objectContaining({ number: 9, checksStatus: "pending" })]);
|
||||
|
||||
// Pending checks are never frozen, so the next cycle re-runs the checks
|
||||
// query and observes the completed run.
|
||||
now = EXPECTED_GITHUB_FAST_POLL_MS;
|
||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls).toHaveLength(4);
|
||||
expect(statuses[1]).toEqual(expect.objectContaining({ number: 9, checksStatus: "success" }));
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("falls back to the per-target lookup when the batch candidate page is full", async () => {
|
||||
let now = 0;
|
||||
const crowdedNodes = Array.from({ length: 10 }, (_, index) =>
|
||||
batchPollPrNodeJson({
|
||||
number: 100 + index,
|
||||
url: `https://github.com/acme/widgets/pull/${100 + index}`,
|
||||
state: "OPEN",
|
||||
mergedAt: null,
|
||||
headRefName: "feat-a",
|
||||
headRepositoryOwner: { login: "stranger" },
|
||||
}),
|
||||
);
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson(crowdedNodes) }),
|
||||
currentPullRequestJson({ headRefName: "feat-a" }),
|
||||
currentPullRequestGithubFactsJson(),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "acme/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const statuses: Array<CurrentPullRequestStatus | null> = [];
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
// Ten same-named fork PRs filled the page, so absence of a self-owned PR
|
||||
// is inconclusive and the target resolves through the legacy path.
|
||||
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
|
||||
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("ignores another fork owner's same-named branch in batched polls without an owner hint", async () => {
|
||||
let now = 0;
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusJson({
|
||||
t0: batchPollRepositoryJson([
|
||||
batchPollPrNodeJson({
|
||||
number: 900,
|
||||
url: "https://github.com/acme/widgets/pull/900",
|
||||
state: "OPEN",
|
||||
mergedAt: null,
|
||||
headRefName: "main",
|
||||
headRepositoryOwner: { login: "stranger" },
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "acme/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const statuses: Array<CurrentPullRequestStatus | null> = [];
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "main",
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls).toHaveLength(1);
|
||||
expect(statuses).toEqual([null]);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("recovers healthy aliases from a partially failed batch response", async () => {
|
||||
let now = 0;
|
||||
const partialBody = batchPollStatusJson({
|
||||
t0: batchPollRepositoryJson([
|
||||
batchPollPrNodeJson({
|
||||
number: 41,
|
||||
url: "https://github.com/acme/widgets/pull/41",
|
||||
headRefName: "feat-a",
|
||||
headRefOid: "oid-a",
|
||||
}),
|
||||
]),
|
||||
t1: null,
|
||||
});
|
||||
const runner = createScriptedRunner([
|
||||
{
|
||||
error: Object.assign(new Error("gh exited 1"), {
|
||||
code: 1,
|
||||
stdout: partialBody,
|
||||
stderr: "GraphQL: Could not resolve to a Repository",
|
||||
}),
|
||||
},
|
||||
batchPollStatusJson({
|
||||
t0: batchPollChecksAliasJson([
|
||||
{ __typename: "StatusContext", context: "ci", state: "SUCCESS" },
|
||||
]),
|
||||
}),
|
||||
]);
|
||||
const slugs: Record<string, string> = { "/ws-a": "acme/widgets", "/ws-b": "acme/missing" };
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async (cwd) => slugs[cwd] ?? null,
|
||||
now: () => now,
|
||||
});
|
||||
const statuses: Array<CurrentPullRequestStatus | null> = [];
|
||||
const errors: unknown[] = [];
|
||||
|
||||
const subscriptionA = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
headSha: "oid-a",
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
const subscriptionB = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-b",
|
||||
headRef: "feat-b",
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls).toHaveLength(2);
|
||||
expect(statuses).toEqual([
|
||||
expect.objectContaining({ number: 41, state: "merged", checksStatus: "success" }),
|
||||
]);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toBeInstanceOf(GitHubCommandError);
|
||||
|
||||
subscriptionA?.unsubscribe();
|
||||
subscriptionB?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("fetches PR reviews and issue comments with one GraphQL call sorted chronologically", async () => {
|
||||
const runner = createRunner([pullRequestTimelineJson()]);
|
||||
const service = createGitHubService({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user