authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-01 16:48:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-01 16:48:29-07:00
log10ac2f2ba495508a2a2fd469e67eda5df73f0df1
treec2ec7bdd5a8e82c799509c03a1d478337e4389e8
parenta3abb5266080c15b2a675d7f92b517b567c427e2
signature Signed by PGP key B5690EEEBB952194

fix(server): background GitHub polling uses ~74% fewer calls with batched GraphQL (#14673)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

15 files changed, 1039 insertions(+), 213 deletions(-)

apps/server/src/git/GitManager.test.ts+26
......@@ -13,6 +13,7 @@ import * as Logger from "effect/Logger";
1313import * as Option from "effect/Option";
1414import * as PlatformError from "effect/PlatformError";
1515import * as References from "effect/References";
16import * as Result from "effect/Result";
1617import * as Schema from "effect/Schema";
1718import * as Scope from "effect/Scope";
1819import { TestClock } from "effect/testing";
......@@ -32,6 +33,7 @@ import {
3233 TextGenerationError,
3334} from "@t3tools/contracts";
3435import * as GitHubCli from "../sourceControl/GitHubCli.ts";
36import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts";
3537import * as GitLabCli from "../sourceControl/GitLabCli.ts";
3638import * as TextGeneration from "../textGeneration/TextGeneration.ts";
3739import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
......@@ -510,6 +512,30 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
510512 return {
511513 service: {
512514 execute,
515 // The fake answers the CLI shape, so batched lookups read it the way the fallback does.
516 listPullRequestsByHead: (input) =>
517 execute({
518 cwd: input.cwd,
519 args: [
520 "pr",
521 "list",
522 "--head",
523 input.headSelector,
524 "--state",
525 input.state,
526 "--limit",
527 String(input.limit),
528 "--json",
529 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
530 ],
531 }).pipe(
532 Effect.map((result) => {
533 const raw = result.stdout.trim();
534 if (raw.length === 0) return [];
535 const decoded = decodeGitHubPullRequestListJson(raw);
536 return Result.isSuccess(decoded) ? decoded.success : [];
537 }),
538 ),
513539 listOpenPullRequests: (input) =>
514540 execute({
515541 cwd: input.cwd,
apps/server/src/orchestration/PullRequestSyncReactor.test.ts+82
......@@ -754,6 +754,88 @@ describe("PullRequestSyncReactor", () => {
754754 ),
755755 );
756756
757 it.effect("reads a burst of requested links in one sweep", () =>
758 Effect.scoped(
759 Effect.gen(function* () {
760 yield* TestClock.setTime(Date.parse(NOW));
761 const reading = yield* Deferred.make<void>();
762 const release = yield* Deferred.make<void>();
763 const fixture = yield* makeHarness({
764 snapshot: makeSnapshot([]),
765 summary: (input) =>
766 Effect.gen(function* () {
767 if (input.number === 1) {
768 yield* Deferred.succeed(reading, undefined);
769 yield* Deferred.await(release);
770 }
771 return makeSummary(input);
772 }),
773 });
774 yield* Effect.gen(function* () {
775 const reactor = yield* startAndSweep(fixture);
776 yield* Ref.set(
777 fixture.snapshots,
778 makeSnapshot(
779 [1, 2, 3, 4].map((number) =>
780 makeThread(`thread-${number}`, { pullRequests: [makeLink(number)] }),
781 ),
782 ),
783 );
784 const request = (number: number) =>
785 reactor.requestSync({ host: "github.com", repository: "owner/repository", number });
786 yield* request(1);
787 yield* Deferred.await(reading);
788 yield* Effect.forEach([2, 3, 4], request, { discard: true });
789 yield* Deferred.succeed(release, undefined);
790 yield* reactor.drain;
791 // One sweep for the first link and one for the three that arrived while it read.
792 assert.strictEqual(yield* Queue.size(fixture.snapshotReads), 2);
793 assert.deepStrictEqual(
794 (yield* Ref.get(fixture.summaryCalls)).map((ref) => ref.number).toSorted(),
795 [1, 2, 3, 4],
796 );
797 }).pipe(Effect.provide(fixture.layer));
798 }),
799 ),
800 );
801
802 it.effect("skips the stack read for a pull request the summary places in no stack", () =>
803 Effect.scoped(
804 Effect.gen(function* () {
805 yield* TestClock.setTime(Date.parse(NOW));
806 const fixture = yield* makeHarness({
807 snapshot: makeSnapshot([
808 makeThread("unstacked", { pullRequests: [makeLink(1)] }),
809 makeThread("stacked", { pullRequests: [makeLink(2)] }),
810 makeThread("unknown", { pullRequests: [makeLink(3)] }),
811 ]),
812 summary: (input) =>
813 Effect.succeed(
814 makeSummary(
815 input,
816 input.number === 1
817 ? { stack: null }
818 : input.number === 2
819 ? { stack: { number: 9, position: 1, size: 2, base: "main" } }
820 : {},
821 ),
822 ),
823 });
824 yield* Effect.gen(function* () {
825 yield* startAndSweep(fixture);
826 assert.deepStrictEqual(
827 (yield* Ref.get(fixture.stackCalls)).map((ref) => ref.number).toSorted(),
828 [2, 3],
829 );
830 assert.deepStrictEqual(
831 (yield* Ref.get(fixture.syncCommands)).map((command) => command.number).toSorted(),
832 [1, 2, 3],
833 );
834 }).pipe(Effect.provide(fixture.layer));
835 }),
836 ),
837 );
838
757839 it.effect("polls open pull requests on settled threads every fifteen minutes", () =>
758840 Effect.scoped(
759841 Effect.gen(function* () {
apps/server/src/orchestration/PullRequestSyncReactor.ts+41-23
......@@ -133,6 +133,9 @@ export const make = Effect.gen(function* () {
133133 const lastSyncedAt = new Map<string, number>();
134134 const requested = new Map<string, number>();
135135 let requestGeneration = 0;
136 // Requested keys wait in `requested` for one queued sweep, so a burst of links (an agent
137 // linking dozens of pull requests) is read together and shares the summary batches.
138 let requestedSweepQueued = false;
136139 const retryStacks = new Set<string>();
137140
138141 const isDue = (key: string, entries: ReadonlyArray<LinkEntry>, nowMs: number): boolean => {
......@@ -151,7 +154,8 @@ export const make = Effect.gen(function* () {
151154 <E>(cause: Cause.Cause<E>): Effect.Effect<void, E> =>
152155 Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning(message, fields);
153156
154 const sweep = Effect.fn("PullRequestSyncReactor.sweep")(function* (requestedKey?: string) {
157 /** `requested` reads only keys asked for through `requestSync`; `all` is the periodic pass. */
158 const sweep = Effect.fn("PullRequestSyncReactor.sweep")(function* (scope: "all" | "requested") {
155159 const threads = yield* snapshots.listThreadsWithPullRequests();
156160 const now = yield* DateTime.now;
157161 const nowMs = DateTime.toEpochMillis(now);
......@@ -250,22 +254,31 @@ export const make = Effect.gen(function* () {
250254 retryStacks.has(key) ||
251255 entries.some(
252256 (entry) =>
253 entry.link.snapshot === null || !snapshotFieldsEqual(entry.link.snapshot, fields),
257 entry.link.snapshot === null ||
258 !snapshotFieldsEqual(entry.link.snapshot, fields) ||
259 (summary.stack !== undefined &&
260 (entry.link.stack?.number ?? null) !== (summary.stack?.number ?? null)),
254261 );
255 const fetchedStack = needsStack
256 ? yield* pullRequests.stack(ref, { includeDetails: false }).pipe(
257 Effect.map((stack) => ({
258 stack: stack === null ? null : ({ kind: "native", ...stack } as const),
259 })),
260 Effect.catchCauseIf(
261 (cause) => !Cause.hasInterruptsOnly(cause),
262 () =>
263 Effect.logWarning("pull request stack lookup failed", {
264 key,
265 }).pipe(Effect.as(null)),
266 ),
267 )
268 : null;
262 // A summary that says the pull request is in no stack, for links that hold none, already
263 // answers what the stack read would.
264 const knownUnstacked =
265 summary.stack === null && entries.every((entry) => entry.link.stack === null);
266 const fetchedStack = !needsStack
267 ? null
268 : knownUnstacked
269 ? { stack: null }
270 : yield* pullRequests.stack(ref, { includeDetails: false }).pipe(
271 Effect.map((stack) => ({
272 stack: stack === null ? null : ({ kind: "native", ...stack } as const),
273 })),
274 Effect.catchCauseIf(
275 (cause) => !Cause.hasInterruptsOnly(cause),
276 () =>
277 Effect.logWarning("pull request stack lookup failed", {
278 key,
279 }).pipe(Effect.as(null)),
280 ),
281 );
269282 if (needsStack) {
270283 if (fetchedStack === null) {
271284 retryStacks.add(key);
......@@ -296,7 +309,7 @@ export const make = Effect.gen(function* () {
296309 yield* Effect.forEach(
297310 groups,
298311 ([key, entries]) =>
299 (requestedKey === undefined || requestedKey === key) && isDue(key, entries, nowMs)
312 (scope === "all" || requested.has(key)) && isDue(key, entries, nowMs)
300313 ? syncGroup(key, entries).pipe(
301314 Effect.catchCause(logSkipped("pull request sync skipped", { key })),
302315 )
......@@ -307,8 +320,12 @@ export const make = Effect.gen(function* () {
307320 );
308321 });
309322
310 const worker = yield* makeDrainableWorker((key: string | undefined) =>
311 sweep(key).pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))),
323 const worker = yield* makeDrainableWorker((scope: "all" | "requested") =>
324 Effect.suspend(() => {
325 // Requests from here on queue another sweep; the ones already recorded are read by this.
326 if (scope === "requested") requestedSweepQueued = false;
327 return sweep(scope);
328 }).pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))),
312329 );
313330
314331 const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn(
......@@ -322,7 +339,7 @@ export const make = Effect.gen(function* () {
322339 );
323340 yield* forkParked(
324341 Effect.gen(function* () {
325 yield* worker.enqueue(undefined);
342 yield* worker.enqueue("all");
326343 yield* worker.drain;
327344 }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid),
328345 );
......@@ -330,9 +347,10 @@ export const make = Effect.gen(function* () {
330347
331348 const requestSync: PullRequestSyncReactor["Service"]["requestSync"] = (key) =>
332349 Effect.suspend(() => {
333 const syncKey = threadPullRequestKeyOf(key);
334 requested.set(syncKey, ++requestGeneration);
335 return worker.enqueue(syncKey);
350 requested.set(threadPullRequestKeyOf(key), ++requestGeneration);
351 if (requestedSweepQueued) return Effect.void;
352 requestedSweepQueued = true;
353 return worker.enqueue("requested");
336354 });
337355
338356 return { start, drain: worker.drain, requestSync } satisfies PullRequestSyncReactor["Service"];
apps/server/src/orchestration/ThreadPullRequestReactor.ts+3-1
......@@ -348,7 +348,9 @@ export const make = Effect.gen(function* () {
348348 }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))),
349349 ),
350350 ),
351 { concurrency: 8, discard: true },
351 // Wide enough that a sweep's GitHub branch lookups reach GitHubCli together and share one
352 // GraphQL document, instead of one `gh pr list` per branch.
353 { concurrency: 32, discard: true },
352354 );
353355 });
354356
apps/server/src/pullRequest/GitHubPullRequestCli.ts+6-1
......@@ -1821,7 +1821,12 @@ export const make = Effect.gen(function* () {
18211821 const batchable = entries.filter(
18221822 (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null,
18231823 );
1824 const query = buildPullRequestSummariesGraphQlQuery(batchable.map((entry) => entry.request));
1824 // Stack membership rides along where GitHub serves stacks, so the background sync can skip
1825 // the REST stack read for pull requests that are in none.
1826 const query = buildPullRequestSummariesGraphQlQuery(
1827 batchable.map((entry) => entry.request),
1828 first.request.host === "github.com",
1829 );
18251830 const batched =
18261831 query === null
18271832 ? Effect.succeed(new Map<number, GitHubPullRequestSummary>())
apps/server/src/pullRequest/PullRequestProvider.ts+5
......@@ -116,6 +116,11 @@ export interface ProviderChangeRequestSummary {
116116 readonly reviewDecision?: PullRequestReviewDecision | null | undefined;
117117 readonly checksState?: PullRequestChecksState | null | undefined;
118118 readonly mergeability?: PullRequestMergeability | undefined;
119 /**
120 * The host-native stack the pull request sits in, from the same read. Null when the host says
121 * it is in none; absent when the read did not ask.
122 */
123 readonly stack?: PullRequestStackMembership | null | undefined;
119124}
120125
121126/** One layer of a host-native stack, bottom to top order is the array's. */
apps/server/src/pullRequest/PullRequestService.ts+1
......@@ -1596,6 +1596,7 @@ export const make = Effect.gen(function* () {
15961596 ...(changeRequest.mergeability === undefined
15971597 ? {}
15981598 : { mergeability: changeRequest.mergeability }),
1599 ...(changeRequest.stack === undefined ? {} : { stack: changeRequest.stack }),
15991600 })),
16001601 );
16011602 }),
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+38
......@@ -1934,5 +1934,43 @@ describe("batched pull request summaries", () => {
19341934 mergeability: "unknown",
19351935 additions: 0,
19361936 });
1937 // The document did not ask about stacks, so the summary does not claim an answer.
1938 expect(decoded.success.get(0)).not.toHaveProperty("stack");
1939 });
1940
1941 it("reads stack membership only where the document asked for it", () => {
1942 expect(
1943 buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }], true),
1944 ).toContain("stack { number size baseRefName } stackEntry { position }");
1945 expect(
1946 buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }]),
1947 ).not.toContain("stack {");
1948 const pullRequest = (number: number, stack: Record<string, unknown>) => ({
1949 pullRequest: {
1950 number,
1951 title: "Stacked",
1952 url: `https://github.com/acme/web/pull/${number}`,
1953 headRefName: `feat/${number}`,
1954 baseRefName: "main",
1955 state: "OPEN",
1956 updatedAt: "2026-08-24T00:00:00Z",
1957 ...stack,
1958 },
1959 });
1960 const decoded = expectSuccess(
1961 decodePullRequestSummariesJson(
1962 JSON.stringify({
1963 data: {
1964 s0: pullRequest(7, { stack: null, stackEntry: null }),
1965 s1: pullRequest(8, {
1966 stack: { number: 3, size: 2, baseRefName: "main" },
1967 stackEntry: { position: 2 },
1968 }),
1969 },
1970 }),
1971 ),
1972 );
1973 expect(decoded.get(0)?.stack).toBeNull();
1974 expect(decoded.get(1)?.stack).toEqual({ number: 3, size: 2, base: "main", position: 2 });
19371975 });
19381976});
apps/server/src/pullRequest/gitHubPullRequestJson.ts+7-1
......@@ -1852,6 +1852,8 @@ export function decodePullRequestStatsJson(
18521852 * shape as the search row where the two overlap: the checks arrive as GitHub's one-word rollup
18531853 * rather than the whole check list `gh pr view` hands back, which is what keeps a batch cheap.
18541854 */
1855const STACK_MEMBERSHIP_SELECTION = "stack { number size baseRefName } stackEntry { position }";
1856
18551857const PULL_REQUEST_SUMMARY_SELECTION =
18561858 "number title url state isDraft mergeable reviewDecision additions deletions changedFiles " +
18571859 "updatedAt mergedAt closedAt headRefName baseRefName " +
......@@ -1865,6 +1867,7 @@ const PULL_REQUEST_SUMMARY_SELECTION =
18651867 */
18661868export function buildPullRequestSummariesGraphQlQuery(
18671869 changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>,
1870 includeStacks = false,
18681871): string | null {
18691872 if (changeRequests.length === 0) return null;
18701873 const selections: string[] = [];
......@@ -1874,7 +1877,7 @@ export function buildPullRequestSummariesGraphQlQuery(
18741877 if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null;
18751878 if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null;
18761879 selections.push(
1877 ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_SUMMARY_SELECTION} } }`,
1880 ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""} } }`,
18781881 );
18791882 }
18801883 return `query PullRequestSummaries {\n${selections.join("\n")}\n}`;
......@@ -1920,6 +1923,8 @@ export interface GitHubPullRequestSummary {
19201923 readonly reviewDecision: PullRequestReviewDecision | null;
19211924 readonly checksState: PullRequestChecksState | null;
19221925 readonly mergeability: PullRequestMergeability;
1926 /** Null when GitHub says the pull request is in no stack; absent when the read did not ask. */
1927 readonly stack?: PullRequestStackMembership | null;
19231928}
19241929
19251930/**
......@@ -1965,6 +1970,7 @@ export function decodePullRequestSummariesJson(
19651970 }),
19661971 ),
19671972 mergeability: toMergeability(pr.mergeable),
1973 ...(pr.stack === undefined ? {} : { stack: toStackMembership(pr) ?? null }),
19681974 });
19691975 }
19701976 return Result.succeed(summaries);
apps/server/src/sourceControl/GitHubCli.test.ts+317-14
......@@ -4,6 +4,7 @@ import * as TestClock from "effect/testing/TestClock";
44import * as Clock from "effect/Clock";
55import * as DateTime from "effect/DateTime";
66import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
78import * as Layer from "effect/Layer";
89import * as PlatformError from "effect/PlatformError";
910import * as Redacted from "effect/Redacted";
......@@ -31,13 +32,18 @@ const quotaOutput = (remaining = 5000, resetAt = "2099-01-01T00:00:00Z") =>
3132 JSON.stringify({ data: { rateLimit: { cost: 1, limit: 5000, remaining, resetAt } } }),
3233 );
3334
35const isBudgetReading = (input: VcsProcess.VcsProcessInput) =>
36 input.args[0] === "api" &&
37 input.args[1] === "graphql" &&
38 input.args.at(-1)?.includes("rateLimit");
39
3440const mockRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>();
3541
42// Budget readings are answered here, so `mockRun` sees only the commands under test.
3643const layer = GitHubCli.layer.pipe(
3744 Layer.provide(
3845 Layer.mock(VcsProcess.VcsProcess)({
39 run: (input) =>
40 input.args[1] === "rate_limit" ? Effect.succeed(quotaOutput()) : mockRun(input),
46 run: (input) => (isBudgetReading(input) ? Effect.succeed(quotaOutput()) : mockRun(input)),
4147 }),
4248 ),
4349);
......@@ -46,9 +52,9 @@ afterEach(() => {
4652 mockRun.mockReset();
4753});
4854
49it.effect("shares quota checks, preserves the reserve, and resumes after reset", () =>
55it.effect("reads the GraphQL budget once per window, preserves the reserve, and resumes", () =>
5056 Effect.gen(function* () {
51 let probes = 0;
57 let readings = 0;
5258 const commands: string[] = [];
5359 let remaining = 501;
5460 let resetAt = DateTime.formatIso(
......@@ -58,8 +64,8 @@ it.effect("shares quota checks, preserves the reserve, and resumes after reset",
5864 Effect.provideService(VcsProcess.VcsProcess, {
5965 run: (input) =>
6066 Effect.sync(() => {
61 if (input.args[1] === "rate_limit") {
62 probes++;
67 if (isBudgetReading(input)) {
68 readings++;
6369 assert.strictEqual(input.args[3], "enterprise.test");
6470 return quotaOutput(remaining, resetAt);
6571 }
......@@ -79,24 +85,320 @@ it.effect("shares quota checks, preserves the reserve, and resumes after reset",
7985 yield* read("list");
8086 const failure = yield* read("view").pipe(Effect.flip);
8187 assert.strictEqual(failure._tag, "GitHubCliRateLimitError");
82 assert.strictEqual(probes, 1);
8388 assert.deepStrictEqual(commands, ["pr list"]);
8489 yield* read("view").pipe(Effect.provideService(GitHubCli.AllowGitHubReserve, true));
8590 yield* gh.execute({ cwd: "/repo", args: ["pr", "merge", "1"] });
8691 assert.deepStrictEqual(commands, ["pr list", "pr view", "pr merge"]);
87 remaining = 0;
88 yield* TestClock.adjust("30 seconds");
89 yield* read("repo").pipe(Effect.flip);
90 assert.strictEqual(probes, 2);
91 yield* TestClock.adjust("30 seconds");
92 // One reading covers the whole window.
93 assert.strictEqual(readings, 1);
94 yield* TestClock.adjust("1 minute");
9295 remaining = 5000;
9396 resetAt = DateTime.formatIso(DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + 60_000));
9497 yield* Effect.all([read("list"), read("repo")], { concurrency: 2 });
95 assert.strictEqual(probes, 3);
98 assert.strictEqual(readings, 2);
9699 assert.deepStrictEqual(commands.slice(3).toSorted(), ["pr list", "repo view"]);
97100 }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))),
98101);
99102
103it.effect("reads the budget again at a near reset, and every ten minutes in a long window", () =>
104 Effect.gen(function* () {
105 let readings = 0;
106 const startedAt = yield* Clock.currentTimeMillis;
107 let resetAt = DateTime.formatIso(DateTime.makeUnsafe(startedAt + 10_000));
108 const gh = yield* GitHubCli.make.pipe(
109 Effect.provideService(VcsProcess.VcsProcess, {
110 run: (input) =>
111 Effect.sync(() => {
112 if (!isBudgetReading(input)) return processOutput("[]");
113 readings++;
114 return quotaOutput(5000, resetAt);
115 }),
116 }),
117 );
118 const read = gh.execute({ cwd: "/repo", args: ["pr", "list"] });
119 yield* read;
120 // The window resets ten seconds in, so the reading expires with it.
121 resetAt = DateTime.formatIso(DateTime.makeUnsafe(startedAt + 10_000 + 3_600_000));
122 yield* TestClock.adjust("10 seconds");
123 yield* read;
124 assert.strictEqual(readings, 2);
125 yield* TestClock.adjust("9 minutes");
126 yield* read;
127 assert.strictEqual(readings, 2);
128 yield* TestClock.adjust("1 minute");
129 yield* read;
130 assert.strictEqual(readings, 3);
131 }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))),
132);
133
134it.effect("reads anyway when the budget reading fails", () =>
135 Effect.gen(function* () {
136 const gh = yield* GitHubCli.make.pipe(
137 Effect.provideService(VcsProcess.VcsProcess, {
138 run: (input) =>
139 isBudgetReading(input)
140 ? Effect.fail(
141 new VcsProcessSpawnError({
142 operation: "GitHubCli.execute",
143 command: "gh",
144 cwd: "/gone",
145 cause: new Error("ENOENT"),
146 }),
147 )
148 : Effect.succeed(processOutput("[]")),
149 }),
150 );
151 const result = yield* gh.execute({ cwd: "/repo", args: ["pr", "list"] });
152 assert.strictEqual(result.stdout, "[]");
153 }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))),
154);
155
156describe("selectGitHubBaseRepository", () => {
157 const remotes = (...entries: ReadonlyArray<readonly [string, string]>) =>
158 entries
159 .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
160 .join("\n");
161 const select = (input: { remotes: string; resolved?: string }) =>
162 GitHubCli.selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input });
163
164 it("picks the repository gh reads without a prompt", () => {
165 assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), {
166 owner: "me",
167 name: "web",
168 });
169 const fork = remotes(
170 ["origin", "git@github.com:me/web.git"],
171 ["upstream", "https://github.com/Acme/Web.git"],
172 );
173 assert.deepStrictEqual(select({ remotes: fork }), { owner: "acme", name: "web" });
174 assert.deepStrictEqual(select({ remotes: fork, resolved: "remote.origin.gh-resolved base" }), {
175 owner: "me",
176 name: "web",
177 });
178 assert.deepStrictEqual(
179 select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }),
180 { owner: "acme", name: "other" },
181 );
182 // gh ranks remote names in any case.
183 assert.deepStrictEqual(
184 select({
185 remotes: remotes(
186 ["origin", "git@github.com:me/web.git"],
187 ["Upstream", "git@github.com:acme/web.git"],
188 ),
189 }),
190 { owner: "acme", name: "web" },
191 );
192 });
193
194 it("leaves gh to choose when it might weigh the remotes differently", () => {
195 for (const input of [
196 { remotes: "" },
197 {
198 remotes: remotes(["a", "git@github.com:me/web.git"], ["b", "git@github.com:acme/web.git"]),
199 },
200 {
201 remotes: remotes(
202 ["origin", "git@github.com:me/web.git"],
203 ["mirror", "git@gitlab.com:me/web.git"],
204 ),
205 },
206 { remotes: remotes(["origin", "git@github-work:me/web.git"]) },
207 {
208 remotes: remotes(
209 ["origin", "git@github.com:me/web.git"],
210 ["Origin", "git@github.com:acme/web.git"],
211 ),
212 },
213 {
214 remotes: remotes(
215 ["origin", "git@github.com:me/web.git"],
216 ["upstream", "git@github.com:acme/web.git"],
217 ),
218 resolved: "remote.origin.gh-resolved base\nremote.upstream.gh-resolved base",
219 },
220 ]) {
221 assert.strictEqual(select(input), null);
222 }
223 });
224});
225
226describe("GitHubCli.listPullRequestsByHead", () => {
227 const remoteOutput =
228 "origin\tgit@github.com:acme/web.git (fetch)\norigin\tgit@github.com:acme/web.git (push)\n";
229 const node = (number: number, headRefName: string) => ({
230 number,
231 title: `PR ${number}`,
232 url: `https://github.com/acme/web/pull/${number}`,
233 baseRefName: "main",
234 headRefName,
235 state: "MERGED",
236 mergedAt: "2026-01-01T00:00:00Z",
237 updatedAt: "2026-01-02T00:00:00Z",
238 headRepository: { name: "web", nameWithOwner: "acme/web" },
239 headRepositoryOwner: { login: "acme" },
240 });
241 const decodeRequest = Schema.decodeSync(
242 Schema.fromJsonString(
243 Schema.Struct({
244 query: Schema.String,
245 variables: Schema.Record(Schema.String, Schema.Unknown),
246 }),
247 ),
248 );
249 const jsonOutput = (value: unknown) => processOutput(JSON.stringify(value));
250 const git = (input: VcsProcess.VcsProcessInput) =>
251 input.args[0] === "remote"
252 ? processOutput(remoteOutput)
253 : { ...processOutput(""), exitCode: ChildProcessSpawner.ExitCode(1) };
254
255 it.effect("reads heads on one repository in one GraphQL document", () =>
256 Effect.gen(function* () {
257 const documents: Array<{ query: string; variables: Record<string, unknown> }> = [];
258 mockRun.mockImplementation((input) =>
259 Effect.sync(() => {
260 if (input.command === "git") return git(input);
261 documents.push(decodeRequest(input.stdin ?? ""));
262 return jsonOutput({
263 data: {
264 repository: { h0: { nodes: [node(7, "feature/a")] }, h1: { nodes: [] } },
265 rateLimit: { cost: 1, limit: 5000, remaining: 4999, resetAt: "2099-01-01T00:00:00Z" },
266 },
267 });
268 }),
269 );
270 const gh = yield* GitHubCli.GitHubCli;
271 const lookups = yield* Effect.all(
272 ["feature/a", "feature/b"].map((headSelector) =>
273 gh.listPullRequestsByHead({
274 cwd: "/repo",
275 headSelector,
276 state: "all",
277 limit: 100,
278 rateLimitHost: "github.com",
279 }),
280 ),
281 { concurrency: "unbounded" },
282 ).pipe(Effect.forkChild);
283 yield* TestClock.adjust("50 millis");
284 const [first, second] = yield* Fiber.join(lookups);
285 assert.deepStrictEqual(
286 first?.map((pr) => [pr.number, pr.state, pr.headRepositoryNameWithOwner]),
287 [[7, "merged", "acme/web"]],
288 );
289 assert.deepStrictEqual(second, []);
290 assert.strictEqual(documents.length, 1);
291 assert.include(documents[0]!.query, "rateLimit");
292 assert.deepStrictEqual(documents[0]!.variables, {
293 owner: "acme",
294 name: "web",
295 h0: "feature/a",
296 s0: ["OPEN", "CLOSED", "MERGED"],
297 h1: "feature/b",
298 s1: ["OPEN", "CLOSED", "MERGED"],
299 });
300 }).pipe(Effect.provide(layer)),
301 );
302
303 it.effect("asks gh pr list when gh could read another repository", () =>
304 Effect.gen(function* () {
305 const commands: Array<ReadonlyArray<string>> = [];
306 mockRun.mockImplementation((input) =>
307 Effect.sync(() => {
308 commands.push([input.command, ...input.args]);
309 if (input.command === "git") {
310 return processOutput(
311 input.args[0] === "remote"
312 ? "a\tgit@github.com:me/web.git (fetch)\nb\tgit@github.com:acme/web.git (fetch)\n"
313 : "",
314 );
315 }
316 return input.args[3] === "feature/empty"
317 ? processOutput("")
318 : jsonOutput([node(8, "feature/a")]);
319 }),
320 );
321 const gh = yield* GitHubCli.GitHubCli;
322 const pullRequests = yield* gh.listPullRequestsByHead({
323 cwd: "/repo",
324 headSelector: "feature/a",
325 state: "all",
326 limit: 100,
327 rateLimitHost: "github.com",
328 });
329 assert.deepStrictEqual(
330 pullRequests.map((pr) => pr.number),
331 [8],
332 );
333 assert.deepStrictEqual(commands.at(-1), [
334 "gh",
335 "pr",
336 "list",
337 "--head",
338 "feature/a",
339 "--state",
340 "all",
341 "--limit",
342 "100",
343 "--json",
344 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
345 ]);
346 const empty = yield* gh.listPullRequestsByHead({
347 cwd: "/repo",
348 headSelector: "feature/empty",
349 state: "all",
350 limit: 100,
351 rateLimitHost: "github.com",
352 });
353 assert.deepStrictEqual(empty, []);
354 }).pipe(Effect.provide(layer)),
355 );
356
357 it.effect("fails a rate-limited document whole instead of asking head by head", () =>
358 Effect.gen(function* () {
359 let ghCalls = 0;
360 mockRun.mockImplementation((input) => {
361 if (input.command === "git") return Effect.succeed(git(input));
362 ghCalls++;
363 return Effect.fail(
364 new VcsProcessExitError({
365 operation: "GitHubCli.execute",
366 command: "gh",
367 cwd: "/repo",
368 exitCode: 1,
369 failureKind: "rate-limited",
370 detail: "API rate limit exceeded.",
371 stderrLength: 24,
372 stderrTruncated: false,
373 }),
374 );
375 });
376 const gh = yield* GitHubCli.GitHubCli;
377 const lookups = yield* Effect.all(
378 ["feature/a", "feature/b"].map((headSelector) =>
379 gh
380 .listPullRequestsByHead({
381 cwd: "/repo",
382 headSelector,
383 state: "all",
384 limit: 100,
385 rateLimitHost: "github.com",
386 })
387 .pipe(Effect.flip),
388 ),
389 { concurrency: "unbounded" },
390 ).pipe(Effect.forkChild);
391 yield* TestClock.adjust("50 millis");
392 const errors = yield* Fiber.join(lookups);
393 assert.deepStrictEqual(
394 errors.map((error) => error._tag),
395 ["GitHubCliRateLimitError", "GitHubCliRateLimitError"],
396 );
397 assert.strictEqual(ghCalls, 1);
398 }).pipe(Effect.provide(layer)),
399 );
400});
401
100402describe("GitHubCli.layer", () => {
101403 it.effect("shares the registry budget with CLI reads through nested layer providers", () =>
102404 Effect.gen(function* () {
......@@ -116,8 +418,9 @@ describe("GitHubCli.layer", () => {
116418 Effect.provideService(VcsProcess.VcsProcess, {
117419 run: (input) =>
118420 Effect.sync(() => {
119 if (input.args[1] === "rate_limit")
421 if (isBudgetReading(input)) {
120422 return quotaOutput(input.env?.GH_TOKEN === "empty" ? 0 : 5000);
423 }
121424 reads++;
122425 return processOutput("[]");
123426 }),
apps/server/src/sourceControl/GitHubCli.ts+435-45
......@@ -1,4 +1,5 @@
11import * as Cache from "effect/Cache";
2import * as Clock from "effect/Clock";
23import * as Duration from "effect/Duration";
34import * as Exit from "effect/Exit";
45import * as Context from "effect/Context";
......@@ -8,6 +9,8 @@ import * as Layer from "effect/Layer";
89import * as Option from "effect/Option";
910import * as PlatformError from "effect/PlatformError";
1011import * as Redacted from "effect/Redacted";
12import * as Request from "effect/Request";
13import * as RequestResolver from "effect/RequestResolver";
1114import * as Result from "effect/Result";
1215import * as Schema from "effect/Schema";
1316
......@@ -16,11 +19,14 @@ import {
1619 type SourceControlRepositoryVisibility,
1720 type VcsError,
1821} from "@t3tools/contracts";
22import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
23import { decodeJsonResult } from "@t3tools/shared/schemaJson";
1924
2025import * as VcsProcess from "../vcs/VcsProcess.ts";
2126import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
2227import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
2328import {
29 decodeGitHubPullRequestEntries,
2430 decodeGitHubPullRequestJson,
2531 decodeGitHubPullRequestListJson,
2632 type NormalizedGitHubPullRequestRecord,
......@@ -300,6 +306,20 @@ export class GitHubCli extends Context.Service<
300306 readonly rateLimitHost?: string;
301307 }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>;
302308
309 /**
310 * Pull requests whose head is `headSelector`, in the repository `gh pr list` would read in
311 * `cwd`. Lookups on one repository that arrive together share one GraphQL document; a
312 * checkout whose repository gh could pick another way is asked through `gh pr list`.
313 */
314 readonly listPullRequestsByHead: (input: {
315 readonly cwd: string;
316 readonly headSelector: string;
317 readonly state: "open" | "closed" | "merged" | "all";
318 readonly limit: number;
319 /** The checkout's GitHub host. Without it the lookup is not batched. */
320 readonly rateLimitHost?: string;
321 }) => Effect.Effect<ReadonlyArray<NormalizedGitHubPullRequestRecord>, GitHubCliError>;
322
303323 readonly getPullRequest: (input: {
304324 readonly cwd: string;
305325 readonly reference: string;
......@@ -394,6 +414,149 @@ function deriveRepositoryCloneUrlsFromCreateOutput(
394414 };
395415}
396416
417type PullRequestListState = "open" | "closed" | "merged" | "all";
418
419const PULL_REQUEST_LIST_JSON_FIELDS =
420 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner";
421/** The `gh pr list --json` fields above, as GraphQL selects them. */
422const PULL_REQUEST_NODE_SELECTION =
423 "number title url baseRefName headRefName state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }";
424const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = {
425 open: ["OPEN"],
426 closed: ["CLOSED"],
427 merged: ["MERGED"],
428 all: ["OPEN", "CLOSED", "MERGED"],
429};
430/**
431 * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one
432 * `gh pr list`, but half that keeps each answer near half a second.
433 */
434const HEAD_LOOKUPS_PER_DOCUMENT = 50;
435/**
436 * How long a head lookup waits for company. Branch discovery reaches GitHub only after each
437 * branch's own git reads, so lookups started together arrive tens of milliseconds apart.
438 */
439const HEAD_LOOKUP_BATCH_WINDOW = "50 millis";
440/** A full document is 5,000 rows of well under 2 KB each. */
441const HEAD_LOOKUP_MAX_OUTPUT_BYTES = 16_000_000;
442const RATE_LIMIT_READING = "query { rateLimit { cost limit remaining resetAt } }";
443
444class PullRequestsByHeadRead extends Request.Class<
445 {
446 readonly cwd: string;
447 readonly host: string;
448 readonly owner: string;
449 readonly name: string;
450 readonly headRefName: string;
451 readonly state: PullRequestListState;
452 readonly limit: number;
453 },
454 ReadonlyArray<NormalizedGitHubPullRequestRecord>,
455 GitHubCliError
456> {}
457
458const GraphQlVariables = Schema.Record(
459 Schema.String,
460 Schema.Union([Schema.String, Schema.Array(Schema.String)]),
461);
462/** A GraphQL request body for `gh api graphql --input -`. */
463const encodeGraphQlRequest = Schema.encodeSync(
464 Schema.fromJsonString(Schema.Struct({ query: Schema.String, variables: GraphQlVariables })),
465);
466
467/** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */
468function buildPullRequestsByHeadQuery(
469 lookups: ReadonlyArray<Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit">>,
470): { readonly document: string; readonly variables: typeof GraphQlVariables.Type } {
471 const variables: Record<string, string | ReadonlyArray<string>> = {};
472 const declarations: string[] = ["$owner: String!", "$name: String!"];
473 const selections: string[] = [];
474 for (const [index, lookup] of lookups.entries()) {
475 variables[`h${index}`] = lookup.headRefName;
476 variables[`s${index}`] = GRAPHQL_STATES[lookup.state];
477 declarations.push(`$h${index}: String!`, `$s${index}: [PullRequestState!]`);
478 // `gh pr list` orders the same way, so a head with more matches than the limit keeps the
479 // same rows.
480 selections.push(
481 ` h${index}: pullRequests(headRefName: $h${index}, states: $s${index}, first: ${lookup.limit}, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${PULL_REQUEST_NODE_SELECTION} } }`,
482 );
483 }
484 return {
485 document: `query PullRequestsByHead(${declarations.join(", ")}) {\n repository(owner: $owner, name: $name) {\n${selections.join("\n")}\n }\n}`,
486 variables,
487 };
488}
489
490/** The reset time of a `rateLimit` reading, which sets when the next one is due. */
491const decodeRateLimitReading = (raw: string) =>
492 Result.map(
493 decodeJsonResult(
494 Schema.Struct({
495 data: Schema.Struct({ rateLimit: Schema.Struct({ resetAt: Schema.String }) }),
496 }),
497 )(raw),
498 (reading) => reading.data.rateLimit.resetAt,
499 );
500
501const decodePullRequestsByHead = decodeJsonResult(
502 Schema.Struct({
503 data: Schema.Struct({
504 repository: Schema.Record(
505 Schema.String,
506 Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })),
507 ),
508 }),
509 }),
510);
511
512/**
513 * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a
514 * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin
515 * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of
516 * `git config --get-regexp '^remote\..*\.gh-resolved$'`.
517 *
518 * Null whenever gh might weigh the remotes differently: a remote on another host or under an
519 * SSH alias, more than one mark, or several remotes with none of those names. Callers then
520 * ask gh itself.
521 */
522export function selectGitHubBaseRepository(input: {
523 readonly remotes: string;
524 readonly resolved: string;
525 readonly host: string;
526}): { readonly owner: string; readonly name: string } | null {
527 const host = input.host.toLowerCase();
528 const repositories = new Map<string, { readonly owner: string; readonly name: string }>();
529 for (const line of input.remotes.split("\n")) {
530 const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim());
531 if (!match) continue;
532 const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/");
533 if (remoteHost !== host || !owner || !name || rest.length > 0) return null;
534 repositories.set(match[1]!, { owner, name });
535 }
536 const marks = input.resolved
537 .split("\n")
538 .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim()))
539 .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!));
540 if (marks.length > 1) return null;
541 const [mark] = marks;
542 if (mark) {
543 if (mark[2] === "base") return repositories.get(mark[1]!) ?? null;
544 const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/");
545 return owner && name && rest.length === 0 ? { owner, name } : null;
546 }
547 // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the
548 // top place has no defined winner.
549 const score = (remoteName: string) =>
550 ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1;
551 const ranked = [...repositories.entries()].toSorted(
552 ([left], [right]) => score(right) - score(left),
553 );
554 const [top, next] = ranked;
555 return top !== undefined && (next === undefined || score(top[0]) > score(next[0]))
556 ? top[1]
557 : null;
558}
559
397560/** @public Service construction is part of the canonical Effect module API. */
398561export const make = Effect.gen(function* () {
399562 const process = yield* VcsProcess.VcsProcess;
......@@ -438,66 +601,86 @@ export const make = Effect.gen(function* () {
438601 },
439602 );
440603
441 const quota = yield* Cache.makeWith(
604 /**
605 * A GraphQL `rateLimit` reading per host and credential, so the budget knows the balance
606 * before reads that cannot report their own cost (`gh pr list`). GraphQL documents keep it
607 * current in between. Each reading costs one point and is redone at the reset, or after ten
608 * minutes so a `gh auth switch` is not priced against the old account for a whole hour.
609 */
610 const budgetReading = yield* Cache.makeWith(
442611 (key: string) => {
443612 const host = key.split("\0")[0]!;
444613 return executeRaw({
445614 cwd: globalThis.process.cwd(),
446 args: [
447 "api",
448 "rate_limit",
449 "--hostname",
450 host,
451 "--jq",
452 ".resources.graphql | {data:{rateLimit:{cost:1,limit:.limit,remaining:.remaining,resetAt:(.reset|todateiso8601)}}}",
453 ],
615 args: ["api", "graphql", "--hostname", host, "-f", `query=${RATE_LIMIT_READING}`],
454616 }).pipe(
455617 Effect.tap((result) => budget.observe(host, result.stdout)),
456 Effect.asVoid,
618 Effect.flatMap((result) =>
619 Clock.currentTimeMillis.pipe(
620 Effect.map((now) => {
621 const resetAtMs = Date.parse(
622 decodeRateLimitReading(result.stdout).pipe(
623 Result.match({ onFailure: () => "", onSuccess: (reading) => reading }),
624 ),
625 );
626 // A reset that is missing or already past (a skewed clock) waits a minute, so a
627 // bad reading cannot ask again on every read.
628 return Duration.millis(
629 Number.isFinite(resetAtMs) && resetAtMs > now
630 ? Math.min(resetAtMs - now, 600_000)
631 : 60_000,
632 );
633 }),
634 ),
635 ),
457636 );
458637 },
459638 {
460639 capacity: 32,
461 timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.seconds(30) : Duration.zero),
640 timeToLive: (exit) => (Exit.isSuccess(exit) ? exit.value : Duration.minutes(1)),
462641 },
463642 );
464 const execute: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.execute")(
465 function* (input) {
466 const [command, action] = input.args;
467 if (
468 !(
469 (command === "pr" && (action === "list" || action === "view")) ||
470 (command === "repo" && action === "view")
471 )
472 )
473 return yield* executeRaw(input);
643
644 /**
645 * Runs a GraphQL-priced read under its host's pause and the GraphQL budget. `run` receives
646 * the document with `rateLimit` added, so a GraphQL read can report what it spent; a CLI read
647 * ignores it and is priced at one point.
648 */
649 const guardedRead = <A>(input: {
650 readonly cwd: string;
651 readonly host: string;
652 readonly document: string;
653 readonly allowReserve: boolean;
654 readonly run: (document: string) => Effect.Effect<A, GitHubCliError>;
655 }) =>
656 Effect.gen(function* () {
474657 const credential = yield* PinnedGitHubCredential;
475 if (credential !== null && !targetsVerifiedHost(input.args, credential.host))
476 return yield* executeRaw(input);
477 const allowReserve = input.allowReserve ?? (yield* AllowGitHubReserve);
478 const host = (
479 credential?.host ??
480 commandHosts(input.args).find((host) => host !== null) ??
481 input.rateLimitHost ??
482 input.env?.GH_HOST ??
483 globalThis.process.env.GH_HOST ??
484 "github.com"
485 ).toLowerCase();
486 const key = { provider: "github" as const, host };
658 const key = { provider: "github" as const, host: input.host };
487659 const guarded = Effect.gen(function* () {
488 const lease = yield* limits.check(key, allowReserve ? { allowPaused: true } : undefined);
489 return yield* Effect.gen(function* () {
490 yield* Cache.get(quota, `${host}\0${credential?.credentialFingerprint ?? ""}`);
491 yield* budget.query(host, "query {}", allowReserve ? { allowReserve: true } : undefined);
492 return yield* executeRaw(input);
493 }).pipe(
494 Effect.tap(() => limits.recordSuccess({ ...key, lease })),
495 Effect.tapError((error) =>
496 error._tag === "GitHubCliRateLimitError"
497 ? limits.recordRateLimit({ ...key, lease })
498 : Effect.void,
499 ),
660 const lease = yield* limits.check(
661 key,
662 input.allowReserve ? { allowPaused: true } : undefined,
500663 );
664 // A failed reading leaves the budget unknown; it never blocks the read itself.
665 yield* Cache.get(
666 budgetReading,
667 `${input.host}\0${yield* SourceControlRateLimit.CredentialScope}`,
668 ).pipe(Effect.ignore);
669 return yield* budget
670 .query(
671 input.host,
672 input.document,
673 input.allowReserve ? { allowReserve: true } : undefined,
674 )
675 .pipe(
676 Effect.flatMap(input.run),
677 Effect.tap(() => limits.recordSuccess({ ...key, lease })),
678 Effect.tapError((error) =>
679 error._tag === "GitHubCliRateLimitError"
680 ? limits.recordRateLimit({ ...key, lease })
681 : Effect.void,
682 ),
683 );
501684 });
502685 return yield* guarded.pipe(
503686 Effect.provideService(
......@@ -516,11 +699,218 @@ export const make = Effect.gen(function* () {
516699 ),
517700 }),
518701 );
702 });
703
704 const execute: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.execute")(
705 function* (input) {
706 const [command, action] = input.args;
707 if (
708 !(
709 (command === "pr" && (action === "list" || action === "view")) ||
710 (command === "repo" && action === "view")
711 )
712 )
713 return yield* executeRaw(input);
714 const credential = yield* PinnedGitHubCredential;
715 if (credential !== null && !targetsVerifiedHost(input.args, credential.host))
716 return yield* executeRaw(input);
717 return yield* guardedRead({
718 cwd: input.cwd,
719 host: (
720 credential?.host ??
721 commandHosts(input.args).find((host) => host !== null) ??
722 input.rateLimitHost ??
723 input.env?.GH_HOST ??
724 globalThis.process.env.GH_HOST ??
725 "github.com"
726 ).toLowerCase(),
727 document: "query {}",
728 allowReserve: input.allowReserve ?? (yield* AllowGitHubReserve),
729 run: () => executeRaw(input),
730 });
731 },
732 );
733
734 const listPullRequestsWithCli = (input: {
735 readonly cwd: string;
736 readonly headSelector: string;
737 readonly state: PullRequestListState;
738 readonly limit: number;
739 readonly rateLimitHost?: string | undefined;
740 }) =>
741 execute({
742 cwd: input.cwd,
743 ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }),
744 args: [
745 "pr",
746 "list",
747 "--head",
748 input.headSelector,
749 "--state",
750 input.state,
751 "--limit",
752 String(input.limit),
753 "--json",
754 PULL_REQUEST_LIST_JSON_FIELDS,
755 ],
756 }).pipe(
757 Effect.flatMap((result) => {
758 const raw = result.stdout.trim();
759 if (raw.length === 0) return Effect.succeed([]);
760 const decoded = decodeGitHubPullRequestListJson(raw);
761 return Result.isSuccess(decoded)
762 ? Effect.succeed(decoded.success)
763 : Effect.fail(
764 new GitHubChangeRequestListDecodeError({
765 command: "gh",
766 cwd: input.cwd,
767 cause: decoded.failure,
768 }),
769 );
770 }),
771 );
772
773 const git = (cwd: string, args: ReadonlyArray<string>) =>
774 process.run({
775 operation: "GitHubCli.baseRepository",
776 command: "git",
777 args,
778 cwd,
779 allowNonZeroExit: true,
780 timeoutMs: 5_000,
781 });
782
783 /** The repository gh reads in `cwd`, or null when gh could pick it another way. */
784 const resolveBaseRepository = (cwd: string, host: string) =>
785 globalThis.process.env.GH_REPO
786 ? Effect.succeed(null)
787 : Effect.all([
788 git(cwd, ["remote", "-v"]),
789 git(cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]),
790 ]).pipe(
791 Effect.map(([remotes, resolved]) =>
792 remotes.exitCode === 0
793 ? selectGitHubBaseRepository({
794 remotes: remotes.stdout,
795 resolved: resolved.exitCode === 0 ? resolved.stdout : "",
796 host,
797 })
798 : null,
799 ),
800 Effect.orElseSucceed(() => null),
801 );
802
803 const headResolver = RequestResolver.makeGrouped<PullRequestsByHeadRead, string>({
804 key: ({ request, context }) =>
805 JSON.stringify([
806 request.host,
807 request.owner,
808 request.name,
809 Context.getOrElse(context, PinnedGitHubCredential, () => null)?.credentialFingerprint ??
810 null,
811 Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""),
812 ]),
813 resolver: (entries) => {
814 const [first] = entries;
815 const { cwd, host, owner, name } = first.request;
816 const query = buildPullRequestsByHeadQuery(entries.map((entry) => entry.request));
817 const readCli = (entry: (typeof entries)[number]) =>
818 listPullRequestsWithCli({
819 cwd: entry.request.cwd,
820 headSelector: entry.request.headRefName,
821 state: entry.request.state,
822 limit: entry.request.limit,
823 rateLimitHost: entry.request.host,
824 }).pipe(
825 Effect.exit,
826 Effect.map((exit) => entry.completeUnsafe(exit)),
827 );
828 return guardedRead({
829 cwd,
830 host,
831 document: query.document,
832 allowReserve: false,
833 run: (document) =>
834 executeRaw({
835 cwd,
836 args: ["api", "graphql", "--hostname", host, "--input", "-"],
837 // Up to 50 heads of 100 rows each. A default branch such as `main` can match a
838 // hundred fork pull requests, so the 1 MB default would cut the answer short.
839 maxOutputBytes: HEAD_LOOKUP_MAX_OUTPUT_BYTES,
840 stdin: encodeGraphQlRequest({
841 query: document,
842 variables: { owner, name, ...query.variables },
843 }),
844 }).pipe(Effect.tap((result) => budget.observe(host, result.stdout))),
845 }).pipe(
846 Effect.flatMap((result) => {
847 const decoded = decodePullRequestsByHead(result.stdout);
848 if (!Result.isSuccess(decoded)) {
849 return Effect.forEach(entries, readCli, { discard: true });
850 }
851 const aliases = decoded.success.data.repository;
852 return Effect.forEach(
853 entries,
854 (entry, index) => {
855 const alias = aliases[`h${index}`];
856 if (alias == null) return readCli(entry);
857 entry.completeUnsafe(Exit.succeed(decodeGitHubPullRequestEntries(alias.nodes)));
858 return Effect.void;
859 },
860 { discard: true },
861 );
862 }),
863 // A document GitHub refused as a whole (a renamed repository, a field an older
864 // Enterprise host lacks) leaves each lookup to gh. A rate limit fails them all:
865 // asking one at a time would only spend what the pause is saving.
866 Effect.catchIf(
867 (error) => error._tag !== "GitHubCliRateLimitError",
868 () => Effect.forEach(entries, readCli, { discard: true }),
869 ),
870 Effect.catchCause((cause) =>
871 Effect.sync(() => {
872 for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause));
873 }),
874 ),
875 );
519876 },
877 }).pipe(
878 RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW),
879 RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT),
520880 );
521881
882 const listPullRequestsByHead: GitHubCli["Service"]["listPullRequestsByHead"] = Effect.fn(
883 "GitHubCli.listPullRequestsByHead",
884 )(function* (input) {
885 const host = input.rateLimitHost?.toLowerCase();
886 const credential = yield* PinnedGitHubCredential;
887 // `owner:branch` selectors and other hosts keep gh's own handling.
888 const repository =
889 host === undefined ||
890 input.headSelector.includes(":") ||
891 (credential !== null && credential.host !== host)
892 ? null
893 : yield* resolveBaseRepository(input.cwd, host);
894 if (host === undefined || repository === null) {
895 return yield* listPullRequestsWithCli(input);
896 }
897 return yield* Effect.request(
898 new PullRequestsByHeadRead({
899 cwd: input.cwd,
900 host,
901 owner: repository.owner,
902 name: repository.name,
903 headRefName: input.headSelector,
904 state: input.state,
905 limit: Math.min(Math.max(Math.trunc(input.limit), 1), 100),
906 }),
907 headResolver,
908 );
909 });
910
522911 return GitHubCli.of({
523912 execute,
913 listPullRequestsByHead,
524914 listOpenPullRequests: (input) =>
525915 execute({
526916 cwd: input.cwd,
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+42-62
......@@ -7,6 +7,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
77
88import * as VcsProcess from "../vcs/VcsProcess.ts";
99import * as GitHubCli from "./GitHubCli.ts";
10import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
1011import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
1112import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
1213
......@@ -32,19 +33,13 @@ function makeProvider(github: Partial<GitHubCli.GitHubCli["Service"]>) {
3233
3334it.effect("uses the enterprise quota for a current-repository default branch read", () =>
3435 Effect.gen(function* () {
35 const provider = yield* GitHubSourceControlProvider.make.pipe(
36 Effect.provide(GitHubCli.layer),
37 Effect.provideService(VcsProcess.VcsProcess, {
38 run: (input) =>
39 Effect.sync(() => {
40 if (input.args[1] !== "rate_limit") return processResult("main");
41 assert.strictEqual(input.args[3], "enterprise.test");
42 return processResult(
43 '{"data":{"rateLimit":{"cost":1,"limit":5000,"remaining":5000,"resetAt":"2099-01-01T00:00:00Z"}}}',
44 );
45 }),
46 }),
36 // github.com is out of quota; the enterprise read must not be priced against it.
37 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
38 yield* budget.observe(
39 "github.com",
40 '{"data":{"rateLimit":{"cost":1,"limit":5000,"remaining":0,"resetAt":"2099-01-01T00:00:00Z"}}}',
4741 );
42 const provider = yield* GitHubSourceControlProvider.make;
4843 const branch = yield* provider.getDefaultBranch({
4944 cwd: "/enterprise-repo",
5045 context: {
......@@ -54,7 +49,12 @@ it.effect("uses the enterprise quota for a current-repository default branch rea
5449 },
5550 });
5651 assert.strictEqual(branch, "main");
57 }),
52 }).pipe(
53 Effect.provide(GitHubCli.layer),
54 Effect.provideService(VcsProcess.VcsProcess, {
55 run: () => Effect.succeed(processResult("main")),
56 }),
57 ),
5858);
5959
6060it.effect("maps GitHub PR summaries into provider-neutral change requests", () =>
......@@ -138,50 +138,47 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () =>
138138 }),
139139);
140140
141it.effect("uses gh json listing for non-open change request state queries", () =>
141it.effect("lists change request history through the batched head lookup", () =>
142142 Effect.gen(function* () {
143 let executeArgs: ReadonlyArray<string> = [];
143 let lookup: Parameters<GitHubCli.GitHubCli["Service"]["listPullRequestsByHead"]>[0] | null =
144 null;
144145 const provider = yield* makeProvider({
145 execute: (input) => {
146 executeArgs = input.args;
147 return Effect.succeed(
148 processResult(
149 JSON.stringify([
150 {
151 number: 7,
152 title: "Merged work",
153 url: "https://github.com/pingdotgg/t3code/pull/7",
154 baseRefName: "main",
155 headRefName: "feature/merged",
156 state: "merged",
157 mergedAt: "2026-01-01T00:00:00Z",
158 updatedAt: "2026-01-02T00:00:00.000Z",
159 },
160 ]),
161 ),
162 );
146 listPullRequestsByHead: (input) => {
147 lookup = input;
148 return Effect.succeed([
149 {
150 number: 7,
151 title: "Merged work",
152 url: "https://enterprise.test/acme/web/pull/7",
153 baseRefName: "main",
154 headRefName: "feature/merged",
155 state: "merged",
156 mergedAt: "2026-01-01T00:00:00Z",
157 updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")),
158 },
159 ]);
163160 },
164161 });
165162
166163 const changeRequests = yield* provider.listChangeRequests({
167164 cwd: "/repo",
165 context: {
166 provider: { kind: "github", name: "GitHub Enterprise", baseUrl: "https://enterprise.test" },
167 remoteName: "origin",
168 remoteUrl: "https://enterprise.test/acme/web.git",
169 },
168170 headSelector: "feature/merged",
169171 state: "all",
170172 limit: 10,
171173 });
172174
173 assert.deepStrictEqual(executeArgs, [
174 "pr",
175 "list",
176 "--head",
177 "feature/merged",
178 "--state",
179 "all",
180 "--limit",
181 "10",
182 "--json",
183 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
184 ]);
175 assert.deepStrictEqual(lookup, {
176 cwd: "/repo",
177 headSelector: "feature/merged",
178 state: "all",
179 limit: 10,
180 rateLimitHost: "enterprise.test",
181 });
185182 assert.strictEqual(changeRequests[0]?.provider, "github");
186183 assert.strictEqual(changeRequests[0]?.state, "merged");
187184 assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z");
......@@ -192,23 +189,6 @@ it.effect("uses gh json listing for non-open change request state queries", () =
192189 }),
193190);
194191
195it.effect("treats empty non-open change request listing output as no results", () =>
196 Effect.gen(function* () {
197 const provider = yield* makeProvider({
198 execute: () => Effect.succeed(processResult("")),
199 });
200
201 const changeRequests = yield* provider.listChangeRequests({
202 cwd: "/repo",
203 headSelector: "feature/empty",
204 state: "all",
205 limit: 10,
206 });
207
208 assert.deepStrictEqual(changeRequests, []);
209 }),
210);
211
212192it.effect("creates GitHub PRs through provider-neutral input names", () =>
213193 Effect.gen(function* () {
214194 let createInput: Parameters<GitHubCli.GitHubCli["Service"]["createPullRequest"]>[0] | null =
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+16-53
......@@ -2,16 +2,10 @@ import * as Schema from "effect/Schema";
22import * as DateTime from "effect/DateTime";
33import * as Effect from "effect/Effect";
44import * as Option from "effect/Option";
5import * as Result from "effect/Result";
6import {
7 SourceControlProviderError,
8 type ChangeRequest,
9 type ChangeRequestState,
10} from "@t3tools/contracts";
5import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
116
127import * as GitHubCli from "./GitHubCli.ts";
138import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
14import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts";
159import * as SourceControlProvider from "./SourceControlProvider.ts";
1610import {
1711 combinedAuthOutput,
......@@ -151,59 +145,28 @@ export const make = Effect.gen(function* () {
151145 );
152146 }
153147
154 const stateArg: ChangeRequestState | "all" = input.state;
155148 return github
156 .execute({
149 .listPullRequestsByHead({
157150 cwd: input.cwd,
151 headSelector: input.headSelector,
152 state: input.state,
153 limit: input.limit ?? 20,
158154 ...(input.context === undefined
159155 ? {}
160156 : { rateLimitHost: new URL(input.context.provider.baseUrl).host }),
161 args: [
162 "pr",
163 "list",
164 "--head",
165 input.headSelector,
166 "--state",
167 stateArg,
168 "--limit",
169 String(input.limit ?? 20),
170 "--json",
171 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
172 ],
173157 })
174158 .pipe(
175 Effect.flatMap((result) => {
176 const raw = result.stdout.trim();
177 if (raw.length === 0) {
178 return Effect.succeed([]);
179 }
180 return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
181 Effect.flatMap((decoded) =>
182 Result.isSuccess(decoded)
183 ? Effect.succeed(
184 decoded.success.map((item) => {
185 const { updatedAt, ...summary } = item;
186 return {
187 ...toChangeRequest({
188 ...summary,
189 ...(Option.isSome(updatedAt)
190 ? { updatedAt: DateTime.formatIso(updatedAt.value) }
191 : {}),
192 }),
193 updatedAt,
194 };
195 }),
196 )
197 : Effect.fail(
198 new GitHubCli.GitHubChangeRequestListDecodeError({
199 command: "gh",
200 cwd: input.cwd,
201 cause: decoded.failure,
202 }),
203 ),
204 ),
205 );
206 }),
159 Effect.map((items) =>
160 items.map(({ updatedAt, ...summary }) => ({
161 ...toChangeRequest({
162 ...summary,
163 ...(Option.isSome(updatedAt)
164 ? { updatedAt: DateTime.formatIso(updatedAt.value) }
165 : {}),
166 }),
167 updatedAt,
168 })),
169 ),
207170 Effect.mapError(
208171 (error) =>
209172 new SourceControlProviderError({
apps/server/src/sourceControl/gitHubPullRequests.ts+18-13
......@@ -114,25 +114,30 @@ const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown
114114const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema);
115115const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema);
116116
117/**
118 * Pull request rows in `gh --json` or GraphQL node shape. A row that does not decode is
119 * skipped, so one malformed pull request cannot hide the rest.
120 */
121export function decodeGitHubPullRequestEntries(
122 entries: ReadonlyArray<unknown>,
123): ReadonlyArray<NormalizedGitHubPullRequestRecord> {
124 const pullRequests: NormalizedGitHubPullRequestRecord[] = [];
125 for (const entry of entries) {
126 const decodedEntry = decodeGitHubPullRequestEntry(entry);
127 if (Exit.isSuccess(decodedEntry)) {
128 pullRequests.push(normalizeGitHubPullRequestRecord(decodedEntry.value));
129 }
130 }
131 return pullRequests;
132}
133
117134export function decodeGitHubPullRequestListJson(
118135 raw: string,
119136): Result.Result<
120137 ReadonlyArray<NormalizedGitHubPullRequestRecord>,
121138 Cause.Cause<Schema.SchemaError>
122139> {
123 const result = decodeGitHubPullRequestList(raw);
124 if (Result.isSuccess(result)) {
125 const pullRequests: NormalizedGitHubPullRequestRecord[] = [];
126 for (const entry of result.success) {
127 const decodedEntry = decodeGitHubPullRequestEntry(entry);
128 if (Exit.isFailure(decodedEntry)) {
129 continue;
130 }
131 pullRequests.push(normalizeGitHubPullRequestRecord(decodedEntry.value));
132 }
133 return Result.succeed(pullRequests);
134 }
135 return Result.fail(result.failure);
140 return Result.map(decodeGitHubPullRequestList(raw), decodeGitHubPullRequestEntries);
136141}
137142
138143export function decodeGitHubPullRequestJson(
packages/contracts/src/pullRequest.ts+2
......@@ -747,6 +747,8 @@ export const PullRequestSummary = Schema.Struct({
747747 reviewDecision: Schema.optional(Schema.NullOr(PullRequestReviewDecision)),
748748 checksState: Schema.optional(Schema.NullOr(PullRequestChecksState)),
749749 mergeability: Schema.optional(PullRequestMergeability),
750 /** Null when the host says the pull request is in no stack; absent when the read did not ask. */
751 stack: Schema.optional(Schema.NullOr(PullRequestStackMembership)),
750752});
751753export type PullRequestSummary = typeof PullRequestSummary.Type;
752754