authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 17:26:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 17:26:25-07:00
loga21ac5fb153e3bf418ccd2a4abef2787c241813c
treed939c6da0f995dc3e06479dad8491799a4abb538
parent08b1222d0bae670433c275ec70165c7b5048726f
signature Signed by PGP key B5690EEEBB952194

refactor(server): GitHub GraphQL batches use variables and share one pager (#16960)

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

7 files changed, 565 insertions(+), 255 deletions(-)

apps/server/src/pullRequest/GitHubPullRequestApi.test.ts+87-20
......@@ -767,11 +767,15 @@ layer("GitHubPullRequestApi.layer", (it) => {
767767 );
768768 assert.strictEqual(eight?.headBranch, "feat/8");
769769 expect(mockedExecute).toHaveBeenCalledOnce();
770 const document = queryAt(0);
771 expect(document).toContain(
772 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)',
770 expect(queryAt(0)).toContain(
771 "s0: repository(owner: $s0_owner, name: $s0_name) { pullRequest(number: $s0_number)",
773772 );
774 expect(document).toContain("pullRequest(number: 8)");
773 expect(varsAt(0)).toMatchObject({
774 s0_owner: "acme",
775 s0_name: "web",
776 s0_number: 7,
777 s1_number: 8,
778 });
775779 }),
776780 );
777781
......@@ -815,10 +819,10 @@ layer("GitHubPullRequestApi.layer", (it) => {
815819 // GitHub had no answer for #8, so its watch reads it in full.
816820 expect(eight).toBeNull();
817821 expect(mockedExecute).toHaveBeenCalledOnce();
818 const call = callAt(0);
819 expect(call.kind === "graphql" ? call.query : "").toContain(
820 'w1: repository(owner: "acme", name: "web") { pullRequest(number: 8)',
822 expect(queryAt(0)).toContain(
823 "w1: repository(owner: $w1_owner, name: $w1_name) { pullRequest(number: $w1_number)",
821824 );
825 expect(varsAt(0)).toMatchObject({ w1_owner: "acme", w1_name: "web", w1_number: 8 });
822826 }),
823827 );
824828
......@@ -1108,6 +1112,37 @@ layer("GitHubPullRequestApi.layer", (it) => {
11081112 }),
11091113 );
11101114
1115 it.effect("reports truncation when GitHub repeats a cursor before the page is full", () =>
1116 Effect.gen(function* () {
1117 // Two pages of 100 that hand back the same cursor, for a page of 250.
1118 const repeating = output(
1119 encodeJson({
1120 data: {
1121 search: {
1122 pageInfo: { hasNextPage: true, endCursor: "same" },
1123 nodes: rows(100, 1),
1124 },
1125 },
1126 }),
1127 );
1128 mockedExecute.mockReturnValue(Effect.succeed(repeating));
1129 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1130
1131 const batch = yield* cli.listPullRequests({
1132 cwd: "/w",
1133 repository: "acme/web",
1134 host: "github.com",
1135 state: "open",
1136 involvement: "all",
1137 viewer: "bilal",
1138 limit: 250,
1139 });
1140
1141 assert.strictEqual(mockedExecute.mock.calls.length, 2);
1142 assert.isTrue(batch.truncated);
1143 }),
1144 );
1145
11111146 it.effect("excludes merged pull requests from the Closed tab", () =>
11121147 Effect.gen(function* () {
11131148 mockedExecute.mockImplementation((call) =>
......@@ -1408,11 +1443,12 @@ layer("GitHubPullRequestApi.layer", (it) => {
14081443 expect(batch.continues).toBe(false);
14091444 expect(mockedStackMemberships).toHaveBeenCalledTimes(1);
14101445 const membership = mockedStackMemberships.mock.calls[0]?.[0];
1411 const query = membership?.kind === "graphql" ? membership.query : "";
1412 expect(query).toContain("pullRequest(number: 4)");
1413 expect(query).toContain("pullRequest(number: 5)");
1414 expect(query).not.toContain("pullRequest(number: 1)");
1415 expect(query).not.toContain("pullRequest(number: 6)");
1446 const numbers = Object.entries(
1447 membership?.kind === "graphql" ? (membership.variables ?? {}) : {},
1448 )
1449 .filter(([name]) => name.endsWith("_number"))
1450 .map(([, number]) => number);
1451 expect(numbers).toEqual([4, 5]);
14161452 }),
14171453 );
14181454
......@@ -1420,7 +1456,7 @@ layer("GitHubPullRequestApi.layer", (it) => {
14201456 Effect.gen(function* () {
14211457 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1))));
14221458 mockedStackMemberships.mockImplementation((input) =>
1423 input.kind === "graphql" && input.query.includes("pullRequest(number: 26)")
1459 input.kind === "graphql" && Object.values(input.variables ?? {}).includes(26)
14241460 ? Effect.fail(
14251461 new GitHubApi.GitHubApiResponseError({
14261462 host: "github.com",
......@@ -1513,9 +1549,8 @@ layer("GitHubPullRequestApi.layer", (it) => {
15131549 { repository: "acme/web", number: 1, additions: 4, deletions: 1 },
15141550 { repository: "acme/web", number: 26, additions: 4, deletions: 1 },
15151551 ]);
1516 const document = queryAt(0);
1517 expect(document).toContain('s0: repository(owner: "acme", name: "web")');
1518 expect(document).toContain("pullRequest(number: 25)");
1552 expect(queryAt(0)).toContain("s0: repository(owner: $s0_owner, name: $s0_name)");
1553 expect(varsAt(0)).toMatchObject({ s0_owner: "acme", s0_name: "web", s24_number: 25 });
15191554 }),
15201555 );
15211556
......@@ -2614,6 +2649,38 @@ layer("GitHubPullRequestApi.layer", (it) => {
26142649 }),
26152650 );
26162651
2652 it.effect("refuses workflow approval when the head list stops before its end", () =>
2653 Effect.gen(function* () {
2654 // GitHub hands the same cursor back, so the second page is never read past. #7 looks
2655 // unique on what was read, and an unread page could still hold another head like it.
2656 const repeating = heads([7]);
2657 repeating.data.repository.pullRequests.pageInfo = {
2658 hasNextPage: true,
2659 endCursor: "same",
2660 } as never;
2661 workflowApprovalRoutes(() => crossRepositoryDetail(), repeating, workflowRuns([]));
2662 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2663
2664 const error = yield* Effect.flip(
2665 cli.listWorkflowRunsRequiringApproval({
2666 cwd: "/w",
2667 repository: "acme/web",
2668 host: "github.com",
2669 number: 7,
2670 headSha: "abc123",
2671 headBranch: "feat/page",
2672 headRepositoryOwner: "octocat",
2673 isCrossRepository: true,
2674 }),
2675 );
2676
2677 expect(error).toMatchObject({
2678 _tag: "GitHubWorkflowApprovalRefusedError",
2679 reason: "head-list-truncated",
2680 });
2681 }),
2682 );
2683
26172684 it.effect("refuses workflow approval when GitHub omits the head repository", () =>
26182685 Effect.gen(function* () {
26192686 mockedExecute.mockReturnValueOnce(
......@@ -4386,9 +4453,9 @@ layer("GitHubPullRequestApi.layer", (it) => {
43864453 number: 7,
43874454 });
43884455
4389 assert.strictEqual(mockedExecute.mock.calls.length, 5);
4456 // The same cursor twice is a page GitHub already gave, so reading stops there.
4457 assert.strictEqual(mockedExecute.mock.calls.length, 2);
43904458 assert.isTrue(viewed.truncated);
4391 assert.strictEqual(viewed.files.length, 5);
43924459 }),
43934460 );
43944461
......@@ -4420,8 +4487,8 @@ layer("GitHubPullRequestApi.layer", (it) => {
44204487 expect(queryAt(1)).toContain("f1: unmarkFileAsViewed");
44214488 expect(varsAt(1)).toEqual({
44224489 pullRequestId: "PR_1",
4423 path0: "src/a.ts",
4424 path1: "src/b.ts",
4490 f0_path: "src/a.ts",
4491 f1_path: "src/b.ts",
44254492 });
44264493 }),
44274494 );
apps/server/src/pullRequest/GitHubPullRequestApi.ts+138-130
......@@ -44,6 +44,7 @@ import {
4444
4545import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts";
4646import * as GitHubApi from "../sourceControl/GitHubApi.ts";
47import { readGraphQlPages } from "../sourceControl/githubGraphQl.ts";
4748import * as VcsProcess from "../vcs/VcsProcess.ts";
4849import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
4950import {
......@@ -64,7 +65,6 @@ import {
6465 PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY,
6566 PULL_REQUEST_HEADS_GRAPHQL_QUERY,
6667 decodeWorkflowRunsJson,
67 type GitHubCheckContext,
6868 pullRequestCoreGraphQlQuery,
6969 type GitHubPullRequestCore,
7070 type GitHubPullRequestSummary,
......@@ -1481,42 +1481,45 @@ export const make = Effect.gen(function* () {
14811481 * its first hundred. Each page names the head it read, so a push mid-walk fails rather than
14821482 * mixing two revisions' checks.
14831483 */
1484 type CheckContextsPage = Result.Result.Success<
1485 ReturnType<typeof decodePullRequestCheckContextsJson>
1486 >;
14841487 const readAllCheckContexts = (
14851488 input: Parameters<GitHubPullRequestApi["Service"]["getPullRequestDetail"]>[0],
14861489 allowReserve: boolean,
14871490 ) =>
14881491 Effect.gen(function* () {
14891492 const { owner, name } = parseRepositorySelector(input.repository);
1490 const contexts: GitHubCheckContext[] = [];
1491 let headSha: string | null = null;
1492 let after: string | null = null;
1493 for (let page = 0; page < CHECK_CONTEXT_PAGES; page++) {
1494 const read: {
1495 readonly headSha: string;
1496 readonly contexts: ReadonlyArray<GitHubCheckContext>;
1497 readonly nextCursor: string | null;
1498 } = yield* graphqlRead({
1499 cwd: input.cwd,
1500 host: input.host,
1501 operation: "getPullRequestDetail",
1502 allowReserve,
1503 variables: { owner, name, number: input.number, after },
1504 query: pullRequestCheckContextsGraphQlQuery(input.host),
1505 decode: decodePullRequestCheckContextsJson,
1506 });
1507 if (headSha !== null && read.headSha !== headSha) {
1508 return yield* readError(
1509 input.cwd,
1510 "getPullRequestDetail",
1511 new Error("Pull request head changed while reading checks."),
1512 );
1513 }
1514 headSha = read.headSha;
1515 contexts.push(...read.contexts);
1516 after = read.nextCursor;
1517 if (after === null) break;
1518 }
1519 return { headSha, contexts, truncated: after !== null };
1493 const { pages, truncated } = yield* readGraphQlPages(
1494 (after, previous: ReadonlyArray<CheckContextsPage>) =>
1495 graphqlRead({
1496 cwd: input.cwd,
1497 host: input.host,
1498 operation: "getPullRequestDetail",
1499 allowReserve,
1500 variables: { owner, name, number: input.number, after },
1501 query: pullRequestCheckContextsGraphQlQuery(input.host),
1502 decode: decodePullRequestCheckContextsJson,
1503 }).pipe(
1504 // Each page names the head it read, so a push mid-walk fails rather than mixing
1505 // two revisions' checks.
1506 Effect.filterOrFail(
1507 (page) => previous.length === 0 || page.headSha === previous[0]!.headSha,
1508 () =>
1509 readError(
1510 input.cwd,
1511 "getPullRequestDetail",
1512 new Error("Pull request head changed while reading checks."),
1513 ),
1514 ),
1515 ),
1516 { maxPages: CHECK_CONTEXT_PAGES, nextCursor: (page) => page.nextCursor },
1517 );
1518 return {
1519 headSha: pages[0]?.headSha ?? null,
1520 contexts: pages.flatMap((page) => page.contexts),
1521 truncated,
1522 };
15201523 });
15211524
15221525 const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => {
......@@ -1568,7 +1571,10 @@ export const make = Effect.gen(function* () {
15681571 const workflowApprovalReadError = (cwd: string, cause: unknown) =>
15691572 readError(cwd, "listWorkflowRunsRequiringApproval", cause);
15701573
1571 /** Every open pull request whose head branch carries this name, up to one past the limit. */
1574 /**
1575 * Every open pull request whose head branch carries this name, up to one past the limit.
1576 * `truncated` says some were never read, which is never enough to call a head unique.
1577 */
15721578 const listHeadsByBranch = (input: {
15731579 readonly cwd: string;
15741580 readonly repository: string;
......@@ -1577,25 +1583,25 @@ export const make = Effect.gen(function* () {
15771583 }) =>
15781584 Effect.gen(function* () {
15791585 const { owner, name } = parseRepositorySelector(input.repository);
1580 const heads: GitHubPullRequestHead[] = [];
1581 let after: string | null = null;
1582 do {
1583 const page: {
1584 readonly heads: ReadonlyArray<GitHubPullRequestHead>;
1585 readonly nextCursor: string | null;
1586 } = yield* graphqlRead({
1587 cwd: input.cwd,
1588 host: input.host,
1589 operation: "listWorkflowRunsRequiringApproval",
1590 allowReserve: true,
1591 variables: { owner, name, head: input.headBranch, after },
1592 query: PULL_REQUEST_HEADS_GRAPHQL_QUERY,
1593 decode: decodePullRequestHeadsJson,
1594 });
1595 heads.push(...page.heads);
1596 after = page.nextCursor;
1597 } while (after !== null && heads.length <= workflowApprovalLimit);
1598 return heads;
1586 const countHeads = (pages: ReadonlyArray<{ readonly heads: ReadonlyArray<unknown> }>) =>
1587 pages.reduce((total, page) => total + page.heads.length, 0);
1588 const { pages, truncated } = yield* readGraphQlPages(
1589 (after) =>
1590 graphqlRead({
1591 cwd: input.cwd,
1592 host: input.host,
1593 operation: "listWorkflowRunsRequiringApproval",
1594 allowReserve: true,
1595 variables: { owner, name, head: input.headBranch, after },
1596 query: PULL_REQUEST_HEADS_GRAPHQL_QUERY,
1597 decode: decodePullRequestHeadsJson,
1598 }),
1599 {
1600 nextCursor: (page) => page.nextCursor,
1601 until: (pages) => countHeads(pages) > workflowApprovalLimit,
1602 },
1603 );
1604 return { heads: pages.flatMap((page) => page.heads), truncated };
15991605 });
16001606
16011607 /**
......@@ -1650,9 +1656,10 @@ export const make = Effect.gen(function* () {
16501656 [
16511657 listHeadsByBranch(input).pipe(
16521658 Effect.flatMap(
1653 (
1659 ({
16541660 heads,
1655 ): Effect.Effect<
1661 truncated,
1662 }): Effect.Effect<
16561663 GitHubPullRequestHead,
16571664 GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError
16581665 > => {
......@@ -1663,7 +1670,7 @@ export const make = Effect.gen(function* () {
16631670 pullRequest.headRepositoryOwner?.toLowerCase() ===
16641671 input.headRepositoryOwner.toLowerCase(),
16651672 );
1666 if (heads.length > workflowApprovalLimit) {
1673 if (truncated || heads.length > workflowApprovalLimit) {
16671674 return Effect.fail(
16681675 new GitHubWorkflowApprovalRefusedError({
16691676 cwd: input.cwd,
......@@ -1755,18 +1762,18 @@ export const make = Effect.gen(function* () {
17551762 );
17561763 // Stack membership rides along where GitHub serves stacks, so the background sync can skip
17571764 // the REST stack read for pull requests that are in none.
1758 const query = buildPullRequestSummariesGraphQlQuery(
1765 const document = buildPullRequestSummariesGraphQlQuery(
17591766 batchable.map((entry) => entry.request),
17601767 first.request.host === "github.com",
17611768 );
17621769 const batched =
1763 query === null
1770 document === null
17641771 ? Effect.succeed(new Map<number, GitHubPullRequestSummary>())
17651772 : graphqlRead({
17661773 cwd: first.request.cwd,
17671774 host: first.request.host,
17681775 operation: "getPullRequestSummary",
1769 query,
1776 ...document,
17701777 decode: decodePullRequestSummariesJson,
17711778 });
17721779 return batched.pipe(
......@@ -1833,17 +1840,17 @@ export const make = Effect.gen(function* () {
18331840 const batchable = entries.filter(
18341841 (entry) => buildPullRequestWatchFingerprintsGraphQlQuery([entry.request]) !== null,
18351842 );
1836 const query = buildPullRequestWatchFingerprintsGraphQlQuery(
1843 const document = buildPullRequestWatchFingerprintsGraphQlQuery(
18371844 batchable.map((entry) => entry.request),
18381845 );
18391846 const read =
1840 query === null
1847 document === null
18411848 ? Effect.succeed(new Map<number, GitHubPullRequestWatchFingerprint>())
18421849 : graphqlRead({
18431850 cwd: first.request.cwd,
18441851 host: first.request.host,
18451852 operation: "getPullRequestWatchFingerprint",
1846 query,
1853 ...document,
18471854 decode: decodePullRequestWatchFingerprintsJson,
18481855 });
18491856 return read.pipe(
......@@ -1897,16 +1904,22 @@ export const make = Effect.gen(function* () {
18971904 ) => Effect.Effect<GitHubPullRequestListPage, GitHubPullRequestApiError>,
18981905 ) =>
18991906 Effect.gen(function* () {
1900 const items: GitHubPullRequestListItem[] = [];
1901 let rawCount = 0;
1902 let after: string | null = null;
1903 do {
1904 const read: GitHubPullRequestListPage = yield* page(after, rows - rawCount);
1905 items.push(...read.items);
1906 rawCount += read.rawCount;
1907 after = read.endCursor;
1908 } while (after !== null && rawCount < rows);
1909 return { items, rawCount };
1907 const countRows = (pages: ReadonlyArray<GitHubPullRequestListPage>) =>
1908 pages.reduce((total, read) => total + read.rawCount, 0);
1909 const { pages, truncated } = yield* readGraphQlPages(
1910 (after, pages: ReadonlyArray<GitHubPullRequestListPage>) =>
1911 page(after, rows - countRows(pages)),
1912 {
1913 nextCursor: (read) => read.endCursor,
1914 until: (pages) => countRows(pages) >= rows,
1915 },
1916 );
1917 // `truncated` also covers a cursor GitHub repeated before `rows` arrived.
1918 return {
1919 items: pages.flatMap((read) => read.items),
1920 rawCount: countRows(pages),
1921 truncated,
1922 };
19101923 });
19111924 const read = (
19121925 continues: boolean,
......@@ -1932,7 +1945,7 @@ export const make = Effect.gen(function* () {
19321945 decode: decodePullRequestListJson,
19331946 }),
19341947 ).pipe(
1935 Effect.flatMap(({ items: rawItems, rawCount }) => {
1948 Effect.flatMap(({ items: rawItems, rawCount, truncated: cutShort }) => {
19361949 const items = continues
19371950 ? rawItems
19381951 : rawItems.filter((item) => matchesUnsortedListing(item, input));
......@@ -1949,9 +1962,11 @@ export const make = Effect.gen(function* () {
19491962 items: items.slice(0, input.limit),
19501963 // One row over the page size is the probe for a next page, and it is
19511964 // counted before decoding: a skipped malformed row must not end paging.
1952 truncated: continues
1953 ? rawCount > input.limit
1954 : items.length > input.limit || rawCount >= requestedRows,
1965 truncated:
1966 cutShort ||
1967 (continues
1968 ? rawCount > input.limit
1969 : items.length > input.limit || rawCount >= requestedRows),
19551970 continues,
19561971 });
19571972 }),
......@@ -1989,16 +2004,16 @@ export const make = Effect.gen(function* () {
19892004 return Effect.forEach(
19902005 chunks,
19912006 (chunk) => {
1992 const query = buildPullRequestStackMembershipsGraphQlQuery(
2007 const document = buildPullRequestStackMembershipsGraphQlQuery(
19932008 input.repository,
19942009 chunk.map((item) => item.number),
19952010 );
1996 if (query === null) return Effect.succeed(chunk);
2011 if (document === null) return Effect.succeed(chunk);
19972012 return graphqlRead({
19982013 cwd: input.cwd,
19992014 host: input.host,
20002015 operation: "listPullRequestStackMemberships",
2001 query,
2016 ...document,
20022017 decode: decodePullRequestStackMembershipsJson,
20032018 }).pipe(
20042019 Effect.map((memberships) =>
......@@ -2061,8 +2076,8 @@ export const make = Effect.gen(function* () {
20612076 return Effect.forEach(
20622077 chunks,
20632078 (chunk) => {
2064 const query = buildPullRequestStatsGraphQlQuery(chunk);
2065 if (query === null) {
2079 const document = buildPullRequestStatsGraphQlQuery(chunk);
2080 if (document === null) {
20662081 return Effect.fail(
20672082 new GitHubRepositorySelectorError({
20682083 cwd: input.cwd,
......@@ -2074,7 +2089,7 @@ export const make = Effect.gen(function* () {
20742089 cwd: input.cwd,
20752090 host: input.host,
20762091 operation: "listPullRequestStats",
2077 query,
2092 ...document,
20782093 decode: decodePullRequestStatsJson,
20792094 }).pipe(
20802095 Effect.map((stats) =>
......@@ -2328,23 +2343,26 @@ export const make = Effect.gen(function* () {
23282343 // Almost never entered: the embedded page already holds every dismissal a pull request
23292344 // ordinarily accrues. Followed so a review whose event fell past that page still finds
23302345 // its reason.
2331 let dismissalPage = 0;
2332 while (dismissalCursor !== null && dismissalPage < REVIEW_THREAD_PAGES) {
2333 const read: {
2334 readonly dismissalsByReviewId: ReadonlyMap<string, string>;
2335 readonly nextCursor: string | null;
2336 } = yield* graphqlRead({
2337 cwd: input.cwd,
2338 host: input.host,
2339 operation: "listReviewThreadComments",
2340 variables: { owner, name, number: input.number, cursor: dismissalCursor },
2341 query: REVIEW_DISMISSALS_GRAPHQL_QUERY,
2342 decode: decodeReviewDismissalsJson,
2343 });
2344 for (const [id, message] of read.dismissalsByReviewId)
2345 dismissalsByReviewId.set(id, message);
2346 dismissalCursor = read.nextCursor;
2347 dismissalPage += 1;
2346 if (dismissalCursor !== null) {
2347 const { pages } = yield* readGraphQlPages(
2348 (cursor) =>
2349 graphqlRead({
2350 cwd: input.cwd,
2351 host: input.host,
2352 operation: "listReviewThreadComments",
2353 variables: { owner, name, number: input.number, cursor },
2354 query: REVIEW_DISMISSALS_GRAPHQL_QUERY,
2355 decode: decodeReviewDismissalsJson,
2356 }),
2357 {
2358 from: dismissalCursor,
2359 maxPages: REVIEW_THREAD_PAGES,
2360 nextCursor: (read) => read.nextCursor,
2361 },
2362 );
2363 for (const read of pages)
2364 for (const [id, message] of read.dismissalsByReviewId)
2365 dismissalsByReviewId.set(id, message);
23482366 }
23492367
23502368 const reviewThreads = entries.map((entry) => ({
......@@ -2708,44 +2726,34 @@ export const make = Effect.gen(function* () {
27082726
27092727 getPullRequestFilesViewed: (input) => {
27102728 const { owner, name } = parseRepositorySelector(input.repository);
2711 const read = (
2712 after: string | null,
2713 collected: ReadonlyArray<PullRequestFileViewed>,
2714 pagesLeft: number,
2715 ): Effect.Effect<GitHubPullRequestFilesViewed, GitHubPullRequestApiError> =>
2716 graphqlRead({
2717 cwd: input.cwd,
2718 host: input.host,
2719 operation: "getPullRequestFilesViewed",
2720 variables: { owner, name, number: input.number, after },
2721 query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,
2722 decode: decodePullRequestFilesViewedJson,
2723 }).pipe(
2724 Effect.flatMap((page) => {
2725 const files = [...collected, ...page.files];
2726 if (page.nextCursor === null) {
2727 return Effect.succeed({ files, truncated: false });
2728 }
2729 return pagesLeft <= 1
2730 ? Effect.succeed({ files, truncated: true })
2731 : read(page.nextCursor, files, pagesLeft - 1);
2729 return readGraphQlPages(
2730 (after) =>
2731 graphqlRead({
2732 cwd: input.cwd,
2733 host: input.host,
2734 operation: "getPullRequestFilesViewed",
2735 variables: { owner, name, number: input.number, after },
2736 query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,
2737 decode: decodePullRequestFilesViewedJson,
27322738 }),
2733 );
2734 return read(null, [], FILES_VIEWED_MAX_PAGES);
2739 { maxPages: FILES_VIEWED_MAX_PAGES, nextCursor: (page) => page.nextCursor },
2740 ).pipe(
2741 Effect.map(({ pages, truncated }) => ({
2742 files: pages.flatMap((page) => page.files),
2743 truncated,
2744 })),
2745 );
27352746 },
27362747
27372748 setPullRequestFilesViewed: (input) => {
2738 const mutation = buildSetFilesViewedGraphQlMutation(input.files);
2739 if (mutation === null) return Effect.void;
2749 if (input.files.length === 0) return Effect.void;
27402750 return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe(
2741 Effect.flatMap((pullRequestId) =>
2742 graphql({
2743 host: input.host,
2744 operation: "setPullRequestFilesViewed",
2745 query: mutation.query,
2746 variables: { pullRequestId, ...mutation.variables },
2747 }),
2748 ),
2751 Effect.flatMap((pullRequestId) => {
2752 const mutation = buildSetFilesViewedGraphQlMutation(pullRequestId, input.files);
2753 return mutation === null
2754 ? Effect.void
2755 : graphql({ host: input.host, operation: "setPullRequestFilesViewed", ...mutation });
2756 }),
27492757 );
27502758 },
27512759
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+35-20
......@@ -1754,36 +1754,40 @@ describe("decodePullRequestFilesViewedJson", () => {
17541754
17551755describe("buildSetFilesViewedGraphQlMutation", () => {
17561756 it("asks for nothing when nothing was pressed", () => {
1757 expect(buildSetFilesViewedGraphQlMutation([])).toBeNull();
1757 expect(buildSetFilesViewedGraphQlMutation("PR_1", [])).toBeNull();
17581758 });
17591759
17601760 it("clears and restores in one document, each file under its own alias", () => {
1761 const mutation = buildSetFilesViewedGraphQlMutation([
1761 const mutation = buildSetFilesViewedGraphQlMutation("PR_1", [
17621762 { path: "src/a.ts", viewed: true },
17631763 { path: "src/b.ts", viewed: false },
17641764 ]);
17651765 expect(mutation).not.toBeNull();
17661766 if (mutation === null) return;
17671767 expect(mutation.query).toContain(
1768 "mutation($pullRequestId: ID!, $path0: String!, $path1: String!)",
1768 "mutation($pullRequestId: ID!, $f0_path: String!, $f1_path: String!)",
17691769 );
17701770 expect(mutation.query).toContain(
1771 "f0: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path0 })",
1771 "f0: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $f0_path })",
17721772 );
17731773 expect(mutation.query).toContain(
1774 "f1: unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path1 })",
1774 "f1: unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $f1_path })",
17751775 );
1776 expect(mutation.variables).toEqual({ path0: "src/a.ts", path1: "src/b.ts" });
1776 expect(mutation.variables).toEqual({
1777 pullRequestId: "PR_1",
1778 f0_path: "src/a.ts",
1779 f1_path: "src/b.ts",
1780 });
17771781 });
17781782
17791783 it("keeps a path out of the document, so one cannot be read as part of it", () => {
1780 const mutation = buildSetFilesViewedGraphQlMutation([
1784 const mutation = buildSetFilesViewedGraphQlMutation("PR_1", [
17811785 { path: '") { __typename } evil: markFileAsViewed(input: { path: "x', viewed: true },
17821786 ]);
17831787 expect(mutation).not.toBeNull();
17841788 if (mutation === null) return;
17851789 expect(mutation.query).not.toContain("evil");
1786 expect(mutation.variables.path0).toBe(
1790 expect(mutation.variables.f0_path).toBe(
17871791 '") { __typename } evil: markFileAsViewed(input: { path: "x',
17881792 );
17891793 });
......@@ -1922,9 +1926,14 @@ describe("pull request stack membership batches", () => {
19221926 expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [0])).toBeNull();
19231927 expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [1.5])).toBeNull();
19241928 expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [])).toBeNull();
1925 expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [7, 8])).toContain(
1926 "pullRequest(number: 8)",
1927 );
1929 expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [7, 8])?.variables).toEqual({
1930 s0_owner: "acme",
1931 s0_name: "web",
1932 s0_number: 7,
1933 s1_owner: "acme",
1934 s1_name: "web",
1935 s1_number: 8,
1936 });
19281937 });
19291938});
19301939
......@@ -1982,10 +1991,10 @@ describe("batched pull request summaries", () => {
19821991
19831992 it("reads stack membership only where the document asked for it", () => {
19841993 expect(
1985 buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }], true),
1994 buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }], true)?.query,
19861995 ).toContain("stack { number size baseRefName } stackEntry { position }");
19871996 expect(
1988 buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }]),
1997 buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }])?.query,
19891998 ).not.toContain("stack {");
19901999 const pullRequest = (number: number, stack: Record<string, unknown>) => ({
19912000 pullRequest: {
......@@ -2019,16 +2028,22 @@ describe("batched pull request summaries", () => {
20192028
20202029describe("pull request watch fingerprints", () => {
20212030 it("asks for every pull request in one aliased read, and refuses an unsafe selector", () => {
2022 const query = buildPullRequestWatchFingerprintsGraphQlQuery([
2031 const document = buildPullRequestWatchFingerprintsGraphQlQuery([
20232032 { repository: "pingdotgg/t3code", number: 7 },
20242033 { repository: "pingdotgg/lakebed", number: 8 },
20252034 ]);
2026 expect(query).toContain(
2027 'w0: repository(owner: "pingdotgg", name: "t3code") { pullRequest(number: 7)',
2028 );
2029 expect(query).toContain(
2030 'w1: repository(owner: "pingdotgg", name: "lakebed") { pullRequest(number: 8)',
2031 );
2035 expect(document?.query).toContain(
2036 "w0: repository(owner: $w0_owner, name: $w0_name) { pullRequest(number: $w0_number)",
2037 );
2038 expect(document?.query).toContain("w1: repository(owner: $w1_owner, name: $w1_name)");
2039 expect(document?.variables).toEqual({
2040 w0_owner: "pingdotgg",
2041 w0_name: "t3code",
2042 w0_number: 7,
2043 w1_owner: "pingdotgg",
2044 w1_name: "lakebed",
2045 w1_number: 8,
2046 });
20322047 expect(
20332048 buildPullRequestWatchFingerprintsGraphQlQuery([{ repository: 'evil") { x', number: 1 }]),
20342049 ).toBeNull();
apps/server/src/pullRequest/gitHubPullRequestJson.ts+87-78
......@@ -35,6 +35,7 @@ import type {
3535import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
3636import { decodeJsonResult } from "@t3tools/shared/schemaJson";
3737
38import { aliasedGraphQlDocument, type GraphQlDocument } from "../sourceControl/githubGraphQl.ts";
3839import { dedupeChecks } from "./pullRequestChecks.ts";
3940
4041/**
......@@ -1917,53 +1918,81 @@ function toStackMembership(
19171918 : undefined;
19181919}
19191920
1920/** What a repository selector may hold before it is written into a GraphQL document unquoted. */
1921/** What GitHub allows in an owner or repository name. */
19211922const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/;
19221923
1924/**
1925 * The owner and name of an `owner/name` selector, or null for one GitHub cannot name. A batch is
1926 * one document, and a single unanswerable alias fails all of it, so these are refused up front.
1927 */
1928function repositoryParts(
1929 repository: string,
1930): { readonly owner: string; readonly name: string } | null {
1931 const [owner, name, ...rest] = repository.trim().split("/");
1932 if (rest.length > 0 || owner === undefined || name === undefined) return null;
1933 return REPOSITORY_PART.test(owner) && REPOSITORY_PART.test(name) ? { owner, name } : null;
1934}
1935
1936const isPullRequestNumber = (number: number) => Number.isSafeInteger(number) && number > 0;
1937
1938/** One aliased `repository { pullRequest { selection } }` per change request, on one host. */
1939function aliasedPullRequestsDocument(
1940 name: string,
1941 alias: string,
1942 changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>,
1943 selection: string,
1944): GraphQlDocument | null {
1945 const addressed = changeRequests.map((changeRequest) => ({
1946 parts: repositoryParts(changeRequest.repository),
1947 number: changeRequest.number,
1948 }));
1949 if (addressed.some(({ parts, number }) => parts === null || !isPullRequestNumber(number))) {
1950 return null;
1951 }
1952 return aliasedGraphQlDocument({
1953 operation: "query",
1954 name,
1955 alias,
1956 items: addressed,
1957 variables: ({ parts, number }) => ({
1958 owner: ["String!", parts!.owner],
1959 name: ["String!", parts!.name],
1960 number: ["Int!", number],
1961 }),
1962 field: ({ owner, name, number }) =>
1963 `repository(owner: ${owner}, name: ${name}) { pullRequest(number: ${number}) { ${selection} } }`,
1964 });
1965}
1966
19231967/**
19241968 * The line counts for rows a listing already handed over, as one aliased lookup each.
19251969 *
19261970 * Aliases rather than `nodes(ids:)` because the caller asks in the terms the page holds — a
1927 * repository and a number — and never sees a node id. Owner, name and number are written into
1928 * the document, so each is checked against what GitHub can actually name first: null for anything
1929 * else, which the caller reports rather than sends.
1930 *
1931 * Null too for an empty request, since a GraphQL document with no selection is not a document.
1971 * repository and a number — and never sees a node id. Null for a selector GitHub cannot name,
1972 * which the caller reports rather than sends, and for an empty request.
19321973 */
19331974export function buildPullRequestStatsGraphQlQuery(
19341975 changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>,
1935): string | null {
1936 if (changeRequests.length === 0) return null;
1937 const selections: string[] = [];
1938 for (const [index, changeRequest] of changeRequests.entries()) {
1939 const [owner, name, ...rest] = changeRequest.repository.trim().split("/");
1940 if (rest.length > 0 || owner === undefined || name === undefined) return null;
1941 if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null;
1942 if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null;
1943 selections.push(
1944 ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { additions deletions } }`,
1945 );
1946 }
1947 return `query {\n${selections.join("\n")}\n}`;
1976): GraphQlDocument | null {
1977 return aliasedPullRequestsDocument(
1978 "PullRequestStats",
1979 "s",
1980 changeRequests,
1981 "additions deletions",
1982 );
19481983}
19491984
19501985/** Stack membership for the visible rows of a per-repository listing. */
19511986export function buildPullRequestStackMembershipsGraphQlQuery(
19521987 repository: string,
19531988 numbers: ReadonlyArray<number>,
1954): string | null {
1955 if (numbers.length === 0) return null;
1956 const [owner, name, ...rest] = repository.trim().split("/");
1957 if (rest.length > 0 || owner === undefined || name === undefined) return null;
1958 if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null;
1959 const selections: string[] = [];
1960 for (const [index, number] of numbers.entries()) {
1961 if (!Number.isSafeInteger(number) || number <= 0) return null;
1962 selections.push(
1963 ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${number}) { stack { number size baseRefName } stackEntry { position } } }`,
1964 );
1965 }
1966 return `query PullRequestStackMemberships {\n${selections.join("\n")}\n}`;
1989): GraphQlDocument | null {
1990 return aliasedPullRequestsDocument(
1991 "PullRequestStackMemberships",
1992 "s",
1993 numbers.map((number) => ({ repository, number })),
1994 STACK_MEMBERSHIP_SELECTION,
1995 );
19671996}
19681997
19691998const decodeStackMemberships = decodeJsonResult(RawStackMembershipsSchema);
......@@ -2024,26 +2053,17 @@ const PULL_REQUEST_SUMMARY_SELECTION =
20242053 "latestReviews(first: 20) { nodes { state author { login } } } " +
20252054 "commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }";
20262055
2027/**
2028 * Summaries for pull requests anywhere on one host, one aliased lookup each. Checked and written
2029 * into the document the way `buildPullRequestStatsGraphQlQuery` does, so null means "do not send".
2030 */
2056/** Summaries for pull requests anywhere on one host, one aliased lookup each; null when unsafe. */
20312057export function buildPullRequestSummariesGraphQlQuery(
20322058 changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>,
20332059 includeStacks = false,
2034): string | null {
2035 if (changeRequests.length === 0) return null;
2036 const selections: string[] = [];
2037 for (const [index, changeRequest] of changeRequests.entries()) {
2038 const [owner, name, ...rest] = changeRequest.repository.trim().split("/");
2039 if (rest.length > 0 || owner === undefined || name === undefined) return null;
2040 if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null;
2041 if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null;
2042 selections.push(
2043 ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""} } }`,
2044 );
2045 }
2046 return `query PullRequestSummaries {\n${selections.join("\n")}\n}`;
2060): GraphQlDocument | null {
2061 return aliasedPullRequestsDocument(
2062 "PullRequestSummaries",
2063 "s",
2064 changeRequests,
2065 `${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""}`,
2066 );
20472067}
20482068
20492069/** One pull request's summary, aliased the way the batch is so the same decoder reads it. */
......@@ -2170,19 +2190,13 @@ const PULL_REQUEST_WATCH_FINGERPRINT_SELECTION =
21702190/** Watch fingerprints for pull requests on one host, one aliased lookup each; null when unsafe. */
21712191export function buildPullRequestWatchFingerprintsGraphQlQuery(
21722192 changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>,
2173): string | null {
2174 if (changeRequests.length === 0) return null;
2175 const selections: string[] = [];
2176 for (const [index, changeRequest] of changeRequests.entries()) {
2177 const [owner, name, ...rest] = changeRequest.repository.trim().split("/");
2178 if (rest.length > 0 || owner === undefined || name === undefined) return null;
2179 if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null;
2180 if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null;
2181 selections.push(
2182 ` w${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_WATCH_FINGERPRINT_SELECTION} } }`,
2183 );
2184 }
2185 return `query PullRequestWatchFingerprints {\n${selections.join("\n")}\n}`;
2193): GraphQlDocument | null {
2194 return aliasedPullRequestsDocument(
2195 "PullRequestWatchFingerprints",
2196 "w",
2197 changeRequests,
2198 PULL_REQUEST_WATCH_FINGERPRINT_SELECTION,
2199 );
21862200}
21872201
21882202/**
......@@ -3357,25 +3371,20 @@ export function decodePullRequestFilesViewedJson(
33573371 * request each. GitHub has no bulk form of `markFileAsViewed`/`unmarkFileAsViewed`, which each
33583372 * take a single path, so the batching is done with aliases; top-level mutation fields run in
33593373 * write order, so the last word about a path is the one that sticks.
3360 *
3361 * Paths travel as variables rather than interpolated into the document, since a path is data
3362 * and a document is not.
33633374 */
33643375export function buildSetFilesViewedGraphQlMutation(
3376 pullRequestId: string,
33653377 files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>,
3366): { readonly query: string; readonly variables: Readonly<Record<string, string>> } | null {
3367 if (files.length === 0) return null;
3368 const parameters = files.map((_, index) => `$path${index}: String!`).join(", ");
3369 const fields = files
3370 .map(
3371 (file, index) =>
3372 ` f${index}: ${file.viewed ? "markFileAsViewed" : "unmarkFileAsViewed"}(input: { pullRequestId: $pullRequestId, path: $path${index} }) { clientMutationId }`,
3373 )
3374 .join("\n");
3375 return {
3376 query: `mutation($pullRequestId: ID!, ${parameters}) {\n${fields}\n}`,
3377 variables: Object.fromEntries(files.map((file, index) => [`path${index}`, file.path])),
3378 };
3378): GraphQlDocument | null {
3379 return aliasedGraphQlDocument({
3380 operation: "mutation",
3381 alias: "f",
3382 items: files,
3383 shared: { pullRequestId: ["ID!", pullRequestId] },
3384 variables: (file) => ({ path: ["String!", file.path] }),
3385 field: ({ path }, file) =>
3386 `${file.viewed ? "markFileAsViewed" : "unmarkFileAsViewed"}(input: { pullRequestId: $pullRequestId, path: ${path} }) { clientMutationId }`,
3387 });
33793388}
33803389
33813390/** One pull request as the stacks API lists it: a number, a head, and whether it is done. */
apps/server/src/pullRequest/githubStackActions.ts+13-7
......@@ -9,6 +9,7 @@ import * as Result from "effect/Result";
99import * as Schema from "effect/Schema";
1010
1111import * as GitHubApi from "../sourceControl/GitHubApi.ts";
12import { aliasedGraphQlDocument } from "../sourceControl/githubGraphQl.ts";
1213import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts";
1314import { cascadeRebaseStack } from "./githubStackRebase.ts";
1415
......@@ -192,13 +193,18 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*
192193 host: input.host,
193194 operation: "runGitHubStackAction",
194195 allowReserve: true,
195 variables: { owner, name },
196 query: `query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open
197 .map(
198 (layer) =>
199 `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`,
200 )
201 .join(" ")}}}`,
196 // `open` is non-empty here, so there is always a document.
197 ...aliasedGraphQlDocument({
198 operation: "query",
199 alias: "pr",
200 key: (layer) => layer.number,
201 items: open,
202 shared: { owner: ["String!", owner], name: ["String!", name] },
203 variables: (layer) => ({ number: ["Int!", layer.number] }),
204 field: ({ number }) =>
205 `pullRequest(number: ${number}) { headRepository { viewerPermission } maintainerCanModify }`,
206 within: (fields) => `repository(owner: $owner, name: $name) {\n${fields}\n}`,
207 })!,
202208 });
203209 const access = yield* decodeBranchAccess(permissions).pipe(
204210 Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })),
apps/server/src/sourceControl/githubGraphQl.test.ts created+105
......@@ -0,0 +1,105 @@
1import { assert, describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3
4import { aliasedGraphQlDocument, readGraphQlPages } from "./githubGraphQl.ts";
5
6describe("aliasedGraphQlDocument", () => {
7 it("keeps every value out of the document, each under its alias's own variable", () => {
8 const document = aliasedGraphQlDocument({
9 operation: "query",
10 name: "Lookups",
11 alias: "s",
12 items: ['acme") { x } #', "web"],
13 shared: { owner: ["String!", "acme"] },
14 variables: (repo) => ({ name: ["String!", repo] }),
15 field: ({ name }) => `repository(owner: $owner, name: ${name}) { id }`,
16 });
17 assert.isNotNull(document);
18 expect(document!.query).toBe(
19 "query Lookups($owner: String!, $s0_name: String!, $s1_name: String!) {\n" +
20 " s0: repository(owner: $owner, name: $s0_name) { id }\n" +
21 " s1: repository(owner: $owner, name: $s1_name) { id }\n}",
22 );
23 expect(document!.variables).toEqual({
24 owner: "acme",
25 s0_name: 'acme") { x } #',
26 s1_name: "web",
27 });
28 });
29
30 it("aliases by key and nests inside a shared parent when asked", () => {
31 const document = aliasedGraphQlDocument({
32 operation: "query",
33 alias: "pr",
34 key: (number) => number,
35 items: [2, 3],
36 variables: (number) => ({ number: ["Int!", number] }),
37 field: ({ number }) => `pullRequest(number: ${number}) { id }`,
38 within: (fields) => `repository(owner: "a", name: "b") {\n${fields}\n}`,
39 });
40 expect(document?.query).toContain(" pr2: pullRequest(number: $pr2_number) { id }");
41 expect(document?.query).toContain(" pr3: pullRequest(number: $pr3_number) { id }");
42 expect(document?.variables).toEqual({ pr2_number: 2, pr3_number: 3 });
43 });
44
45 it("asks for nothing when there is nothing to ask", () => {
46 expect(
47 aliasedGraphQlDocument({
48 operation: "mutation",
49 alias: "f",
50 items: [],
51 variables: () => ({}),
52 field: () => "x",
53 }),
54 ).toBeNull();
55 });
56});
57
58describe("readGraphQlPages", () => {
59 const pages = (cursors: ReadonlyArray<string | null>) => {
60 const asked: Array<string | null> = [];
61 const read = (after: string | null) =>
62 Effect.sync(() => {
63 asked.push(after);
64 return { next: cursors[asked.length - 1] ?? null };
65 });
66 return { asked, read };
67 };
68
69 it.effect("follows the cursor until GitHub has no more", () =>
70 Effect.gen(function* () {
71 const { asked, read } = pages(["a", "b", null]);
72 const result = yield* readGraphQlPages(read, { nextCursor: (page) => page.next });
73 expect(asked).toEqual([null, "a", "b"]);
74 expect(result.truncated).toBe(false);
75 }),
76 );
77
78 it.effect("stops as truncated at the page limit, at `until`, or on a repeated cursor", () =>
79 Effect.gen(function* () {
80 const limited = pages(["a", "b", "c"]);
81 const atLimit = yield* readGraphQlPages(limited.read, {
82 nextCursor: (page) => page.next,
83 maxPages: 2,
84 });
85 expect(limited.asked).toEqual([null, "a"]);
86 expect(atLimit.truncated).toBe(true);
87
88 const enough = pages(["a", "b", "c"]);
89 const satisfied = yield* readGraphQlPages(enough.read, {
90 nextCursor: (page) => page.next,
91 until: (read) => read.length === 1,
92 });
93 expect(enough.asked).toEqual([null]);
94 expect(satisfied.truncated).toBe(true);
95
96 const stuck = pages(["a", "a", "a"]);
97 const repeated = yield* readGraphQlPages(stuck.read, {
98 nextCursor: (page) => page.next,
99 from: "start",
100 });
101 expect(stuck.asked).toEqual(["start", "a"]);
102 expect(repeated.truncated).toBe(true);
103 }),
104 );
105});
apps/server/src/sourceControl/githubGraphQl.ts created+100
......@@ -0,0 +1,100 @@
1import * as Effect from "effect/Effect";
2
3/** GraphQL variables by name, each with the type it is declared as and the value it carries. */
4export type GraphQlVariables = Readonly<Record<string, readonly [type: string, value: unknown]>>;
5
6export interface GraphQlDocument {
7 readonly query: string;
8 readonly variables: Readonly<Record<string, unknown>>;
9}
10
11/**
12 * One document asking the same field for many items, each under its own alias: the prefix and
13 * the item's position (`s0`, `s1`, …), or its `key` when the answer is easier read by one. GitHub
14 * has no bulk form of most lookups, so aliases are how a batch travels in one request.
15 *
16 * Every value travels as a variable renamed per alias (`$s0_owner`), and `field` is handed those
17 * placeholders rather than the values: nothing an item holds is written into the document.
18 * `shared` variables are declared once and referenced by their own name; `within` nests the aliased
19 * fields inside one parent selection, such as a single repository.
20 *
21 * Null for no items, since a document with no selection is not a document.
22 */
23export function aliasedGraphQlDocument<Item, Variables extends GraphQlVariables>(input: {
24 readonly operation: "query" | "mutation";
25 /** The operation's name, which tells one batch from another in logs and recorded traffic. */
26 readonly name?: string;
27 readonly alias: string;
28 readonly key?: (item: Item, index: number) => number;
29 readonly items: ReadonlyArray<Item>;
30 readonly variables: (item: Item) => Variables;
31 readonly field: (
32 placeholders: { readonly [Name in keyof Variables]: string },
33 item: Item,
34 ) => string;
35 readonly shared?: GraphQlVariables;
36 readonly within?: (fields: string) => string;
37}): GraphQlDocument | null {
38 if (input.items.length === 0) return null;
39 const declarations: string[] = [];
40 const variables: Record<string, unknown> = {};
41 for (const [name, [type, value]] of Object.entries(input.shared ?? {})) {
42 declarations.push(`$${name}: ${type}`);
43 variables[name] = value;
44 }
45 const fields = input.items.map((item, index) => {
46 const alias = `${input.alias}${input.key?.(item, index) ?? index}`;
47 const placeholders: Record<string, string> = {};
48 for (const [name, [type, value]] of Object.entries(input.variables(item))) {
49 const variable = `${alias}_${name}`;
50 declarations.push(`$${variable}: ${type}`);
51 variables[variable] = value;
52 placeholders[name] = `$${variable}`;
53 }
54 return ` ${alias}: ${input.field(placeholders as { readonly [Name in keyof Variables]: string }, item)}`;
55 });
56 const selection = fields.join("\n");
57 const parameters = declarations.length === 0 ? "" : `(${declarations.join(", ")})`;
58 return {
59 query: `${input.operation}${input.name === undefined ? "" : ` ${input.name}`}${parameters} {\n${input.within?.(selection) ?? selection}\n}`,
60 variables,
61 };
62}
63
64/**
65 * The pages of a GraphQL connection, read one after another from `from` (the first page by
66 * default) until GitHub has no more, `until` is satisfied, or `maxPages` have been read. `read` is
67 * handed the pages so far, for reads that size or check the next page by what has already arrived.
68 *
69 * `truncated` says pages remained when reading stopped. A cursor GitHub hands back a second time
70 * would page forever, so it stops the read as truncated too.
71 */
72export const readGraphQlPages = <Page, E, R>(
73 read: (after: string | null, pages: ReadonlyArray<Page>) => Effect.Effect<Page, E, R>,
74 options: {
75 readonly nextCursor: (page: NoInfer<Page>) => string | null;
76 readonly from?: string | null;
77 readonly maxPages?: number;
78 readonly until?: (pages: ReadonlyArray<NoInfer<Page>>) => boolean;
79 },
80): Effect.Effect<{ readonly pages: ReadonlyArray<Page>; readonly truncated: boolean }, E, R> =>
81 Effect.gen(function* () {
82 const pages: Page[] = [];
83 const seen = new Set<string>();
84 let after = options.from ?? null;
85 while (true) {
86 const page: Page = yield* read(after, pages);
87 pages.push(page);
88 const next = options.nextCursor(page);
89 if (next === null) return { pages, truncated: false };
90 if (
91 seen.has(next) ||
92 pages.length >= (options.maxPages ?? Infinity) ||
93 options.until?.(pages) === true
94 ) {
95 return { pages, truncated: true };
96 }
97 seen.add(next);
98 after = next;
99 }
100 });