| author | |
| committer | |
| log | 18de6bb328059703b5d1b0661610076e3c0fa2d4 |
| tree | 12801304a4d550fe3eb85ccae15001f116c2996d |
| parent | 5975ec78b7eaff654e6183b99f8de7a05591e99d |
| signature | Signed by PGP key B5690EEEBB952194 |
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* () { |
| 303 | 303 | Effect.catchCause(logSkipped("pull request sync skipped", { key })), |
| 304 | 304 | ) |
| 305 | 305 | : 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 }, | |
| 307 | 309 | ); |
| 308 | 310 | }); |
| 309 | 311 |
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+104-45| ... | ... | @@ -452,77 +452,137 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 452 | 452 | }), |
| 453 | 453 | ); |
| 454 | 454 | |
| 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", () => | |
| 456 | 456 | 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 | }); | |
| 457 | 477 | mockedExecute.mockReturnValueOnce( |
| 458 | 478 | Effect.succeed( |
| 459 | 479 | output( |
| 460 | 480 | // @effect-diagnostics-next-line preferSchemaOverJson:off |
| 461 | 481 | 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) } }, | |
| 483 | 483 | }), |
| 484 | 484 | ), |
| 485 | 485 | ), |
| 486 | 486 | ); |
| 487 | 487 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 488 | 488 | |
| 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); | |
| 495 | 502 | |
| 496 | 503 | assert.deepStrictEqual( |
| 497 | 504 | { |
| 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, | |
| 509 | 513 | }, |
| 510 | 514 | { |
| 511 | 515 | number: 7, |
| 512 | 516 | state: "open", |
| 513 | headBranch: "feat/summary", | |
| 514 | isDraft: false, | |
| 517 | headBranch: "feat/7", | |
| 515 | 518 | author: "octocat", |
| 516 | additions: 12, | |
| 517 | deletions: 3, | |
| 518 | 519 | changedFiles: 2, |
| 519 | 520 | reviewDecision: "approved", |
| 520 | 521 | checksState: "passing", |
| 521 | 522 | mergeability: "mergeable", |
| 522 | 523 | }, |
| 523 | 524 | ); |
| 525 | assert.strictEqual(eight?.headBranch, "feat/8"); | |
| 524 | 526 | 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([ | |
| 526 | 586 | "pr", |
| 527 | 587 | "view", |
| 528 | 588 | "7", |
| ... | ... | @@ -531,7 +591,6 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 531 | 591 | "--json", |
| 532 | 592 | expect.stringContaining("statusCheckRollup"), |
| 533 | 593 | ]); |
| 534 | expect(mockedGetPullRequest).not.toHaveBeenCalled(); | |
| 535 | 594 | }), |
| 536 | 595 | ); |
| 537 | 596 |
apps/server/src/pullRequest/GitHubPullRequestCli.ts+153-50| ... | ... | @@ -1,10 +1,15 @@ |
| 1 | 1 | import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; |
| 2 | import * as Cause from "effect/Cause"; | |
| 2 | 3 | import * as Context from "effect/Context"; |
| 3 | 4 | import * as Clock from "effect/Clock"; |
| 4 | 5 | import * as NodeCrypto from "node:crypto"; |
| 5 | 6 | import * as Effect from "effect/Effect"; |
| 6 | 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Exit from "effect/Exit"; | |
| 9 | import * as Option from "effect/Option"; | |
| 7 | 10 | import * as Redacted from "effect/Redacted"; |
| 11 | import * as Request from "effect/Request"; | |
| 12 | import * as RequestResolver from "effect/RequestResolver"; | |
| 8 | 13 | import * as Result from "effect/Result"; |
| 9 | 14 | import * as Schema from "effect/Schema"; |
| 10 | 15 | import * as Semaphore from "effect/Semaphore"; |
| ... | ... | @@ -48,6 +53,7 @@ import { |
| 48 | 53 | decodePullRequestCoreJson, |
| 49 | 54 | PULL_REQUEST_CORE_GRAPHQL_QUERY, |
| 50 | 55 | type GitHubPullRequestCore, |
| 56 | type GitHubPullRequestSummary, | |
| 51 | 57 | decodePullRequestPreviewJson, |
| 52 | 58 | PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, |
| 53 | 59 | decodePullRequestFilesJson, |
| ... | ... | @@ -58,6 +64,7 @@ import { |
| 58 | 64 | decodePullRequestSearchJson, |
| 59 | 65 | decodePullRequestStacksJson, |
| 60 | 66 | decodePullRequestStatsJson, |
| 67 | decodePullRequestSummariesJson, | |
| 61 | 68 | decodeReactionSubjectScopeJson, |
| 62 | 69 | decodeReviewerCandidatesJson, |
| 63 | 70 | decodeLabelCandidatesJson, |
| ... | ... | @@ -67,6 +74,7 @@ import { |
| 67 | 74 | decodeReviewThreadCommentsJson, |
| 68 | 75 | decodeReviewThreadsJson, |
| 69 | 76 | buildPullRequestStatsGraphQlQuery, |
| 77 | buildPullRequestSummariesGraphQlQuery, | |
| 70 | 78 | buildPullRequestStackMembershipsGraphQlQuery, |
| 71 | 79 | decodePullRequestStackMembershipsJson, |
| 72 | 80 | encodeGraphQlRequestJson, |
| ... | ... | @@ -419,6 +427,23 @@ export interface GitHubPullRequestStat { |
| 419 | 427 | */ |
| 420 | 428 | const STAT_ALIASES_PER_REQUEST = 25; |
| 421 | 429 | const 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 | */ | |
| 435 | const SUMMARY_BATCH_WINDOW = "10 millis"; | |
| 436 | ||
| 437 | class 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 | > {} | |
| 422 | 447 | |
| 423 | 448 | export interface GitHubPullRequestSearchBatch { |
| 424 | 449 | /** Rows across every repository asked for, newest update first, each naming its own. */ |
| ... | ... | @@ -1727,6 +1752,133 @@ export const make = Effect.gen(function* () { |
| 1727 | 1752 | { concurrency: 2 }, |
| 1728 | 1753 | ).pipe(Effect.map(([, runs]) => runs)); |
| 1729 | 1754 | |
| 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 | ||
| 1730 | 1882 | return GitHubPullRequestCli.of({ |
| 1731 | 1883 | withVerifiedCredential, |
| 1732 | 1884 | getRoutingIdentity, |
| ... | ... | @@ -1933,56 +2085,7 @@ export const make = Effect.gen(function* () { |
| 1933 | 2085 | ).pipe(Effect.map((results) => results.flat())); |
| 1934 | 2086 | }, |
| 1935 | 2087 | |
| 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, | |
| 1986 | 2089 | |
| 1987 | 2090 | getPullRequestDetail, |
| 1988 | 2091 | getPullRequestPreview: (input) => { |
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+53| ... | ... | @@ -2,6 +2,8 @@ import * as Result from "effect/Result"; |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | buildPullRequestSummariesGraphQlQuery, | |
| 6 | decodePullRequestSummariesJson, | |
| 5 | 7 | buildReviewSubmissionJson, |
| 6 | 8 | buildPullRequestStackMembershipsGraphQlQuery, |
| 7 | 9 | decodePullRequestStackMembershipsJson, |
| ... | ... | @@ -1883,3 +1885,54 @@ describe("pull request stack membership batches", () => { |
| 1883 | 1885 | ); |
| 1884 | 1886 | }); |
| 1885 | 1887 | }); |
| 1888 | ||
| 1889 | describe("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( |
| 1847 | 1847 | return Result.succeed(stats); |
| 1848 | 1848 | } |
| 1849 | 1849 | |
| 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 | */ | |
| 1855 | const 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 | */ | |
| 1866 | export 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 | ||
| 1883 | const 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 | }); | |
| 1891 | const 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 | ); | |
| 1903 | const decodeSummaryEntry = Schema.decodeUnknownExit(RawSummarySchema); | |
| 1904 | ||
| 1905 | export 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 | */ | |
| 1929 | export 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 | ||
| 1850 | 1973 | export interface GitHubPullRequestCore extends GitHubPullRequestDetail { |
| 1851 | 1974 | readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess; |
| 1852 | 1975 | readonly comparison: GitHubBaseComparison | null; |