authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 20:50:49-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 20:50:49-07:00
log18de6bb328059703b5d1b0661610076e3c0fa2d4
tree12801304a4d550fe3eb85ccae15001f116c2996d
parent5975ec78b7eaff654e6183b99f8de7a05591e99d
signature Signed by PGP key B5690EEEBB952194

fix(server): background PR sync reads summaries in batches (#13198)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

5 files changed, 436 insertions(+), 96 deletions(-)

apps/server/src/orchestration/PullRequestSyncReactor.ts+3-1
......@@ -303,7 +303,9 @@ export const make = Effect.gen(function* () {
303303 Effect.catchCause(logSkipped("pull request sync skipped", { key })),
304304 )
305305 : Effect.void,
306 { concurrency: 8, discard: true },
306 // As wide as one batched summary read, so the sweep's reads on a host arrive together and
307 // GitHub answers them in one request rather than one `gh pr view` apiece.
308 { concurrency: 25, discard: true },
307309 );
308310 });
309311
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+104-45
......@@ -452,77 +452,137 @@ layer("GitHubPullRequestCli.layer", (it) => {
452452 }),
453453 );
454454
455 it.effect("reads linked pull request status with the overview fields in one request", () =>
455 it.effect("reads linked pull requests on one host together, filed back by position", () =>
456456 Effect.gen(function* () {
457 const node = (number: number) => ({
458 number,
459 title: `Pull request ${number}`,
460 url: `https://github.com/acme/web/pull/${number}`,
461 author: { __typename: "User", login: "octocat", name: "Octo Cat", avatarUrl: null },
462 baseRefName: "main",
463 headRefName: `feat/${number}`,
464 state: "OPEN",
465 isDraft: false,
466 mergeable: "MERGEABLE",
467 reviewDecision: null,
468 latestReviews: { nodes: [{ state: "APPROVED", author: { login: "reviewer" } }] },
469 additions: 12,
470 deletions: 3,
471 changedFiles: 2,
472 updatedAt: "2026-08-24T12:34:56.000Z",
473 mergedAt: null,
474 closedAt: null,
475 commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] },
476 });
457477 mockedExecute.mockReturnValueOnce(
458478 Effect.succeed(
459479 output(
460480 // @effect-diagnostics-next-line preferSchemaOverJson:off
461481 JSON.stringify({
462 number: 7,
463 title: "Reuse the summary",
464 url: "https://github.com/acme/web/pull/7",
465 author: { login: "octocat", name: "Octo Cat" },
466 baseRefName: "main",
467 headRefName: "feat/summary",
468 state: "OPEN",
469 isDraft: false,
470 mergeable: "MERGEABLE",
471 reviewDecision: "APPROVED",
472 additions: 12,
473 deletions: 3,
474 changedFiles: 2,
475 createdAt: "2026-08-20T00:00:00.000Z",
476 updatedAt: "2026-08-24T12:34:56.000Z",
477 reviewRequests: [],
478 labels: [],
479 statusCheckRollup: [
480 { __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS", name: "ci" },
481 ],
482 body: "",
482 data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } },
483483 }),
484484 ),
485485 ),
486486 );
487487 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
488488
489 const summary = yield* cli.getPullRequestSummary({
490 cwd: "/w",
491 repository: "acme/web",
492 host: "github.com",
493 number: 7,
494 });
489 const reads = yield* Effect.forEach(
490 [7, 8],
491 (number) =>
492 cli.getPullRequestSummary({
493 cwd: "/w",
494 repository: "acme/web",
495 host: "github.com",
496 number,
497 }),
498 { concurrency: "unbounded" },
499 ).pipe(Effect.forkChild);
500 yield* TestClock.adjust("10 millis");
501 const [seven, eight] = yield* Fiber.join(reads);
495502
496503 assert.deepStrictEqual(
497504 {
498 number: summary.number,
499 state: summary.state,
500 headBranch: summary.headBranch,
501 isDraft: summary.isDraft,
502 author: summary.author?.login,
503 additions: summary.additions,
504 deletions: summary.deletions,
505 changedFiles: summary.changedFiles,
506 reviewDecision: summary.reviewDecision,
507 checksState: summary.checksState,
508 mergeability: summary.mergeability,
505 number: seven?.number,
506 state: seven?.state,
507 headBranch: seven?.headBranch,
508 author: seven?.author?.login,
509 changedFiles: seven?.changedFiles,
510 reviewDecision: seven?.reviewDecision,
511 checksState: seven?.checksState,
512 mergeability: seven?.mergeability,
509513 },
510514 {
511515 number: 7,
512516 state: "open",
513 headBranch: "feat/summary",
514 isDraft: false,
517 headBranch: "feat/7",
515518 author: "octocat",
516 additions: 12,
517 deletions: 3,
518519 changedFiles: 2,
519520 reviewDecision: "approved",
520521 checksState: "passing",
521522 mergeability: "mergeable",
522523 },
523524 );
525 assert.strictEqual(eight?.headBranch, "feat/8");
524526 expect(mockedExecute).toHaveBeenCalledOnce();
525 expect(mockedExecute.mock.calls[0]?.[0]?.args).toEqual([
527 const document = callAt(0).args.at(-1) ?? "";
528 expect(document).toContain(
529 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)',
530 );
531 expect(document).toContain("pullRequest(number: 8)");
532 }),
533 );
534
535 it.effect("reads a pull request the batch said nothing about on its own", () =>
536 Effect.gen(function* () {
537 mockedExecute
538 .mockReturnValueOnce(Effect.succeed(output('{"data":{"s0":{"pullRequest":null}}}')))
539 .mockReturnValueOnce(
540 Effect.succeed(
541 output(
542 // @effect-diagnostics-next-line preferSchemaOverJson:off
543 JSON.stringify({
544 number: 7,
545 title: "Reuse the summary",
546 url: "https://github.com/acme/web/pull/7",
547 author: { login: "octocat", name: "Octo Cat" },
548 baseRefName: "main",
549 headRefName: "feat/summary",
550 state: "OPEN",
551 isDraft: false,
552 mergeable: "MERGEABLE",
553 reviewDecision: "APPROVED",
554 additions: 12,
555 deletions: 3,
556 changedFiles: 2,
557 createdAt: "2026-08-20T00:00:00.000Z",
558 updatedAt: "2026-08-24T12:34:56.000Z",
559 reviewRequests: [],
560 labels: [],
561 statusCheckRollup: [
562 {
563 __typename: "CheckRun",
564 status: "COMPLETED",
565 conclusion: "SUCCESS",
566 name: "ci",
567 },
568 ],
569 body: "",
570 }),
571 ),
572 ),
573 );
574 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
575
576 const read = yield* cli
577 .getPullRequestSummary({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 })
578 .pipe(Effect.forkChild);
579 yield* TestClock.adjust("10 millis");
580 const summary = yield* Fiber.join(read);
581
582 assert.strictEqual(summary.headBranch, "feat/summary");
583 assert.strictEqual(summary.checksState, "passing");
584 assert.strictEqual(mockedExecute.mock.calls.length, 2);
585 expect(callAt(1).args).toEqual([
526586 "pr",
527587 "view",
528588 "7",
......@@ -531,7 +591,6 @@ layer("GitHubPullRequestCli.layer", (it) => {
531591 "--json",
532592 expect.stringContaining("statusCheckRollup"),
533593 ]);
534 expect(mockedGetPullRequest).not.toHaveBeenCalled();
535594 }),
536595 );
537596
apps/server/src/pullRequest/GitHubPullRequestCli.ts+153-50
......@@ -1,10 +1,15 @@
11import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";
2import * as Cause from "effect/Cause";
23import * as Context from "effect/Context";
34import * as Clock from "effect/Clock";
45import * as NodeCrypto from "node:crypto";
56import * as Effect from "effect/Effect";
67import * as Layer from "effect/Layer";
8import * as Exit from "effect/Exit";
9import * as Option from "effect/Option";
710import * as Redacted from "effect/Redacted";
11import * as Request from "effect/Request";
12import * as RequestResolver from "effect/RequestResolver";
813import * as Result from "effect/Result";
914import * as Schema from "effect/Schema";
1015import * as Semaphore from "effect/Semaphore";
......@@ -48,6 +53,7 @@ import {
4853 decodePullRequestCoreJson,
4954 PULL_REQUEST_CORE_GRAPHQL_QUERY,
5055 type GitHubPullRequestCore,
56 type GitHubPullRequestSummary,
5157 decodePullRequestPreviewJson,
5258 PULL_REQUEST_PREVIEW_GRAPHQL_QUERY,
5359 decodePullRequestFilesJson,
......@@ -58,6 +64,7 @@ import {
5864 decodePullRequestSearchJson,
5965 decodePullRequestStacksJson,
6066 decodePullRequestStatsJson,
67 decodePullRequestSummariesJson,
6168 decodeReactionSubjectScopeJson,
6269 decodeReviewerCandidatesJson,
6370 decodeLabelCandidatesJson,
......@@ -67,6 +74,7 @@ import {
6774 decodeReviewThreadCommentsJson,
6875 decodeReviewThreadsJson,
6976 buildPullRequestStatsGraphQlQuery,
77 buildPullRequestSummariesGraphQlQuery,
7078 buildPullRequestStackMembershipsGraphQlQuery,
7179 decodePullRequestStackMembershipsJson,
7280 encodeGraphQlRequestJson,
......@@ -419,6 +427,23 @@ export interface GitHubPullRequestStat {
419427 */
420428const STAT_ALIASES_PER_REQUEST = 25;
421429const STAT_REQUEST_CONCURRENCY = 4;
430/**
431 * How long a summary read waits for company. The background sync asks for every linked pull
432 * request at once, and each read reaches the resolver after its own cache check, so a batch
433 * needs a moment longer than one scheduler tick to gather them.
434 */
435const SUMMARY_BATCH_WINDOW = "10 millis";
436
437class PullRequestSummaryRead extends Request.Class<
438 {
439 readonly cwd: string;
440 readonly repository: string;
441 readonly host: string;
442 readonly number: number;
443 },
444 ProviderChangeRequestSummary,
445 GitHubPullRequestCliError
446> {}
422447
423448export interface GitHubPullRequestSearchBatch {
424449 /** Rows across every repository asked for, newest update first, each naming its own. */
......@@ -1727,6 +1752,133 @@ export const make = Effect.gen(function* () {
17271752 { concurrency: 2 },
17281753 ).pipe(Effect.map(([, runs]) => runs));
17291754
1755 // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands
1756 // the thread overview its author, diff stat, review decision and checks in the same read.
1757 const viewPullRequestSummary = (input: PullRequestSummaryRead) =>
1758 github
1759 .execute({
1760 cwd: input.cwd,
1761 args: [
1762 "pr",
1763 "view",
1764 String(input.number),
1765 ...repositoryArgs(input),
1766 "--json",
1767 PULL_REQUEST_DETAIL_JSON_FIELDS,
1768 ],
1769 })
1770 .pipe(
1771 Effect.flatMap((result) => {
1772 const decoded = decodePullRequestDetailJson(result.stdout.trim());
1773 if (!Result.isSuccess(decoded)) {
1774 return Effect.fail(
1775 new GitHubPullRequestReadError({
1776 command: "gh",
1777 cwd: input.cwd,
1778 operation: "getPullRequestSummary",
1779 cause: decoded.failure,
1780 }),
1781 );
1782 }
1783 const detail = decoded.success;
1784 return Effect.succeed({
1785 number: detail.number,
1786 title: detail.title,
1787 url: detail.url,
1788 headBranch: detail.headBranch,
1789 baseBranch: detail.baseBranch,
1790 state: detail.state,
1791 updatedAt: detail.updatedAt,
1792 closedAt: detail.closedAt ?? null,
1793 mergedAt: detail.mergedAt ?? null,
1794 isDraft: detail.isDraft,
1795 author: detail.author,
1796 additions: detail.additions,
1797 deletions: detail.deletions,
1798 changedFiles: detail.changedFiles,
1799 reviewDecision: detail.reviewDecision,
1800 checksState: detail.checksState,
1801 mergeability: detail.mergeability,
1802 });
1803 }),
1804 );
1805
1806 /**
1807 * Summaries asked for together, on one host under one credential, share aliased GraphQL reads
1808 * of twenty-five: the background sync reads every linked pull request each minute, and one
1809 * `gh pr view` apiece is most of what it spends. Whatever the batch cannot answer — a selector
1810 * GraphQL cannot address, a pull request GitHub returned nothing for — is read on its own.
1811 */
1812 const summaryResolver = RequestResolver.makeGrouped<PullRequestSummaryRead, string>({
1813 key: ({ request, context }) =>
1814 JSON.stringify([
1815 request.host.toLowerCase(),
1816 Context.getOrElse(context, GitHubCli.PinnedGitHubCredential, () => null)
1817 ?.credentialFingerprint ?? null,
1818 Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""),
1819 ]),
1820 resolver: (entries) => {
1821 const [first] = entries;
1822 const batchable = entries.filter(
1823 (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null,
1824 );
1825 const query = buildPullRequestSummariesGraphQlQuery(batchable.map((entry) => entry.request));
1826 const batched =
1827 query === null
1828 ? Effect.succeed(new Map<number, GitHubPullRequestSummary>())
1829 : graphqlRead({
1830 cwd: first.request.cwd,
1831 host: first.request.host,
1832 operation: "getPullRequestSummary",
1833 query,
1834 decode: decodePullRequestSummariesJson,
1835 });
1836 return batched.pipe(
1837 // A GraphQL error anywhere fails the whole document — one repository gone or out of
1838 // reach — so a batch that could not be read leaves every entry to its own read. A paused
1839 // budget is the exception: reading one at a time would only spend what is being saved.
1840 Effect.catchCauseIf(
1841 (cause) =>
1842 !Cause.hasInterruptsOnly(cause) &&
1843 !Cause.findErrorOption(cause).pipe(
1844 Option.exists((error) => error._tag === "SourceControlRateLimitPausedError"),
1845 ),
1846 (cause) =>
1847 Effect.logDebug("batched pull request summary read failed", { cause }).pipe(
1848 Effect.as(new Map<number, GitHubPullRequestSummary>()),
1849 ),
1850 ),
1851 Effect.flatMap((summaries) => {
1852 const unanswered = entries.filter((entry) => {
1853 const summary = summaries.get(batchable.indexOf(entry));
1854 if (summary === undefined) return true;
1855 entry.completeUnsafe(Exit.succeed(summary));
1856 return false;
1857 });
1858 return Effect.forEach(
1859 unanswered,
1860 (entry) =>
1861 viewPullRequestSummary(entry.request).pipe(
1862 Effect.exit,
1863 Effect.map((exit) => entry.completeUnsafe(exit)),
1864 ),
1865 { concurrency: STAT_REQUEST_CONCURRENCY, discard: true },
1866 );
1867 }),
1868 Effect.catchCause((cause) =>
1869 Effect.sync(() => {
1870 for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause));
1871 }),
1872 ),
1873 );
1874 },
1875 }).pipe(
1876 RequestResolver.setDelay(SUMMARY_BATCH_WINDOW),
1877 RequestResolver.batchN(STAT_ALIASES_PER_REQUEST),
1878 );
1879 const getPullRequestSummary: GitHubPullRequestCli["Service"]["getPullRequestSummary"] = (input) =>
1880 Effect.request(new PullRequestSummaryRead(input), summaryResolver);
1881
17301882 return GitHubPullRequestCli.of({
17311883 withVerifiedCredential,
17321884 getRoutingIdentity,
......@@ -1933,56 +2085,7 @@ export const make = Effect.gen(function* () {
19332085 ).pipe(Effect.map((results) => results.flat()));
19342086 },
19352087
1936 // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands
1937 // the thread overview its author, diff stat, review decision and checks in the same read.
1938 getPullRequestSummary: (input) =>
1939 github
1940 .execute({
1941 cwd: input.cwd,
1942 args: [
1943 "pr",
1944 "view",
1945 String(input.number),
1946 ...repositoryArgs(input),
1947 "--json",
1948 PULL_REQUEST_DETAIL_JSON_FIELDS,
1949 ],
1950 })
1951 .pipe(
1952 Effect.flatMap((result) => {
1953 const decoded = decodePullRequestDetailJson(result.stdout.trim());
1954 if (!Result.isSuccess(decoded)) {
1955 return Effect.fail(
1956 new GitHubPullRequestReadError({
1957 command: "gh",
1958 cwd: input.cwd,
1959 operation: "getPullRequestSummary",
1960 cause: decoded.failure,
1961 }),
1962 );
1963 }
1964 const detail = decoded.success;
1965 return Effect.succeed({
1966 number: detail.number,
1967 title: detail.title,
1968 url: detail.url,
1969 headBranch: detail.headBranch,
1970 baseBranch: detail.baseBranch,
1971 state: detail.state,
1972 updatedAt: detail.updatedAt,
1973 closedAt: detail.closedAt ?? null,
1974 mergedAt: detail.mergedAt ?? null,
1975 isDraft: detail.isDraft,
1976 author: detail.author,
1977 additions: detail.additions,
1978 deletions: detail.deletions,
1979 changedFiles: detail.changedFiles,
1980 reviewDecision: detail.reviewDecision,
1981 checksState: detail.checksState,
1982 mergeability: detail.mergeability,
1983 });
1984 }),
1985 ),
2088 getPullRequestSummary,
19862089
19872090 getPullRequestDetail,
19882091 getPullRequestPreview: (input) => {
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+53
......@@ -2,6 +2,8 @@ import * as Result from "effect/Result";
22import { describe, expect, it } from "vite-plus/test";
33
44import {
5 buildPullRequestSummariesGraphQlQuery,
6 decodePullRequestSummariesJson,
57 buildReviewSubmissionJson,
68 buildPullRequestStackMembershipsGraphQlQuery,
79 decodePullRequestStackMembershipsJson,
......@@ -1883,3 +1885,54 @@ describe("pull request stack membership batches", () => {
18831885 );
18841886 });
18851887});
1888
1889describe("batched pull request summaries", () => {
1890 it("refuses a repository GraphQL cannot address, rather than writing it into the document", () => {
1891 expect(
1892 buildPullRequestSummariesGraphQlQuery([{ repository: 'acme/web") { x } #', number: 1 }]),
1893 ).toBeNull();
1894 expect(
1895 buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 0 }]),
1896 ).toBeNull();
1897 expect(buildPullRequestSummariesGraphQlQuery([])).toBeNull();
1898 });
1899
1900 it("files each answer by its alias and skips what GitHub or the decoder could not give", () => {
1901 const decoded = decodePullRequestSummariesJson(
1902 JSON.stringify({
1903 data: {
1904 s0: {
1905 pullRequest: {
1906 number: 7,
1907 title: "Merged",
1908 url: "https://github.com/acme/web/pull/7",
1909 author: { __typename: "Bot", login: "renovate", avatarUrl: "https://a/r.png" },
1910 headRefName: "feat/seven",
1911 baseRefName: "main",
1912 state: "MERGED",
1913 mergedAt: "2026-08-24T00:00:00Z",
1914 closedAt: "2026-08-24T00:00:00Z",
1915 updatedAt: "2026-08-24T00:00:00Z",
1916 commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] },
1917 },
1918 },
1919 s1: { pullRequest: null },
1920 s2: { pullRequest: { number: 9 } },
1921 rateLimit: { cost: 1 },
1922 },
1923 }),
1924 );
1925 expect(Result.isSuccess(decoded)).toBe(true);
1926 if (!Result.isSuccess(decoded)) return;
1927 expect([...decoded.success.keys()]).toEqual([0]);
1928 expect(decoded.success.get(0)).toMatchObject({
1929 number: 7,
1930 state: "merged",
1931 mergedAt: "2026-08-24T00:00:00Z",
1932 author: { login: "renovate", isBot: true },
1933 checksState: "failing",
1934 mergeability: "unknown",
1935 additions: 0,
1936 });
1937 });
1938});
apps/server/src/pullRequest/gitHubPullRequestJson.ts+123
......@@ -1847,6 +1847,129 @@ export function decodePullRequestStatsJson(
18471847 return Result.succeed(stats);
18481848}
18491849
1850/**
1851 * The fields a linked thread keeps current, for many pull requests in one aliased read. Same
1852 * shape as the search row where the two overlap: the checks arrive as GitHub's one-word rollup
1853 * rather than the whole check list `gh pr view` hands back, which is what keeps a batch cheap.
1854 */
1855const PULL_REQUEST_SUMMARY_SELECTION =
1856 "number title url state isDraft mergeable reviewDecision additions deletions changedFiles " +
1857 "updatedAt mergedAt closedAt headRefName baseRefName " +
1858 "author { __typename login avatarUrl ... on User { name } } " +
1859 "latestReviews(first: 20) { nodes { state author { login } } } " +
1860 "commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }";
1861
1862/**
1863 * Summaries for pull requests anywhere on one host, one aliased lookup each. Checked and written
1864 * into the document the way `buildPullRequestStatsGraphQlQuery` does, so null means "do not send".
1865 */
1866export function buildPullRequestSummariesGraphQlQuery(
1867 changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>,
1868): string | null {
1869 if (changeRequests.length === 0) return null;
1870 const selections: string[] = [];
1871 for (const [index, changeRequest] of changeRequests.entries()) {
1872 const [owner, name, ...rest] = changeRequest.repository.trim().split("/");
1873 if (rest.length > 0 || owner === undefined || name === undefined) return null;
1874 if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null;
1875 if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null;
1876 selections.push(
1877 ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_SUMMARY_SELECTION} } }`,
1878 );
1879 }
1880 return `query PullRequestSummaries {\n${selections.join("\n")}\n}`;
1881}
1882
1883const RawSummarySchema = Schema.Struct({
1884 ...RawSearchItemSchema.fields,
1885 changedFiles: Schema.optional(Schema.NullOr(Schema.Int)),
1886 additions: Schema.optional(Schema.NullOr(Schema.Int)),
1887 deletions: Schema.optional(Schema.NullOr(Schema.Int)),
1888 closedAt: Schema.optional(Schema.NullOr(Schema.String)),
1889 createdAt: Schema.optional(Schema.String),
1890});
1891const decodeSummaries = decodeJsonResult(
1892 Schema.Struct({
1893 data: Schema.optional(
1894 Schema.NullOr(
1895 Schema.Record(
1896 Schema.String,
1897 Schema.NullOr(Schema.Struct({ pullRequest: Schema.optional(Schema.Unknown) })),
1898 ),
1899 ),
1900 ),
1901 }),
1902);
1903const decodeSummaryEntry = Schema.decodeUnknownExit(RawSummarySchema);
1904
1905export interface GitHubPullRequestSummary {
1906 readonly number: number;
1907 readonly title: string;
1908 readonly url: string;
1909 readonly headBranch: string;
1910 readonly baseBranch: string;
1911 readonly state: PullRequestState;
1912 readonly isDraft: boolean;
1913 readonly closedAt: string | null;
1914 readonly mergedAt: string | null;
1915 readonly updatedAt: string;
1916 readonly author: PullRequestActor | null;
1917 readonly additions: number;
1918 readonly deletions: number;
1919 readonly changedFiles: number;
1920 readonly reviewDecision: PullRequestReviewDecision | null;
1921 readonly checksState: PullRequestChecksState | null;
1922 readonly mergeability: PullRequestMergeability;
1923}
1924
1925/**
1926 * Summaries by the position they were asked in. A pull request GitHub answered nothing for, or
1927 * one whose fields no longer decode, is absent rather than failing the rest of the batch.
1928 */
1929export function decodePullRequestSummariesJson(
1930 raw: string,
1931): Result.Result<ReadonlyMap<number, GitHubPullRequestSummary>, DecodeFailure> {
1932 const decoded = decodeSummaries(raw);
1933 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
1934 const summaries = new Map<number, GitHubPullRequestSummary>();
1935 for (const [alias, value] of Object.entries(decoded.success.data ?? {})) {
1936 const index = /^s(\d+)$/.exec(alias)?.[1];
1937 if (index === undefined || value?.pullRequest == null) continue;
1938 const entry = decodeSummaryEntry(value.pullRequest);
1939 if (!Exit.isSuccess(entry)) continue;
1940 const pr = entry.value;
1941 summaries.set(Number(index), {
1942 number: pr.number,
1943 title: pr.title,
1944 url: pr.url,
1945 headBranch: pr.headRefName,
1946 baseBranch: pr.baseRefName,
1947 state: toState(pr),
1948 isDraft: pr.isDraft ?? false,
1949 closedAt: trimmed(pr.closedAt),
1950 mergedAt: trimmed(pr.mergedAt),
1951 updatedAt: pr.updatedAt,
1952 author: toActor(pr.author),
1953 additions: pr.additions ?? 0,
1954 deletions: pr.deletions ?? 0,
1955 changedFiles: pr.changedFiles ?? 0,
1956 reviewDecision: toReviewDecisionWithReviews(
1957 pr.reviewDecision,
1958 (pr.latestReviews?.nodes ?? []).flatMap((review) => (review === null ? [] : [review])),
1959 ),
1960 // One enum for the head commit, dressed as a single check like the search row's.
1961 checksState: rollupChecksState(
1962 (pr.commits?.nodes ?? []).flatMap((commitNode) => {
1963 const state = trimmed(commitNode?.commit?.statusCheckRollup?.state);
1964 return state === null ? [] : [{ state }];
1965 }),
1966 ),
1967 mergeability: toMergeability(pr.mergeable),
1968 });
1969 }
1970 return Result.succeed(summaries);
1971}
1972
18501973export interface GitHubPullRequestCore extends GitHubPullRequestDetail {
18511974 readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess;
18521975 readonly comparison: GitHubBaseComparison | null;