| author | |
| committer | |
| log | 08b1222d0bae670433c275ec70165c7b5048726f |
| tree | c5bfc14cdf3606c77a4b81370869d757ae406a63 |
| parent | 14fe0158ede3d964f183e281f9fec225d30a6b32 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>24 files changed, 9234 insertions(+), 9279 deletions(-)
apps/server/scripts/evaluate-thread-titles.ts+2-2| ... | ... | @@ -23,7 +23,7 @@ import { |
| 23 | 23 | } from "../src/textGeneration/ThreadTitleContext.ts"; |
| 24 | 24 | import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; |
| 25 | 25 | import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; |
| 26 | import * as GitHubCli from "../src/sourceControl/GitHubCli.ts"; | |
| 26 | import * as GitHubRepositoryApi from "../src/sourceControl/GitHubRepositoryApi.ts"; | |
| 27 | 27 | import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; |
| 28 | 28 | import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; |
| 29 | 29 | import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; |
| ... | ... | @@ -152,7 +152,7 @@ await Effect.runPromise( |
| 152 | 152 | SourceControlProviderRegistry.layer.pipe( |
| 153 | 153 | Layer.provide( |
| 154 | 154 | Layer.mergeAll( |
| 155 | GitHubCli.layer, | |
| 155 | GitHubRepositoryApi.layer, | |
| 156 | 156 | GitLabCli.layer, |
| 157 | 157 | ForgejoCli.layer, |
| 158 | 158 | AzureDevOpsCli.layer, |
apps/server/scripts/measure-pr-preview.ts+2-2| ... | ... | @@ -10,7 +10,7 @@ import * as Option from "effect/Option"; |
| 10 | 10 | import * as Schema from "effect/Schema"; |
| 11 | 11 | import { FetchHttpClient } from "effect/http"; |
| 12 | 12 | |
| 13 | import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.ts"; | |
| 13 | import * as GitHubPullRequestApi from "../src/pullRequest/GitHubPullRequestApi.ts"; | |
| 14 | 14 | import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts"; |
| 15 | 15 | import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; |
| 16 | 16 | import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts"; |
| ... | ... | @@ -73,7 +73,7 @@ const measuredApi = Layer.effect( |
| 73 | 73 | Layer.provide(NodeServices.layer), |
| 74 | 74 | ); |
| 75 | 75 | |
| 76 | const services = GitHubPullRequestCli.layer.pipe( | |
| 76 | const services = GitHubPullRequestApi.layer.pipe( | |
| 77 | 77 | Layer.provideMerge(measuredApi), |
| 78 | 78 | Layer.provideMerge(VcsProcess.layer), |
| 79 | 79 | Layer.provideMerge(NodeServices.layer), |
apps/server/src/git/GitManager.test.ts+35-44| ... | ... | @@ -42,7 +42,7 @@ import { |
| 42 | 42 | } from "@t3tools/contracts"; |
| 43 | 43 | import * as DateTime from "effect/DateTime"; |
| 44 | 44 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 45 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | |
| 45 | import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; | |
| 46 | 46 | import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; |
| 47 | 47 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; |
| 48 | 48 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| ... | ... | @@ -92,7 +92,7 @@ interface FakeGhScenario { |
| 92 | 92 | headRepositoryOwnerLogin?: string | null; |
| 93 | 93 | }; |
| 94 | 94 | repositoryCloneUrls?: Record<string, { url: string; sshUrl: string }>; |
| 95 | failWith?: GitHubCli.GitHubCliError; | |
| 95 | failWith?: GitHubRepositoryApi.GitHubRepositoryApiError; | |
| 96 | 96 | /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */ |
| 97 | 97 | failAfterCalls?: number; |
| 98 | 98 | } |
| ... | ... | @@ -111,7 +111,9 @@ type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"]; |
| 111 | 111 | |
| 112 | 112 | type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>; |
| 113 | 113 | |
| 114 | function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequestSummary | null { | |
| 114 | function normalizeFakePullRequestSummary( | |
| 115 | raw: unknown, | |
| 116 | ): GitHubRepositoryApi.GitHubPullRequestSummary | null { | |
| 115 | 117 | if (!raw || typeof raw !== "object") { |
| 116 | 118 | return null; |
| 117 | 119 | } |
| ... | ... | @@ -381,8 +383,8 @@ function createTextGeneration( |
| 381 | 383 | }; |
| 382 | 384 | } |
| 383 | 385 | |
| 384 | function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | |
| 385 | service: GitHubCli.GitHubCli["Service"]; | |
| 386 | function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { | |
| 387 | service: GitHubRepositoryApi.GitHubRepositoryApi["Service"]; | |
| 386 | 388 | ghCalls: string[]; |
| 387 | 389 | } { |
| 388 | 390 | const prListQueue = [...(scenario.prListSequence ?? [])]; |
| ... | ... | @@ -398,7 +400,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 398 | 400 | const execute = (input: { |
| 399 | 401 | readonly cwd: string; |
| 400 | 402 | readonly args: ReadonlyArray<string>; |
| 401 | }): Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCli.GitHubCliError> => { | |
| 403 | }): Effect.Effect<VcsProcess.VcsProcessOutput, GitHubRepositoryApi.GitHubRepositoryApiError> => { | |
| 402 | 404 | const args = [...input.args]; |
| 403 | 405 | ghCalls.push(args.join(" ")); |
| 404 | 406 | |
| ... | ... | @@ -486,10 +488,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 486 | 488 | return fakeGhOutput(""); |
| 487 | 489 | }, |
| 488 | 490 | catch: (error) => |
| 489 | GitHubCli.isGitHubCliError(error) | |
| 491 | GitHubRepositoryApi.isGitHubRepositoryApiError(error) | |
| 490 | 492 | ? error |
| 491 | : new GitHubCli.GitHubCliCommandError({ | |
| 492 | command: "gh", | |
| 493 | : new GitHubRepositoryApi.GitHubRepositoryCommandError({ | |
| 493 | 494 | cwd: input.cwd, |
| 494 | 495 | cause: error, |
| 495 | 496 | }), |
| ... | ... | @@ -502,8 +503,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 502 | 503 | const cloneUrls = scenario.repositoryCloneUrls?.[repository]; |
| 503 | 504 | if (!cloneUrls) { |
| 504 | 505 | return Effect.fail( |
| 505 | new GitHubCli.GitHubCliCommandError({ | |
| 506 | command: "gh", | |
| 506 | new GitHubRepositoryApi.GitHubRepositoryCommandError({ | |
| 507 | 507 | cwd: input.cwd, |
| 508 | 508 | cause: new Error(`Unexpected repository lookup: ${repository}`), |
| 509 | 509 | }), |
| ... | ... | @@ -523,8 +523,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 523 | 523 | } |
| 524 | 524 | |
| 525 | 525 | return Effect.fail( |
| 526 | new GitHubCli.GitHubCliCommandError({ | |
| 527 | command: "gh", | |
| 526 | new GitHubRepositoryApi.GitHubRepositoryCommandError({ | |
| 528 | 527 | cwd: input.cwd, |
| 529 | 528 | cause: new Error(`Unexpected gh command: ${args.join(" ")}`), |
| 530 | 529 | }), |
| ... | ... | @@ -577,7 +576,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 577 | 576 | Effect.map((raw) => |
| 578 | 577 | raw |
| 579 | 578 | .map((entry) => normalizeFakePullRequestSummary(entry)) |
| 580 | .filter((entry): entry is GitHubCli.GitHubPullRequestSummary => entry !== null), | |
| 579 | .filter( | |
| 580 | (entry): entry is GitHubRepositoryApi.GitHubPullRequestSummary => entry !== null, | |
| 581 | ), | |
| 581 | 582 | ), |
| 582 | 583 | ), |
| 583 | 584 | createPullRequest: (input) => |
| ... | ... | @@ -617,7 +618,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 617 | 618 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", |
| 618 | 619 | ], |
| 619 | 620 | }).pipe( |
| 620 | Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), | |
| 621 | Effect.map( | |
| 622 | (result) => JSON.parse(result.stdout) as GitHubRepositoryApi.GitHubPullRequestSummary, | |
| 623 | ), | |
| 621 | 624 | ), |
| 622 | 625 | getRepositoryCloneUrls: (input) => |
| 623 | 626 | execute({ |
| ... | ... | @@ -626,8 +629,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 626 | 629 | }).pipe(Effect.map((result) => JSON.parse(result.stdout))), |
| 627 | 630 | createRepository: (input) => |
| 628 | 631 | Effect.fail( |
| 629 | new GitHubCli.GitHubCliCommandError({ | |
| 630 | command: "gh", | |
| 632 | new GitHubRepositoryApi.GitHubRepositoryCommandError({ | |
| 631 | 633 | cwd: input.cwd, |
| 632 | 634 | cause: new Error(`Unexpected repository create: ${input.repository}`), |
| 633 | 635 | }), |
| ... | ... | @@ -691,7 +693,9 @@ function makeManager(input?: { |
| 691 | 693 | ProjectionStore.ProjectionStoreV2 | ProjectStore.ProjectStoreV2 |
| 692 | 694 | >; |
| 693 | 695 | }) { |
| 694 | const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); | |
| 696 | const { service: gitHubRepositoryApi, ghCalls } = createGitHubRepositoryApiWithFakeGh( | |
| 697 | input?.ghScenario, | |
| 698 | ); | |
| 695 | 699 | const textGeneration = createTextGeneration(input?.textGeneration); |
| 696 | 700 | const layerServerConfig = ServerConfig.layerTest(process.cwd(), { |
| 697 | 701 | prefix: "t3-git-manager-test-", |
| ... | ... | @@ -740,7 +744,7 @@ function makeManager(input?: { |
| 740 | 744 | ), |
| 741 | 745 | Effect.provide( |
| 742 | 746 | Layer.merge( |
| 743 | Layer.succeed(GitHubCli.GitHubCli, gitHubCli), | |
| 747 | Layer.succeed(GitHubRepositoryApi.GitHubRepositoryApi, gitHubRepositoryApi), | |
| 744 | 748 | Layer.mock(GitHubApi.GitHubApi)({}), |
| 745 | 749 | ), |
| 746 | 750 | ), |
| ... | ... | @@ -1296,8 +1300,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 1296 | 1300 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]); |
| 1297 | 1301 | const { manager, ghCalls } = yield* makeManager({ |
| 1298 | 1302 | ghScenario: { |
| 1299 | failWith: new GitHubCli.GitHubCliUnavailableError({ | |
| 1300 | command: "gh", | |
| 1303 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 1301 | 1304 | cwd: repoDir, |
| 1302 | 1305 | cause: new Error("rate limited"), |
| 1303 | 1306 | }), |
| ... | ... | @@ -1779,8 +1782,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 1779 | 1782 | |
| 1780 | 1783 | const { manager, ghCalls } = yield* makeManager({ |
| 1781 | 1784 | ghScenario: { |
| 1782 | failWith: new GitHubCli.GitHubCliUnavailableError({ | |
| 1783 | command: "gh", | |
| 1785 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 1784 | 1786 | cwd: repoDir, |
| 1785 | 1787 | cause: new Error("gh is not available on PATH"), |
| 1786 | 1788 | }), |
| ... | ... | @@ -2640,8 +2642,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2640 | 2642 | }, |
| 2641 | 2643 | ]), |
| 2642 | 2644 | }, |
| 2643 | failWith: new GitHubCli.GitHubCliUnavailableError({ | |
| 2644 | command: "gh", | |
| 2645 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 2645 | 2646 | cwd: repoDir, |
| 2646 | 2647 | cause: new Error("rate limited"), |
| 2647 | 2648 | }), |
| ... | ... | @@ -2717,8 +2718,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2717 | 2718 | |
| 2718 | 2719 | const { manager } = yield* makeManager({ |
| 2719 | 2720 | ghScenario: { |
| 2720 | failWith: new GitHubCli.GitHubCliUnavailableError({ | |
| 2721 | command: "gh", | |
| 2721 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 2722 | 2722 | cwd: repoDir, |
| 2723 | 2723 | cause: new Error("gh is not available on PATH"), |
| 2724 | 2724 | }), |
| ... | ... | @@ -2743,8 +2743,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2743 | 2743 | const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value"; |
| 2744 | 2744 | const { manager } = yield* makeManager({ |
| 2745 | 2745 | ghScenario: { |
| 2746 | failWith: new GitHubCli.GitHubCliRateLimitError({ | |
| 2747 | command: "gh", | |
| 2746 | failWith: new GitHubRepositoryApi.GitHubRepositoryRateLimitError({ | |
| 2748 | 2747 | cwd: repoDir, |
| 2749 | 2748 | cause: new Error(upstreamCause), |
| 2750 | 2749 | }), |
| ... | ... | @@ -2770,7 +2769,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2770 | 2769 | errorTag: "SourceControlProviderError", |
| 2771 | 2770 | provider: "github", |
| 2772 | 2771 | providerOperation: "listChangeRequests", |
| 2773 | providerCommand: "gh", | |
| 2774 | 2772 | errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.", |
| 2775 | 2773 | }); |
| 2776 | 2774 | const loggedText = [ |
| ... | ... | @@ -2801,8 +2799,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2801 | 2799 | const { manager } = yield* makeManager({ |
| 2802 | 2800 | ghScenario: { |
| 2803 | 2801 | prListSequence: [JSON.stringify([existingPr])], |
| 2804 | failWith: new GitHubCli.GitHubCliUnavailableError({ | |
| 2805 | command: "gh", | |
| 2802 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 2806 | 2803 | cwd: repoDir, |
| 2807 | 2804 | cause: new Error("rate limited"), |
| 2808 | 2805 | }), |
| ... | ... | @@ -2844,8 +2841,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2844 | 2841 | const { manager } = yield* makeManager({ |
| 2845 | 2842 | ghScenario: { |
| 2846 | 2843 | prListSequence: [JSON.stringify([existingPr])], |
| 2847 | failWith: new GitHubCli.GitHubCliUnavailableError({ | |
| 2848 | command: "gh", | |
| 2844 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 2849 | 2845 | cwd: repoDir, |
| 2850 | 2846 | cause: new Error("rate limited"), |
| 2851 | 2847 | }), |
| ... | ... | @@ -2892,8 +2888,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2892 | 2888 | const { manager } = yield* makeManager({ |
| 2893 | 2889 | ghScenario: { |
| 2894 | 2890 | prListSequence: [JSON.stringify([existingPr])], |
| 2895 | failWith: new GitHubCli.GitHubCliUnavailableError({ | |
| 2896 | command: "gh", | |
| 2891 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 2897 | 2892 | cwd: repoDir, |
| 2898 | 2893 | cause: new Error("rate limited"), |
| 2899 | 2894 | }), |
| ... | ... | @@ -2931,8 +2926,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2931 | 2926 | const { manager } = yield* makeManager({ |
| 2932 | 2927 | ghScenario: { |
| 2933 | 2928 | prListSequence: [JSON.stringify([existingPr])], |
| 2934 | failWith: new GitHubCli.GitHubCliUnavailableError({ | |
| 2935 | command: "gh", | |
| 2929 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 2936 | 2930 | cwd: repoDir, |
| 2937 | 2931 | cause: new Error("rate limited"), |
| 2938 | 2932 | }), |
| ... | ... | @@ -2973,8 +2967,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2973 | 2967 | const { manager } = yield* makeManager({ |
| 2974 | 2968 | ghScenario: { |
| 2975 | 2969 | prListSequence: [JSON.stringify([existingPr])], |
| 2976 | failWith: new GitHubCli.GitHubCliUnavailableError({ | |
| 2977 | command: "gh", | |
| 2970 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 2978 | 2971 | cwd: repoDir, |
| 2979 | 2972 | cause: new Error("rate limited"), |
| 2980 | 2973 | }), |
| ... | ... | @@ -4618,8 +4611,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 4618 | 4611 | |
| 4619 | 4612 | const { manager } = yield* makeManager({ |
| 4620 | 4613 | ghScenario: { |
| 4621 | failWith: new GitHubCli.GitHubCliUnavailableError({ | |
| 4622 | command: "gh", | |
| 4614 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 4623 | 4615 | cwd: repoDir, |
| 4624 | 4616 | cause: new Error("gh is not available on PATH"), |
| 4625 | 4617 | }), |
| ... | ... | @@ -4648,8 +4640,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 4648 | 4640 | |
| 4649 | 4641 | const { manager } = yield* makeManager({ |
| 4650 | 4642 | ghScenario: { |
| 4651 | failWith: new GitHubCli.GitHubCliAuthenticationError({ | |
| 4652 | command: "gh", | |
| 4643 | failWith: new GitHubRepositoryApi.GitHubRepositoryAuthenticationError({ | |
| 4653 | 4644 | cwd: repoDir, |
| 4654 | 4645 | cause: new Error("gh is not authenticated"), |
| 4655 | 4646 | }), |
apps/server/src/git/GitManager.ts+1-1| ... | ... | @@ -1286,7 +1286,7 @@ export const make = Effect.gen(function* () { |
| 1286 | 1286 | ? { |
| 1287 | 1287 | provider: error.provider, |
| 1288 | 1288 | providerOperation: error.operation, |
| 1289 | providerCommand: error.command ?? "unknown", | |
| 1289 | ...(error.command === undefined ? {} : { providerCommand: error.command }), | |
| 1290 | 1290 | errorDetail: error.detail, |
| 1291 | 1291 | } |
| 1292 | 1292 | : {}), |
apps/server/src/orchestration-v2/ThreadPullRequestService.ts+1-1| ... | ... | @@ -345,7 +345,7 @@ export const make = Effect.gen(function* () { |
| 345 | 345 | }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), |
| 346 | 346 | ), |
| 347 | 347 | ), |
| 348 | // Wide enough that a sweep's GitHub branch lookups reach GitHubCli together and share one | |
| 348 | // Wide enough that a sweep's GitHub branch lookups reach GitHubRepositoryApi together and share one | |
| 349 | 349 | // GraphQL document, instead of one `gh pr list` per branch. |
| 350 | 350 | { concurrency: 32, discard: true }, |
| 351 | 351 | ); |
apps/server/src/pullRequest/GitHubPullRequestApi.test.ts created+4545| ... | ... | @@ -0,0 +1,4545 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; | |
| 3 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Clock from "effect/Clock"; | |
| 6 | import * as Deferred from "effect/Deferred"; | |
| 7 | import * as Fiber from "effect/Fiber"; | |
| 8 | import * as Layer from "effect/Layer"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | import * as TestClock from "effect/testing/TestClock"; | |
| 11 | import * as Redacted from "effect/Redacted"; | |
| 12 | ||
| 13 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 14 | ||
| 15 | import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; | |
| 16 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 17 | import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; | |
| 18 | import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; | |
| 19 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; | |
| 20 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 21 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; | |
| 22 | import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; | |
| 23 | ||
| 24 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 25 | ||
| 26 | const coreResponse = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({ | |
| 27 | data: { | |
| 28 | repository: { | |
| 29 | mergeCommitAllowed: true, | |
| 30 | squashMergeAllowed: false, | |
| 31 | rebaseMergeAllowed: true, | |
| 32 | viewerPermission: "WRITE", | |
| 33 | pullRequest: { | |
| 34 | number: 7, | |
| 35 | title: "Pull request 7", | |
| 36 | url: "https://github.com/acme/web/pull/7", | |
| 37 | headRefName: "feature", | |
| 38 | baseRefName: "main", | |
| 39 | headRefOid: "abc123", | |
| 40 | state: "OPEN", | |
| 41 | createdAt: "2026-07-01T00:00:00Z", | |
| 42 | updatedAt: "2026-07-02T00:00:00Z", | |
| 43 | viewerCanUpdate: true, | |
| 44 | viewerDidAuthor: false, | |
| 45 | viewerCanUpdateBranch: true, | |
| 46 | baseRef: { compare: { behindBy: 2 } }, | |
| 47 | reviewRequests: { nodes: [] }, | |
| 48 | labels: { nodes: [] }, | |
| 49 | commits: { nodes: [] }, | |
| 50 | ...pullRequest, | |
| 51 | }, | |
| 52 | }, | |
| 53 | }, | |
| 54 | }); | |
| 55 | ||
| 56 | /** One request the CLI made, as the transport received it. */ | |
| 57 | type ApiCall = | |
| 58 | | ({ readonly kind: "graphql" } & GitHubApi.GitHubGraphQlInput) | |
| 59 | | ({ readonly kind: "rest" } & GitHubApi.GitHubRestInput); | |
| 60 | ||
| 61 | type ApiAnswer = Effect.Effect<GitHubApi.GitHubRestResponse, GitHubApi.GitHubApiError>; | |
| 62 | ||
| 63 | const mockedExecute = vi.fn<(call: ApiCall) => ApiAnswer>(); | |
| 64 | const defaultCredential = (host: string) => | |
| 65 | Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:token` }); | |
| 66 | const mockedCredential = vi.fn(defaultCredential); | |
| 67 | const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() => | |
| 68 | Effect.succeed(output('{"data":{}}')), | |
| 69 | ); | |
| 70 | ||
| 71 | /** | |
| 72 | * A transport that answers from the mocks above but spends the real GraphQL budget, the way | |
| 73 | * GitHubApi does, so the reserve and pause behaviour is still the module's to prove. | |
| 74 | */ | |
| 75 | const mockApi = Layer.effect( | |
| 76 | GitHubApi.GitHubApi, | |
| 77 | Effect.gen(function* () { | |
| 78 | const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; | |
| 79 | return GitHubApi.GitHubApi.of({ | |
| 80 | graphql: (input) => | |
| 81 | budget | |
| 82 | .query(input.host, input.query, input.allowReserve ? { allowReserve: true } : undefined) | |
| 83 | .pipe( | |
| 84 | Effect.flatMap((query) => | |
| 85 | (input.query.includes("query PullRequestStackMemberships") | |
| 86 | ? mockedStackMemberships | |
| 87 | : mockedExecute)({ kind: "graphql", ...input, query }), | |
| 88 | ), | |
| 89 | Effect.map((answer) => answer.body), | |
| 90 | Effect.tap((body) => budget.observe(input.host, body)), | |
| 91 | ), | |
| 92 | rest: (input) => mockedExecute({ kind: "rest", ...input }), | |
| 93 | credential: (host) => mockedCredential(host), | |
| 94 | }); | |
| 95 | }), | |
| 96 | ); | |
| 97 | ||
| 98 | const layer = it.layer( | |
| 99 | GitHubPullRequestApi.layer.pipe( | |
| 100 | Layer.provideMerge(mockApi), | |
| 101 | Layer.provideMerge(GitHubGraphQlBudget.layer), | |
| 102 | Layer.provide(NodeCrypto.layer), | |
| 103 | Layer.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))), | |
| 104 | ), | |
| 105 | ); | |
| 106 | ||
| 107 | /** A successful answer; GraphQL reads take its body. */ | |
| 108 | function output( | |
| 109 | body: string, | |
| 110 | truncated = false, | |
| 111 | invalidUtf8 = false, | |
| 112 | ): GitHubApi.GitHubRestResponse { | |
| 113 | return { status: 200, headers: {}, body, truncated, invalidUtf8 }; | |
| 114 | } | |
| 115 | ||
| 116 | /** | |
| 117 | * Listing rows as GraphQL answers them. Overrides may use the flat shapes a test reads most | |
| 118 | * easily: `reviewRequests` as `[{ login }]` or `[{ slug }]`, and `checks` as the rollup state. | |
| 119 | */ | |
| 120 | function rows( | |
| 121 | count: number, | |
| 122 | firstNumber: number, | |
| 123 | overrides: (number: number) => Readonly<Record<string, unknown>> = () => ({}), | |
| 124 | ): ReadonlyArray<Record<string, unknown>> { | |
| 125 | return Array.from({ length: count }, (_, index) => { | |
| 126 | const number = firstNumber + index; | |
| 127 | const { reviewRequests, checks, ...rest } = overrides(number) as { | |
| 128 | reviewRequests?: ReadonlyArray<Record<string, string>>; | |
| 129 | checks?: string; | |
| 130 | } & Record<string, unknown>; | |
| 131 | return { | |
| 132 | number, | |
| 133 | title: `Pull request ${number}`, | |
| 134 | url: `https://github.com/acme/web/pull/${number}`, | |
| 135 | headRefName: "feat/page", | |
| 136 | baseRefName: "main", | |
| 137 | createdAt: "2026-07-01T00:00:00Z", | |
| 138 | updatedAt: "2026-07-02T00:00:00Z", | |
| 139 | repository: { nameWithOwner: "acme/web" }, | |
| 140 | ...(reviewRequests === undefined | |
| 141 | ? {} | |
| 142 | : { | |
| 143 | reviewRequests: { | |
| 144 | nodes: reviewRequests.map((requestedReviewer) => ({ requestedReviewer })), | |
| 145 | }, | |
| 146 | }), | |
| 147 | ...(checks === undefined | |
| 148 | ? {} | |
| 149 | : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }), | |
| 150 | ...rest, | |
| 151 | }; | |
| 152 | }); | |
| 153 | } | |
| 154 | ||
| 155 | /** A page of the searched listing. */ | |
| 156 | function pullRequests( | |
| 157 | count: number, | |
| 158 | firstNumber: number, | |
| 159 | overrides?: (number: number) => Readonly<Record<string, unknown>>, | |
| 160 | ): string { | |
| 161 | return encodeJson({ | |
| 162 | data: { | |
| 163 | search: { pageInfo: { hasNextPage: false }, nodes: rows(count, firstNumber, overrides) }, | |
| 164 | }, | |
| 165 | }); | |
| 166 | } | |
| 167 | ||
| 168 | /** A page of a repository's own list, which is what the search-free fallback reads. */ | |
| 169 | function listedPullRequests( | |
| 170 | count: number, | |
| 171 | firstNumber: number, | |
| 172 | overrides?: (number: number) => Readonly<Record<string, unknown>>, | |
| 173 | ): string { | |
| 174 | return encodeJson({ | |
| 175 | data: { | |
| 176 | repository: { | |
| 177 | pullRequests: { | |
| 178 | pageInfo: { hasNextPage: false }, | |
| 179 | nodes: rows(count, firstNumber, overrides), | |
| 180 | }, | |
| 181 | }, | |
| 182 | }, | |
| 183 | }); | |
| 184 | } | |
| 185 | ||
| 186 | /** A search that found nothing, which is also what GitHub says for a repository it does not index. */ | |
| 187 | const emptySearch = () => output(pullRequests(0, 1)); | |
| 188 | const emptyList = () => output(listedPullRequests(0, 1)); | |
| 189 | ||
| 190 | function pullRequestFiles(count: number, firstIndex: number): string { | |
| 191 | return encodeJson( | |
| 192 | Array.from({ length: count }, (_, index) => ({ | |
| 193 | filename: `src/file${firstIndex + index}.ts`, | |
| 194 | status: "modified", | |
| 195 | patch: "@@ -1 +1 @@\n-old\n+new", | |
| 196 | })), | |
| 197 | ); | |
| 198 | } | |
| 199 | ||
| 200 | /** One thread's comments as the GraphQL read returns them, cursor and all. */ | |
| 201 | function threadComments( | |
| 202 | ids: ReadonlyArray<string>, | |
| 203 | endCursor: string | null, | |
| 204 | totalCount = ids.length, | |
| 205 | ) { | |
| 206 | return { | |
| 207 | totalCount, | |
| 208 | pageInfo: { hasNextPage: endCursor !== null, endCursor }, | |
| 209 | nodes: ids.map((id) => ({ id, body: id, createdAt: "2026-07-01T00:00:00Z" })), | |
| 210 | }; | |
| 211 | } | |
| 212 | ||
| 213 | function thread(id: string, ...commentIds: ReadonlyArray<string>) { | |
| 214 | return { | |
| 215 | id, | |
| 216 | path: "src/a.ts", | |
| 217 | line: 1, | |
| 218 | diffSide: "RIGHT", | |
| 219 | isResolved: false, | |
| 220 | isOutdated: false, | |
| 221 | comments: threadComments(commentIds, null), | |
| 222 | }; | |
| 223 | } | |
| 224 | ||
| 225 | function reviewThreadsPage( | |
| 226 | nodes: ReadonlyArray<Record<string, unknown>>, | |
| 227 | endCursor: string | null, | |
| 228 | ): string { | |
| 229 | return encodeJson({ | |
| 230 | data: { | |
| 231 | repository: { | |
| 232 | pullRequest: { | |
| 233 | reviewThreads: { | |
| 234 | totalCount: nodes.length, | |
| 235 | pageInfo: { hasNextPage: endCursor !== null, endCursor }, | |
| 236 | nodes, | |
| 237 | }, | |
| 238 | }, | |
| 239 | }, | |
| 240 | }, | |
| 241 | }); | |
| 242 | } | |
| 243 | ||
| 244 | function threadCommentsPage( | |
| 245 | ids: ReadonlyArray<string>, | |
| 246 | endCursor: string | null, | |
| 247 | totalCount: number, | |
| 248 | pullRequestId = "PR_7", | |
| 249 | ): string { | |
| 250 | return encodeJson({ | |
| 251 | data: { | |
| 252 | repository: { pullRequest: { id: "PR_7" } }, | |
| 253 | node: { | |
| 254 | pullRequest: { id: pullRequestId }, | |
| 255 | comments: threadComments(ids, endCursor, totalCount), | |
| 256 | }, | |
| 257 | }, | |
| 258 | }); | |
| 259 | } | |
| 260 | ||
| 261 | /** What GitHub answers for a pull request it will not serve a whole diff for. */ | |
| 262 | const diffRefused = new GitHubApi.GitHubApiResponseError({ | |
| 263 | host: "github.com", | |
| 264 | operation: "getPullRequestDiff", | |
| 265 | status: 406, | |
| 266 | }); | |
| 267 | ||
| 268 | /** The nth request the CLI made. */ | |
| 269 | function callAt(index: number): ApiCall { | |
| 270 | const call = mockedExecute.mock.calls[index]; | |
| 271 | assert.isDefined(call); | |
| 272 | return call[0]; | |
| 273 | } | |
| 274 | ||
| 275 | /** The GraphQL variables of the nth request. */ | |
| 276 | function varsAt(index: number): Readonly<Record<string, unknown>> { | |
| 277 | const call = callAt(index); | |
| 278 | assert.strictEqual(call.kind, "graphql"); | |
| 279 | return call.kind === "graphql" ? (call.variables ?? {}) : {}; | |
| 280 | } | |
| 281 | ||
| 282 | /** The GraphQL document of the nth request. */ | |
| 283 | function queryAt(index: number): string { | |
| 284 | const call = callAt(index); | |
| 285 | return call.kind === "graphql" ? call.query : ""; | |
| 286 | } | |
| 287 | ||
| 288 | /** The REST path of the nth request. */ | |
| 289 | function pathAt(index: number): string { | |
| 290 | const call = callAt(index); | |
| 291 | return call.kind === "rest" ? call.path : ""; | |
| 292 | } | |
| 293 | ||
| 294 | /** The search the nth request carried, or undefined for a read that did not search. */ | |
| 295 | function searchOfCall(index: number): string | undefined { | |
| 296 | const q = callAt(index).kind === "graphql" ? varsAt(index)["q"] : undefined; | |
| 297 | return typeof q === "string" ? q : undefined; | |
| 298 | } | |
| 299 | ||
| 300 | /** One row as a search answers it, which is the listing's row one connection deeper. */ | |
| 301 | function searchItem(number: number, repository: string, updatedAt: string) { | |
| 302 | return { | |
| 303 | number, | |
| 304 | title: `Pull request ${number}`, | |
| 305 | url: `https://github.com/${repository}/pull/${number}`, | |
| 306 | author: { login: "octocat", avatarUrl: "https://avatars/octocat" }, | |
| 307 | headRefName: "feat/page", | |
| 308 | baseRefName: "main", | |
| 309 | state: "OPEN", | |
| 310 | isDraft: false, | |
| 311 | mergeable: "MERGEABLE", | |
| 312 | createdAt: "2026-07-01T00:00:00Z", | |
| 313 | updatedAt, | |
| 314 | repository: { nameWithOwner: repository }, | |
| 315 | reviewRequests: { nodes: [{ requestedReviewer: { login: "hubot" } }] }, | |
| 316 | labels: { nodes: [{ name: "bug", color: "ff0000" }] }, | |
| 317 | }; | |
| 318 | } | |
| 319 | ||
| 320 | function searchPage(nodes: ReadonlyArray<unknown>, hasNextPage = false) { | |
| 321 | return output(encodeJson({ data: { search: { pageInfo: { hasNextPage }, nodes } } })); | |
| 322 | } | |
| 323 | ||
| 324 | const searchQueryOfCall = searchOfCall; | |
| 325 | ||
| 326 | /** | |
| 327 | * Answers every request with the body of the first route whose needle its GraphQL document or | |
| 328 | * REST path contains, and anything unrouted with an empty object, which a mutation never reads. | |
| 329 | */ | |
| 330 | function route(...routes: ReadonlyArray<readonly [needle: string, body: unknown]>): void { | |
| 331 | mockedExecute.mockImplementation((call) => { | |
| 332 | const target = call.kind === "graphql" ? call.query : call.path; | |
| 333 | const match = routes.find(([needle]) => target.includes(needle)); | |
| 334 | return Effect.succeed(output(match === undefined ? "{}" : encodeJson(match[1]))); | |
| 335 | }); | |
| 336 | } | |
| 337 | ||
| 338 | /** The variables of every GraphQL request whose document contains `needle`, in order. */ | |
| 339 | function variablesOf(needle: string): ReadonlyArray<Readonly<Record<string, unknown>>> { | |
| 340 | return mockedExecute.mock.calls.flatMap(([call]) => | |
| 341 | call.kind === "graphql" && call.query.includes(needle) ? [call.variables ?? {}] : [], | |
| 342 | ); | |
| 343 | } | |
| 344 | ||
| 345 | /** Every REST request whose path contains `needle`, in order. */ | |
| 346 | function restCallsTo(needle: string): ReadonlyArray<GitHubApi.GitHubRestInput> { | |
| 347 | return mockedExecute.mock.calls.flatMap(([call]) => | |
| 348 | call.kind === "rest" && call.path.includes(needle) ? [call] : [], | |
| 349 | ); | |
| 350 | } | |
| 351 | ||
| 352 | /** | |
| 353 | * The check that a client-given node hangs off the pull request it names. Routed before the node | |
| 354 | * id lookup, whose document it contains. | |
| 355 | */ | |
| 356 | const SUBJECT_SCOPE_QUERY = "node(id: $subjectId)"; | |
| 357 | const subjectScope = (subjectId: string, pullRequestId: string) => ({ | |
| 358 | data: { | |
| 359 | repository: { pullRequest: { id: pullRequestId } }, | |
| 360 | node: { id: subjectId, pullRequest: { id: pullRequestId } }, | |
| 361 | }, | |
| 362 | }); | |
| 363 | ||
| 364 | /** One page of a head commit's check contexts, the read a rollup past one page is walked by. */ | |
| 365 | const checkContextsPage = ( | |
| 366 | nodes: ReadonlyArray<Record<string, unknown>>, | |
| 367 | endCursor: string | null, | |
| 368 | headRefOid = "abc123", | |
| 369 | ) => | |
| 370 | encodeJson({ | |
| 371 | data: { | |
| 372 | repository: { | |
| 373 | pullRequest: { | |
| 374 | headRefOid, | |
| 375 | commits: { | |
| 376 | nodes: [ | |
| 377 | { | |
| 378 | commit: { | |
| 379 | statusCheckRollup: { | |
| 380 | contexts: { nodes, pageInfo: { hasNextPage: endCursor !== null, endCursor } }, | |
| 381 | }, | |
| 382 | }, | |
| 383 | }, | |
| 384 | ], | |
| 385 | }, | |
| 386 | }, | |
| 387 | }, | |
| 388 | }, | |
| 389 | }); | |
| 390 | ||
| 391 | /** A pull request's base and head, as the REST read a file expansion starts from answers them. */ | |
| 392 | const pullRequestRefs = encodeJson({ base: { sha: "a1b2c3d" }, head: { sha: "b1c2d3e" } }); | |
| 393 | ||
| 394 | /** A cross-repository pull request whose head waits on a maintainer to run its workflows. */ | |
| 395 | const crossRepositoryDetail = (headRefOid = "abc123") => | |
| 396 | coreResponse({ | |
| 397 | headRefName: "feat/page", | |
| 398 | headRefOid, | |
| 399 | isCrossRepository: true, | |
| 400 | headRepositoryOwner: { login: "octocat" }, | |
| 401 | }); | |
| 402 | ||
| 403 | /** Open pull requests on one head branch, every one of them from the same fork head. */ | |
| 404 | const heads = (numbers: ReadonlyArray<number>) => ({ | |
| 405 | data: { | |
| 406 | repository: { | |
| 407 | pullRequests: { | |
| 408 | pageInfo: { hasNextPage: false, endCursor: null }, | |
| 409 | nodes: numbers.map((number) => ({ | |
| 410 | number, | |
| 411 | headRefOid: "abc123", | |
| 412 | isCrossRepository: true, | |
| 413 | headRepositoryOwner: { login: "octocat" }, | |
| 414 | })), | |
| 415 | }, | |
| 416 | }, | |
| 417 | }, | |
| 418 | }); | |
| 419 | ||
| 420 | const workflowRuns = (ids: ReadonlyArray<number>) => ({ | |
| 421 | workflow_runs: ids.map((id) => ({ | |
| 422 | id, | |
| 423 | name: id === 10 ? "build" : `run ${id}`, | |
| 424 | html_url: `https://example.com/${id}`, | |
| 425 | })), | |
| 426 | }); | |
| 427 | ||
| 428 | /** Answers the reads an approval makes: the detail, the heads, the runs; approvals return nothing. */ | |
| 429 | function workflowApprovalRoutes( | |
| 430 | detail: () => unknown, | |
| 431 | headsAnswer: unknown, | |
| 432 | runsAnswer: unknown, | |
| 433 | ): void { | |
| 434 | mockedExecute.mockImplementation((call) => | |
| 435 | Effect.sync(() => | |
| 436 | output( | |
| 437 | call.kind === "rest" | |
| 438 | ? call.path.endsWith("/approve") | |
| 439 | ? "" | |
| 440 | : encodeJson(runsAnswer) | |
| 441 | : call.query.includes("headRefName: $head") | |
| 442 | ? encodeJson(headsAnswer) | |
| 443 | : encodeJson(detail()), | |
| 444 | ), | |
| 445 | ), | |
| 446 | ); | |
| 447 | } | |
| 448 | ||
| 449 | /** The pull request node id lookup, which the layer caches for every test after the first. */ | |
| 450 | const NODE_ID_QUERY = "pullRequest(number: $number) { id }"; | |
| 451 | const nodeIdAnswer = (id: string) => ({ data: { repository: { pullRequest: { id } } } }); | |
| 452 | ||
| 453 | afterEach(() => { | |
| 454 | mockedExecute.mockReset(); | |
| 455 | mockedStackMemberships.mockReset(); | |
| 456 | mockedCredential.mockReset(); | |
| 457 | mockedCredential.mockImplementation(defaultCredential); | |
| 458 | }); | |
| 459 | ||
| 460 | it.effect( | |
| 461 | "keeps a verified credential through an auth switch and separates token fingerprints", | |
| 462 | () => | |
| 463 | Effect.gen(function* () { | |
| 464 | let activeToken = "broad-credential"; | |
| 465 | const requests: Array<{ readonly url: string; readonly authorization: string | undefined }> = | |
| 466 | []; | |
| 467 | const credentials = Layer.succeed( | |
| 468 | GitHubCredentials.GitHubCredentials, | |
| 469 | GitHubCredentials.GitHubCredentials.of({ | |
| 470 | get: (host) => | |
| 471 | Effect.sync(() => ({ | |
| 472 | host, | |
| 473 | token: Redacted.make(activeToken), | |
| 474 | source: "gh" as const, | |
| 475 | fingerprint: `${host}:${activeToken.length}${activeToken.at(-1)}`, | |
| 476 | })), | |
| 477 | invalidate: () => Effect.void, | |
| 478 | }), | |
| 479 | ); | |
| 480 | const http = Layer.succeed( | |
| 481 | HttpClient.HttpClient, | |
| 482 | HttpClient.make((request) => | |
| 483 | Effect.sync(() => { | |
| 484 | requests.push({ url: request.url, authorization: request.headers["authorization"] }); | |
| 485 | const body = request.url.endsWith("/user") | |
| 486 | ? { id: 123, login: "same-account" } | |
| 487 | : { data: { repository: { pullRequest: { id: "PR_1" } }, addComment: {} } }; | |
| 488 | return HttpClientResponse.fromWeb(request, new Response(encodeJson(body))); | |
| 489 | }), | |
| 490 | ), | |
| 491 | ); | |
| 492 | const cli = yield* GitHubPullRequestApi.make.pipe( | |
| 493 | Effect.provide( | |
| 494 | GitHubApi.layer.pipe( | |
| 495 | Layer.provide(Layer.mergeAll(credentials, http)), | |
| 496 | Layer.provide(GitHubGraphQlBudget.layer), | |
| 497 | Layer.provide(SourceControlRateLimit.layer), | |
| 498 | Layer.merge(VcsProcess.layer), | |
| 499 | Layer.provideMerge(NodeServices.layer), | |
| 500 | ), | |
| 501 | ), | |
| 502 | ); | |
| 503 | const input = { cwd: "/repo", host: "github.com" }; | |
| 504 | const first = yield* cli.withVerifiedCredential(input, (identity) => | |
| 505 | Effect.gen(function* () { | |
| 506 | activeToken = "restricted-credential"; | |
| 507 | expect(yield* cli.getViewerLogin(input)).toBe("same-account"); | |
| 508 | yield* cli.commentOnPullRequest({ | |
| 509 | ...input, | |
| 510 | repository: "owner/repo", | |
| 511 | number: 1, | |
| 512 | body: "comment", | |
| 513 | }); | |
| 514 | return identity; | |
| 515 | }), | |
| 516 | ); | |
| 517 | const second = yield* cli.withVerifiedCredential(input, Effect.succeed); | |
| 518 | expect(first.accountId).toBe(second.accountId); | |
| 519 | expect(first.credentialFingerprint).not.toBe(second.credentialFingerprint); | |
| 520 | expect(encodeJson([first, second])).not.toContain("broad-credential"); | |
| 521 | expect(encodeJson([first, second])).not.toContain("restricted-credential"); | |
| 522 | // The comment was written under the credential the page verified, not the switched one. | |
| 523 | expect( | |
| 524 | requests | |
| 525 | .filter((request) => request.url.endsWith("/graphql")) | |
| 526 | .map((request) => request.authorization), | |
| 527 | ).toEqual(["Bearer broad-credential", "Bearer broad-credential"]); | |
| 528 | expect( | |
| 529 | requests | |
| 530 | .filter((request) => request.url.endsWith("/user")) | |
| 531 | .map((request) => request.authorization), | |
| 532 | ).toEqual(["Bearer broad-credential", "Bearer restricted-credential"]); | |
| 533 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | |
| 534 | accountId: "123", | |
| 535 | viewer: "same-account", | |
| 536 | }); | |
| 537 | }), | |
| 538 | ); | |
| 539 | ||
| 540 | layer("GitHubPullRequestApi.layer", (it) => { | |
| 541 | it.effect("admits only one concurrent preview above the reserve and resumes after reset", () => | |
| 542 | Effect.gen(function* () { | |
| 543 | const now = yield* Clock.currentTimeMillis; | |
| 544 | const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; | |
| 545 | const resetAt = "2099-08-13T14:00:00Z"; | |
| 546 | yield* budget.observe( | |
| 547 | "preview-budget.example", | |
| 548 | encodeJson({ data: { rateLimit: { cost: 1, limit: 5_000, remaining: 501, resetAt } } }), | |
| 549 | ); | |
| 550 | mockedExecute.mockReturnValue( | |
| 551 | Effect.succeed( | |
| 552 | output( | |
| 553 | encodeJson({ | |
| 554 | data: { | |
| 555 | repository: { | |
| 556 | pullRequest: { | |
| 557 | number: 7, | |
| 558 | title: "Fast previews", | |
| 559 | url: "https://preview-budget.example/acme/web/pull/7", | |
| 560 | state: "OPEN", | |
| 561 | isDraft: false, | |
| 562 | createdAt: "2026-07-01T00:00:00Z", | |
| 563 | author: null, | |
| 564 | }, | |
| 565 | }, | |
| 566 | rateLimit: { cost: 1, limit: 5_000, remaining: 500, resetAt }, | |
| 567 | }, | |
| 568 | }), | |
| 569 | ), | |
| 570 | ), | |
| 571 | ); | |
| 572 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 573 | const input = { | |
| 574 | cwd: "/w", | |
| 575 | repository: "acme/web", | |
| 576 | host: "preview-budget.example", | |
| 577 | number: 7, | |
| 578 | }; | |
| 579 | const results = yield* Effect.all( | |
| 580 | Array.from({ length: 20 }, (_, index) => | |
| 581 | cli.getPullRequestPreview({ ...input, number: index + 1 }).pipe(Effect.result), | |
| 582 | ), | |
| 583 | { concurrency: "unbounded" }, | |
| 584 | ); | |
| 585 | expect(results.filter((result) => result._tag === "Success")).toHaveLength(1); | |
| 586 | for (const result of results) { | |
| 587 | if (result._tag === "Failure") { | |
| 588 | expect(result.failure._tag).toBe("SourceControlRateLimitPausedError"); | |
| 589 | } | |
| 590 | } | |
| 591 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 592 | yield* TestClock.setTime(Date.parse(resetAt)); | |
| 593 | yield* cli.getPullRequestPreview(input); | |
| 594 | expect(mockedExecute).toHaveBeenCalledTimes(2); | |
| 595 | yield* TestClock.setTime(now); | |
| 596 | }), | |
| 597 | ); | |
| 598 | ||
| 599 | it.effect("loads the complete hover card with one GraphQL request", () => | |
| 600 | Effect.gen(function* () { | |
| 601 | mockedExecute.mockReturnValueOnce( | |
| 602 | Effect.succeed( | |
| 603 | output( | |
| 604 | encodeJson({ | |
| 605 | data: { | |
| 606 | repository: { | |
| 607 | pullRequest: { | |
| 608 | number: 7, | |
| 609 | title: "Fast previews", | |
| 610 | url: "https://github.example/acme/web/pull/7", | |
| 611 | state: "MERGED", | |
| 612 | isDraft: false, | |
| 613 | createdAt: "2026-07-01T00:00:00Z", | |
| 614 | author: { | |
| 615 | login: "octocat", | |
| 616 | name: "Octo Cat", | |
| 617 | avatarUrl: "https://github.example/avatar.png", | |
| 618 | }, | |
| 619 | }, | |
| 620 | }, | |
| 621 | }, | |
| 622 | }), | |
| 623 | ), | |
| 624 | ), | |
| 625 | ); | |
| 626 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 627 | const preview = yield* cli.getPullRequestPreview({ | |
| 628 | cwd: "/w", | |
| 629 | repository: "acme/web", | |
| 630 | host: "github.example", | |
| 631 | number: 7, | |
| 632 | }); | |
| 633 | expect(preview).toEqual({ | |
| 634 | number: 7, | |
| 635 | title: "Fast previews", | |
| 636 | url: "https://github.example/acme/web/pull/7", | |
| 637 | state: "merged", | |
| 638 | isDraft: false, | |
| 639 | createdAt: "2026-07-01T00:00:00Z", | |
| 640 | author: { | |
| 641 | login: "octocat", | |
| 642 | name: "Octo Cat", | |
| 643 | avatarUrl: "https://github.example/avatar.png", | |
| 644 | }, | |
| 645 | }); | |
| 646 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 647 | expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.example" }); | |
| 648 | }), | |
| 649 | ); | |
| 650 | ||
| 651 | it.effect("coalesces concurrent identity verification for the same host and credential", () => | |
| 652 | Effect.gen(function* () { | |
| 653 | mockedExecute.mockImplementation(() => | |
| 654 | Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))), | |
| 655 | ); | |
| 656 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 657 | const results = yield* Effect.all( | |
| 658 | Array.from({ length: 4 }, () => | |
| 659 | cli.getRoutingIdentity({ cwd: "/w", host: "github.identity-flight.test" }), | |
| 660 | ), | |
| 661 | { concurrency: 4 }, | |
| 662 | ); | |
| 663 | expect(results).toEqual( | |
| 664 | Array.from({ length: 4 }, () => ({ accountId: "123", viewer: "viewer" })), | |
| 665 | ); | |
| 666 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 667 | expect(pathAt(0)).toBe("user"); | |
| 668 | }), | |
| 669 | ); | |
| 670 | ||
| 671 | it.effect( | |
| 672 | "lets another identity reader continue when the first verification is interrupted", | |
| 673 | () => | |
| 674 | Effect.gen(function* () { | |
| 675 | const firstStarted = yield* Deferred.make<void>(); | |
| 676 | let verifications = 0; | |
| 677 | mockedExecute.mockImplementation(() => | |
| 678 | Effect.gen(function* () { | |
| 679 | if (++verifications === 1) { | |
| 680 | yield* Deferred.succeed(firstStarted, undefined); | |
| 681 | return yield* Effect.never; | |
| 682 | } | |
| 683 | return output('{"id":123,"login":"viewer"}'); | |
| 684 | }), | |
| 685 | ); | |
| 686 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 687 | const input = { cwd: "/w", host: "github.identity-cancel.test" }; | |
| 688 | const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); | |
| 689 | yield* Deferred.await(firstStarted); | |
| 690 | // The second reader is queued on the same credential's lock behind the first. | |
| 691 | const second = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); | |
| 692 | yield* Effect.yieldNow; | |
| 693 | yield* Fiber.interrupt(first); | |
| 694 | expect(yield* Fiber.join(second)).toEqual({ accountId: "123", viewer: "viewer" }); | |
| 695 | expect(verifications).toBe(2); | |
| 696 | }), | |
| 697 | ); | |
| 698 | ||
| 699 | it.effect("reads linked pull requests on one host together, filed back by position", () => | |
| 700 | Effect.gen(function* () { | |
| 701 | const node = (number: number) => ({ | |
| 702 | number, | |
| 703 | title: `Pull request ${number}`, | |
| 704 | url: `https://github.com/acme/web/pull/${number}`, | |
| 705 | author: { __typename: "User", login: "octocat", name: "Octo Cat", avatarUrl: null }, | |
| 706 | baseRefName: "main", | |
| 707 | headRefName: `feat/${number}`, | |
| 708 | state: "OPEN", | |
| 709 | isDraft: false, | |
| 710 | mergeable: "MERGEABLE", | |
| 711 | reviewDecision: null, | |
| 712 | latestReviews: { nodes: [{ state: "APPROVED", author: { login: "reviewer" } }] }, | |
| 713 | additions: 12, | |
| 714 | deletions: 3, | |
| 715 | changedFiles: 2, | |
| 716 | updatedAt: "2026-08-24T12:34:56.000Z", | |
| 717 | mergedAt: null, | |
| 718 | closedAt: null, | |
| 719 | commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, | |
| 720 | }); | |
| 721 | mockedExecute.mockReturnValueOnce( | |
| 722 | Effect.succeed( | |
| 723 | output( | |
| 724 | encodeJson({ | |
| 725 | data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } }, | |
| 726 | }), | |
| 727 | ), | |
| 728 | ), | |
| 729 | ); | |
| 730 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 731 | ||
| 732 | const reads = yield* Effect.forEach( | |
| 733 | [7, 8], | |
| 734 | (number) => | |
| 735 | cli.getPullRequestSummary({ | |
| 736 | cwd: "/w", | |
| 737 | repository: "acme/web", | |
| 738 | host: "github.com", | |
| 739 | number, | |
| 740 | }), | |
| 741 | { concurrency: "unbounded" }, | |
| 742 | ).pipe(Effect.forkChild); | |
| 743 | yield* TestClock.adjust("10 millis"); | |
| 744 | const [seven, eight] = yield* Fiber.join(reads); | |
| 745 | ||
| 746 | assert.deepStrictEqual( | |
| 747 | { | |
| 748 | number: seven?.number, | |
| 749 | state: seven?.state, | |
| 750 | headBranch: seven?.headBranch, | |
| 751 | author: seven?.author?.login, | |
| 752 | changedFiles: seven?.changedFiles, | |
| 753 | reviewDecision: seven?.reviewDecision, | |
| 754 | checksState: seven?.checksState, | |
| 755 | mergeability: seven?.mergeability, | |
| 756 | }, | |
| 757 | { | |
| 758 | number: 7, | |
| 759 | state: "open", | |
| 760 | headBranch: "feat/7", | |
| 761 | author: "octocat", | |
| 762 | changedFiles: 2, | |
| 763 | reviewDecision: "approved", | |
| 764 | checksState: "passing", | |
| 765 | mergeability: "mergeable", | |
| 766 | }, | |
| 767 | ); | |
| 768 | assert.strictEqual(eight?.headBranch, "feat/8"); | |
| 769 | expect(mockedExecute).toHaveBeenCalledOnce(); | |
| 770 | const document = queryAt(0); | |
| 771 | expect(document).toContain( | |
| 772 | 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)', | |
| 773 | ); | |
| 774 | expect(document).toContain("pullRequest(number: 8)"); | |
| 775 | }), | |
| 776 | ); | |
| 777 | ||
| 778 | it.effect("fingerprints watched pull requests on one host in one read", () => | |
| 779 | Effect.gen(function* () { | |
| 780 | const node = (comments: number) => ({ | |
| 781 | state: "OPEN", | |
| 782 | mergeable: "MERGEABLE", | |
| 783 | headRefOid: "abc123", | |
| 784 | comments: { totalCount: comments, nodes: [] }, | |
| 785 | reviews: { totalCount: 0, nodes: [] }, | |
| 786 | reviewThreads: { totalCount: 0 }, | |
| 787 | commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, | |
| 788 | }); | |
| 789 | mockedExecute.mockReturnValueOnce( | |
| 790 | Effect.succeed( | |
| 791 | output( | |
| 792 | JSON.stringify({ | |
| 793 | data: { w0: { pullRequest: node(1) }, w1: { pullRequest: null } }, | |
| 794 | }), | |
| 795 | ), | |
| 796 | ), | |
| 797 | ); | |
| 798 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 799 | ||
| 800 | const reads = yield* Effect.forEach( | |
| 801 | [7, 8], | |
| 802 | (number) => | |
| 803 | cli.getPullRequestWatchFingerprint({ | |
| 804 | cwd: "/w", | |
| 805 | repository: "acme/web", | |
| 806 | host: "github.com", | |
| 807 | number, | |
| 808 | }), | |
| 809 | { concurrency: "unbounded" }, | |
| 810 | ).pipe(Effect.forkChild); | |
| 811 | yield* TestClock.adjust("10 millis"); | |
| 812 | const [seven, eight] = yield* Fiber.join(reads); | |
| 813 | ||
| 814 | expect(seven?.remarks.startsWith("1 ")).toBe(true); | |
| 815 | // GitHub had no answer for #8, so its watch reads it in full. | |
| 816 | expect(eight).toBeNull(); | |
| 817 | 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)', | |
| 821 | ); | |
| 822 | }), | |
| 823 | ); | |
| 824 | ||
| 825 | it.effect("reads a pull request the batch said nothing about on its own", () => | |
| 826 | Effect.gen(function* () { | |
| 827 | mockedExecute | |
| 828 | .mockReturnValueOnce(Effect.succeed(output('{"data":{"s0":{"pullRequest":null}}}'))) | |
| 829 | .mockReturnValueOnce( | |
| 830 | Effect.succeed( | |
| 831 | output( | |
| 832 | encodeJson({ | |
| 833 | data: { | |
| 834 | s0: { | |
| 835 | pullRequest: { | |
| 836 | number: 7, | |
| 837 | title: "Reuse the summary", | |
| 838 | url: "https://github.com/acme/web/pull/7", | |
| 839 | author: { login: "octocat", name: "Octo Cat" }, | |
| 840 | baseRefName: "main", | |
| 841 | headRefName: "feat/summary", | |
| 842 | state: "OPEN", | |
| 843 | isDraft: false, | |
| 844 | mergeable: "MERGEABLE", | |
| 845 | reviewDecision: "APPROVED", | |
| 846 | additions: 12, | |
| 847 | deletions: 3, | |
| 848 | changedFiles: 2, | |
| 849 | updatedAt: "2026-08-24T12:34:56.000Z", | |
| 850 | commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, | |
| 851 | }, | |
| 852 | }, | |
| 853 | }, | |
| 854 | }), | |
| 855 | ), | |
| 856 | ), | |
| 857 | ); | |
| 858 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 859 | ||
| 860 | const read = yield* cli | |
| 861 | .getPullRequestSummary({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }) | |
| 862 | .pipe(Effect.forkChild); | |
| 863 | yield* TestClock.adjust("10 millis"); | |
| 864 | const summary = yield* Fiber.join(read); | |
| 865 | ||
| 866 | assert.strictEqual(summary.headBranch, "feat/summary"); | |
| 867 | assert.strictEqual(summary.checksState, "passing"); | |
| 868 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 869 | expect(varsAt(1)).toEqual({ owner: "acme", name: "web", number: 7 }); | |
| 870 | }), | |
| 871 | ); | |
| 872 | ||
| 873 | it.effect("reads the stack a pull request is in through the stacks preview, on its host", () => | |
| 874 | Effect.gen(function* () { | |
| 875 | mockedExecute.mockReturnValueOnce( | |
| 876 | Effect.succeed( | |
| 877 | output( | |
| 878 | encodeJson([ | |
| 879 | { | |
| 880 | id: 42, | |
| 881 | number: 3, | |
| 882 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 883 | base: { ref: "main" }, | |
| 884 | pull_requests: [ | |
| 885 | { | |
| 886 | number: 6, | |
| 887 | head: { ref: "feat/one" }, | |
| 888 | state: "closed", | |
| 889 | merged_at: "2026-09-02", | |
| 890 | }, | |
| 891 | { number: 7, head: { ref: "feat/two" }, state: "open", merged_at: null }, | |
| 892 | ], | |
| 893 | }, | |
| 894 | ]), | |
| 895 | ), | |
| 896 | ), | |
| 897 | ); | |
| 898 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 899 | ||
| 900 | const stack = yield* cli.getPullRequestStack({ | |
| 901 | cwd: "/w", | |
| 902 | repository: "acme/web", | |
| 903 | host: "ghe.example.com", | |
| 904 | number: 7, | |
| 905 | }); | |
| 906 | ||
| 907 | assert.deepStrictEqual(stack, { | |
| 908 | id: "42", | |
| 909 | number: 3, | |
| 910 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 911 | base: "main", | |
| 912 | layers: [ | |
| 913 | { number: 6, headBranch: "feat/one", state: "merged" }, | |
| 914 | { number: 7, headBranch: "feat/two", state: "open" }, | |
| 915 | ], | |
| 916 | }); | |
| 917 | expect(callAt(0)).toMatchObject({ | |
| 918 | kind: "rest", | |
| 919 | host: "ghe.example.com", | |
| 920 | path: "repos/acme/web/stacks?pull_request=7", | |
| 921 | }); | |
| 922 | }), | |
| 923 | ); | |
| 924 | ||
| 925 | it.effect("fetches layer titles only when the caller asks for stack details", () => | |
| 926 | Effect.gen(function* () { | |
| 927 | const minimal = { | |
| 928 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 929 | number: 3, | |
| 930 | base: { ref: "main" }, | |
| 931 | pull_requests: [ | |
| 932 | { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null }, | |
| 933 | ], | |
| 934 | }; | |
| 935 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson([minimal])))); | |
| 936 | mockedExecute.mockReturnValueOnce( | |
| 937 | Effect.succeed( | |
| 938 | output( | |
| 939 | encodeJson({ | |
| 940 | ...minimal, | |
| 941 | pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }], | |
| 942 | }), | |
| 943 | ), | |
| 944 | ), | |
| 945 | ); | |
| 946 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 947 | const stack = yield* cli.getPullRequestStack({ | |
| 948 | cwd: "/w", | |
| 949 | repository: "acme/web", | |
| 950 | host: "github.com", | |
| 951 | number: 7, | |
| 952 | includeDetails: true, | |
| 953 | }); | |
| 954 | expect(stack?.layers[0]).toMatchObject({ | |
| 955 | title: "Second layer", | |
| 956 | headSha: "abc123", | |
| 957 | isDraft: false, | |
| 958 | }); | |
| 959 | expect(pathAt(1)).toBe("repos/acme/web/stacks/3"); | |
| 960 | }), | |
| 961 | ); | |
| 962 | ||
| 963 | it.effect("reads an empty stacks listing as not stacked", () => | |
| 964 | Effect.gen(function* () { | |
| 965 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 966 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 967 | ||
| 968 | const stack = yield* cli.getPullRequestStack({ | |
| 969 | cwd: "/w", | |
| 970 | repository: "acme/web", | |
| 971 | host: "github.com", | |
| 972 | number: 7, | |
| 973 | }); | |
| 974 | ||
| 975 | assert.isNull(stack); | |
| 976 | }), | |
| 977 | ); | |
| 978 | ||
| 979 | it.effect("reads a host that refuses the stacks preview as not stacked", () => | |
| 980 | Effect.gen(function* () { | |
| 981 | mockedExecute.mockReturnValueOnce( | |
| 982 | Effect.fail( | |
| 983 | new GitHubApi.GitHubApiNotFoundError({ | |
| 984 | host: "github.com", | |
| 985 | operation: "getPullRequestStack", | |
| 986 | }), | |
| 987 | ), | |
| 988 | ); | |
| 989 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 990 | ||
| 991 | const stack = yield* cli.getPullRequestStack({ | |
| 992 | cwd: "/w", | |
| 993 | repository: "acme/web", | |
| 994 | host: "github.com", | |
| 995 | number: 7, | |
| 996 | }); | |
| 997 | ||
| 998 | assert.isNull(stack); | |
| 999 | }), | |
| 1000 | ); | |
| 1001 | ||
| 1002 | it.effect("does not read a refused credential as an unstacked pull request", () => | |
| 1003 | Effect.gen(function* () { | |
| 1004 | mockedExecute.mockReturnValueOnce( | |
| 1005 | Effect.fail( | |
| 1006 | new GitHubApi.GitHubApiAuthenticationError({ | |
| 1007 | host: "github.com", | |
| 1008 | operation: "getPullRequestStack", | |
| 1009 | }), | |
| 1010 | ), | |
| 1011 | ); | |
| 1012 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1013 | ||
| 1014 | const error = yield* Effect.flip( | |
| 1015 | cli.getPullRequestStack({ | |
| 1016 | cwd: "/w", | |
| 1017 | repository: "acme/web", | |
| 1018 | host: "github.com", | |
| 1019 | number: 7, | |
| 1020 | }), | |
| 1021 | ); | |
| 1022 | ||
| 1023 | assert.strictEqual(error._tag, "GitHubApiAuthenticationError"); | |
| 1024 | }), | |
| 1025 | ); | |
| 1026 | ||
| 1027 | it.effect("preserves transient stack failures instead of reporting no stack", () => | |
| 1028 | Effect.gen(function* () { | |
| 1029 | const failure = new GitHubApi.GitHubApiResponseError({ | |
| 1030 | host: "github.com", | |
| 1031 | operation: "getPullRequestStack", | |
| 1032 | status: 503, | |
| 1033 | }); | |
| 1034 | mockedExecute.mockReturnValueOnce(Effect.fail(failure)); | |
| 1035 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1036 | const error = yield* Effect.flip( | |
| 1037 | cli.getPullRequestStack({ | |
| 1038 | cwd: "/w", | |
| 1039 | repository: "acme/web", | |
| 1040 | host: "github.com", | |
| 1041 | number: 7, | |
| 1042 | }), | |
| 1043 | ); | |
| 1044 | assert.strictEqual(error, failure); | |
| 1045 | }), | |
| 1046 | ); | |
| 1047 | ||
| 1048 | it.effect("reports a stacks answer it cannot read against the stack read", () => | |
| 1049 | Effect.gen(function* () { | |
| 1050 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]'))); | |
| 1051 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1052 | ||
| 1053 | const error = yield* Effect.flip( | |
| 1054 | cli.getPullRequestStack({ | |
| 1055 | cwd: "/w", | |
| 1056 | repository: "acme/web", | |
| 1057 | host: "github.com", | |
| 1058 | number: 7, | |
| 1059 | }), | |
| 1060 | ); | |
| 1061 | ||
| 1062 | assert.strictEqual(error._tag, "GitHubPullRequestReadError"); | |
| 1063 | if (error._tag !== "GitHubPullRequestReadError") return; | |
| 1064 | assert.strictEqual(error.operation, "getPullRequestStack"); | |
| 1065 | }), | |
| 1066 | ); | |
| 1067 | ||
| 1068 | it.effect("asks for one row more than the page, to probe for a next page", () => | |
| 1069 | Effect.gen(function* () { | |
| 1070 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); | |
| 1071 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1072 | ||
| 1073 | const batch = yield* cli.listPullRequests({ | |
| 1074 | cwd: "/w", | |
| 1075 | repository: "acme/web", | |
| 1076 | host: "github.com", | |
| 1077 | state: "open", | |
| 1078 | involvement: "all", | |
| 1079 | viewer: "bilal", | |
| 1080 | limit: 10, | |
| 1081 | }); | |
| 1082 | ||
| 1083 | assert.strictEqual(batch.items.length, 3); | |
| 1084 | assert.isFalse(batch.truncated); | |
| 1085 | expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.com" }); | |
| 1086 | expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); | |
| 1087 | expect(queryAt(0)).toContain("first: 11"); | |
| 1088 | }), | |
| 1089 | ); | |
| 1090 | ||
| 1091 | it.effect("reports truncation from the extra row, counted before decoding", () => | |
| 1092 | Effect.gen(function* () { | |
| 1093 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1)))); | |
| 1094 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1095 | ||
| 1096 | const batch = yield* cli.listPullRequests({ | |
| 1097 | cwd: "/w", | |
| 1098 | repository: "acme/web", | |
| 1099 | host: "github.com", | |
| 1100 | state: "open", | |
| 1101 | involvement: "all", | |
| 1102 | viewer: "bilal", | |
| 1103 | limit: 10, | |
| 1104 | }); | |
| 1105 | ||
| 1106 | assert.strictEqual(batch.items.length, 10); | |
| 1107 | assert.isTrue(batch.truncated); | |
| 1108 | }), | |
| 1109 | ); | |
| 1110 | ||
| 1111 | it.effect("excludes merged pull requests from the Closed tab", () => | |
| 1112 | Effect.gen(function* () { | |
| 1113 | mockedExecute.mockImplementation((call) => | |
| 1114 | Effect.succeed( | |
| 1115 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1116 | ? emptyList() | |
| 1117 | : emptySearch(), | |
| 1118 | ), | |
| 1119 | ); | |
| 1120 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1121 | ||
| 1122 | yield* cli.listPullRequests({ | |
| 1123 | cwd: "/w", | |
| 1124 | repository: "acme/web", | |
| 1125 | host: "github.com", | |
| 1126 | state: "closed", | |
| 1127 | involvement: "all", | |
| 1128 | viewer: "bilal", | |
| 1129 | limit: 10, | |
| 1130 | }); | |
| 1131 | ||
| 1132 | // A closed pull request may also be a merged one, so the tab narrows through search. | |
| 1133 | expect(searchOfCall(0)).toBe("is:pr is:closed is:unmerged sort:updated-desc repo:acme/web"); | |
| 1134 | }), | |
| 1135 | ); | |
| 1136 | ||
| 1137 | it.effect("narrows to the author on the authored tab", () => | |
| 1138 | Effect.gen(function* () { | |
| 1139 | mockedExecute.mockImplementation((call) => | |
| 1140 | Effect.succeed( | |
| 1141 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1142 | ? emptyList() | |
| 1143 | : emptySearch(), | |
| 1144 | ), | |
| 1145 | ); | |
| 1146 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1147 | ||
| 1148 | yield* cli.listPullRequests({ | |
| 1149 | cwd: "/w", | |
| 1150 | repository: "acme/web", | |
| 1151 | host: "github.com", | |
| 1152 | state: "open", | |
| 1153 | involvement: "authored", | |
| 1154 | viewer: "bilal", | |
| 1155 | limit: 10, | |
| 1156 | }); | |
| 1157 | ||
| 1158 | expect(searchOfCall(0)).toBe("is:pr is:open author:bilal sort:updated-desc repo:acme/web"); | |
| 1159 | }), | |
| 1160 | ); | |
| 1161 | ||
| 1162 | it.effect("narrows through search on the reviewing tab", () => | |
| 1163 | Effect.gen(function* () { | |
| 1164 | mockedExecute.mockImplementation((call) => | |
| 1165 | Effect.succeed( | |
| 1166 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1167 | ? emptyList() | |
| 1168 | : emptySearch(), | |
| 1169 | ), | |
| 1170 | ); | |
| 1171 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1172 | ||
| 1173 | yield* cli.listPullRequests({ | |
| 1174 | cwd: "/w", | |
| 1175 | repository: "acme/web", | |
| 1176 | host: "github.com", | |
| 1177 | state: "open", | |
| 1178 | involvement: "reviewing", | |
| 1179 | viewer: "bilal", | |
| 1180 | limit: 10, | |
| 1181 | }); | |
| 1182 | ||
| 1183 | expect(searchOfCall(0)).toBe( | |
| 1184 | "is:pr is:open review-requested:bilal sort:updated-desc repo:acme/web", | |
| 1185 | ); | |
| 1186 | }), | |
| 1187 | ); | |
| 1188 | ||
| 1189 | it.effect("carries every repository and every qualifier into one search", () => | |
| 1190 | Effect.gen(function* () { | |
| 1191 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); | |
| 1192 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1193 | ||
| 1194 | yield* cli.searchPullRequests({ | |
| 1195 | cwd: "/w", | |
| 1196 | host: "github.com", | |
| 1197 | repositories: ["acme/web", "pingdotgg/t3code"], | |
| 1198 | state: "closed", | |
| 1199 | involvement: "reviewing", | |
| 1200 | viewer: "bilal", | |
| 1201 | limit: 10, | |
| 1202 | query: "pull requests page", | |
| 1203 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, | |
| 1204 | }); | |
| 1205 | ||
| 1206 | // One request for both repositories, carrying everything the per-repository read expresses | |
| 1207 | // as a flag: the tab, the involvement, the reader's words, where to carry on from, and the | |
| 1208 | // order the page reads in. | |
| 1209 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 1210 | assert.strictEqual( | |
| 1211 | searchQueryOfCall(0), | |
| 1212 | 'is:pr is:closed is:unmerged review-requested:bilal "pull requests page" ' + | |
| 1213 | "updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web repo:pingdotgg/t3code", | |
| 1214 | ); | |
| 1215 | }), | |
| 1216 | ); | |
| 1217 | ||
| 1218 | it.effect("narrows a search to the author, and to merged on the merged tab", () => | |
| 1219 | Effect.gen(function* () { | |
| 1220 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); | |
| 1221 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1222 | ||
| 1223 | yield* cli.searchPullRequests({ | |
| 1224 | cwd: "/w", | |
| 1225 | host: "github.com", | |
| 1226 | repositories: ["acme/web"], | |
| 1227 | state: "merged", | |
| 1228 | involvement: "authored", | |
| 1229 | viewer: "bilal", | |
| 1230 | limit: 10, | |
| 1231 | }); | |
| 1232 | ||
| 1233 | assert.strictEqual( | |
| 1234 | searchQueryOfCall(0), | |
| 1235 | "is:pr is:merged author:bilal sort:updated-desc repo:acme/web", | |
| 1236 | ); | |
| 1237 | }), | |
| 1238 | ); | |
| 1239 | ||
| 1240 | it.effect("keeps a searched-for qualifier inside the phrase", () => | |
| 1241 | Effect.gen(function* () { | |
| 1242 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); | |
| 1243 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1244 | ||
| 1245 | yield* cli.searchPullRequests({ | |
| 1246 | cwd: "/w", | |
| 1247 | host: "github.com", | |
| 1248 | repositories: ["acme/web"], | |
| 1249 | state: "open", | |
| 1250 | involvement: "all", | |
| 1251 | viewer: "bilal", | |
| 1252 | limit: 10, | |
| 1253 | query: 'x" is:merged repo:evil/repo', | |
| 1254 | }); | |
| 1255 | ||
| 1256 | // Quoted and escaped, so the words a reader typed narrow the listing rather than widening it. | |
| 1257 | assert.strictEqual( | |
| 1258 | searchQueryOfCall(0), | |
| 1259 | 'is:pr is:open "x\\" is:merged repo:evil/repo" sort:updated-desc repo:acme/web', | |
| 1260 | ); | |
| 1261 | }), | |
| 1262 | ); | |
| 1263 | ||
| 1264 | it.effect("refuses to search for a repository GitHub cannot address", () => | |
| 1265 | Effect.gen(function* () { | |
| 1266 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1267 | ||
| 1268 | const failure = yield* Effect.flip( | |
| 1269 | cli.searchPullRequests({ | |
| 1270 | cwd: "/w", | |
| 1271 | host: "github.com", | |
| 1272 | repositories: ["acme/web", "acme/web is:merged"], | |
| 1273 | state: "open", | |
| 1274 | involvement: "all", | |
| 1275 | viewer: "bilal", | |
| 1276 | limit: 10, | |
| 1277 | }), | |
| 1278 | ); | |
| 1279 | ||
| 1280 | // Nothing is sent: a name that could end its own qualifier is refused rather than escaped. | |
| 1281 | assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); | |
| 1282 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 1283 | }), | |
| 1284 | ); | |
| 1285 | ||
| 1286 | it.effect("files each searched row under the repository it came from", () => | |
| 1287 | Effect.gen(function* () { | |
| 1288 | mockedExecute.mockReturnValue( | |
| 1289 | Effect.succeed( | |
| 1290 | searchPage([ | |
| 1291 | searchItem(7, "acme/web", "2026-07-03T00:00:00Z"), | |
| 1292 | searchItem(9, "pingdotgg/t3code", "2026-07-02T00:00:00Z"), | |
| 1293 | // Not a pull request, which `is:pr` excludes and a decode skips rather than fails on. | |
| 1294 | {}, | |
| 1295 | ]), | |
| 1296 | ), | |
| 1297 | ); | |
| 1298 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1299 | ||
| 1300 | const batch = yield* cli.searchPullRequests({ | |
| 1301 | cwd: "/w", | |
| 1302 | host: "github.com", | |
| 1303 | repositories: ["acme/web", "pingdotgg/t3code"], | |
| 1304 | state: "open", | |
| 1305 | involvement: "all", | |
| 1306 | viewer: "bilal", | |
| 1307 | limit: 10, | |
| 1308 | }); | |
| 1309 | ||
| 1310 | assert.deepStrictEqual( | |
| 1311 | batch.items.map((item) => [item.repository, item.number, item.author?.avatarUrl]), | |
| 1312 | [ | |
| 1313 | ["acme/web", 7, "https://avatars/octocat"], | |
| 1314 | ["pingdotgg/t3code", 9, "https://avatars/octocat"], | |
| 1315 | ], | |
| 1316 | ); | |
| 1317 | // The listing leaves the line counts to a read of their own. | |
| 1318 | assert.deepStrictEqual( | |
| 1319 | batch.items.map((item) => [item.additions, item.deletions]), | |
| 1320 | [ | |
| 1321 | [0, 0], | |
| 1322 | [0, 0], | |
| 1323 | ], | |
| 1324 | ); | |
| 1325 | assert.isFalse(batch.truncated); | |
| 1326 | }), | |
| 1327 | ); | |
| 1328 | ||
| 1329 | it.effect("reports truncation from the extra row, and from a page GitHub says has more", () => | |
| 1330 | Effect.gen(function* () { | |
| 1331 | mockedExecute | |
| 1332 | .mockReturnValueOnce( | |
| 1333 | Effect.succeed( | |
| 1334 | searchPage([ | |
| 1335 | searchItem(1, "acme/web", "2026-07-03T00:00:00Z"), | |
| 1336 | searchItem(2, "acme/web", "2026-07-02T00:00:00Z"), | |
| 1337 | searchItem(3, "acme/web", "2026-07-01T00:00:00Z"), | |
| 1338 | ]), | |
| 1339 | ), | |
| 1340 | ) | |
| 1341 | .mockReturnValueOnce( | |
| 1342 | Effect.succeed(searchPage([searchItem(1, "acme/web", "2026-07-03T00:00:00Z")], true)), | |
| 1343 | ); | |
| 1344 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1345 | const read = () => | |
| 1346 | cli.searchPullRequests({ | |
| 1347 | cwd: "/w", | |
| 1348 | host: "github.com", | |
| 1349 | repositories: ["acme/web"], | |
| 1350 | state: "open", | |
| 1351 | involvement: "all", | |
| 1352 | viewer: "bilal", | |
| 1353 | limit: 2, | |
| 1354 | }); | |
| 1355 | ||
| 1356 | const overflowing = yield* read(); | |
| 1357 | const capped = yield* read(); | |
| 1358 | ||
| 1359 | // The extra row is the probe, and it is not handed on. | |
| 1360 | assert.strictEqual(overflowing.items.length, 2); | |
| 1361 | assert.isTrue(overflowing.truncated); | |
| 1362 | // A slice at GitHub's own ceiling has no extra row to probe with, so `hasNextPage` answers. | |
| 1363 | assert.isTrue(capped.truncated); | |
| 1364 | }), | |
| 1365 | ); | |
| 1366 | ||
| 1367 | it.effect("enriches only the visible fallback rows after filtering and widening", () => | |
| 1368 | Effect.gen(function* () { | |
| 1369 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1370 | mockedExecute.mockReturnValueOnce( | |
| 1371 | Effect.succeed(output(listedPullRequests(3, 1, () => ({ isDraft: true })))), | |
| 1372 | ); | |
| 1373 | mockedExecute.mockReturnValueOnce( | |
| 1374 | Effect.succeed(output(listedPullRequests(6, 1, (number) => ({ isDraft: number < 4 })))), | |
| 1375 | ); | |
| 1376 | mockedStackMemberships.mockReturnValueOnce( | |
| 1377 | Effect.succeed( | |
| 1378 | output( | |
| 1379 | encodeJson({ | |
| 1380 | data: { | |
| 1381 | s0: { | |
| 1382 | pullRequest: { | |
| 1383 | stack: { number: 3, size: 2, baseRefName: "main" }, | |
| 1384 | stackEntry: { position: 1 }, | |
| 1385 | }, | |
| 1386 | }, | |
| 1387 | s1: { pullRequest: { stack: null, stackEntry: null } }, | |
| 1388 | }, | |
| 1389 | }), | |
| 1390 | ), | |
| 1391 | ), | |
| 1392 | ); | |
| 1393 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1394 | const batch = yield* cli.listPullRequests({ | |
| 1395 | cwd: "/w", | |
| 1396 | repository: "acme/web", | |
| 1397 | host: "github.com", | |
| 1398 | state: "open", | |
| 1399 | involvement: "all", | |
| 1400 | viewer: "bilal", | |
| 1401 | limit: 2, | |
| 1402 | filters: { draft: "hide" }, | |
| 1403 | }); | |
| 1404 | expect(batch.items.map((item) => item.number)).toEqual([4, 5]); | |
| 1405 | expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); | |
| 1406 | expect(batch.items[1]?.stack).toBeUndefined(); | |
| 1407 | expect(batch.truncated).toBe(true); | |
| 1408 | expect(batch.continues).toBe(false); | |
| 1409 | expect(mockedStackMemberships).toHaveBeenCalledTimes(1); | |
| 1410 | 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)"); | |
| 1416 | }), | |
| 1417 | ); | |
| 1418 | ||
| 1419 | it.effect("batches membership reads and keeps successful rows when one chunk fails", () => | |
| 1420 | Effect.gen(function* () { | |
| 1421 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1)))); | |
| 1422 | mockedStackMemberships.mockImplementation((input) => | |
| 1423 | input.kind === "graphql" && input.query.includes("pullRequest(number: 26)") | |
| 1424 | ? Effect.fail( | |
| 1425 | new GitHubApi.GitHubApiResponseError({ | |
| 1426 | host: "github.com", | |
| 1427 | operation: "listPullRequestStackMemberships", | |
| 1428 | status: 502, | |
| 1429 | }), | |
| 1430 | ) | |
| 1431 | : Effect.succeed( | |
| 1432 | output( | |
| 1433 | encodeJson({ | |
| 1434 | data: { | |
| 1435 | s0: { | |
| 1436 | pullRequest: { | |
| 1437 | stack: { number: 3, size: 2, baseRefName: "main" }, | |
| 1438 | stackEntry: { position: 1 }, | |
| 1439 | }, | |
| 1440 | }, | |
| 1441 | }, | |
| 1442 | }), | |
| 1443 | ), | |
| 1444 | ), | |
| 1445 | ); | |
| 1446 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1447 | const batch = yield* cli.listPullRequests({ | |
| 1448 | cwd: "/w", | |
| 1449 | repository: "acme/web", | |
| 1450 | host: "github.com", | |
| 1451 | state: "open", | |
| 1452 | involvement: "all", | |
| 1453 | viewer: "bilal", | |
| 1454 | limit: 26, | |
| 1455 | }); | |
| 1456 | expect(batch.items.map((item) => item.number)).toEqual( | |
| 1457 | Array.from({ length: 26 }, (_, i) => i + 1), | |
| 1458 | ); | |
| 1459 | expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); | |
| 1460 | expect(batch.items[25]?.stack).toBeUndefined(); | |
| 1461 | expect(batch.truncated).toBe(true); | |
| 1462 | expect(batch.continues).toBe(true); | |
| 1463 | expect(mockedStackMemberships).toHaveBeenCalledTimes(2); | |
| 1464 | }), | |
| 1465 | ); | |
| 1466 | ||
| 1467 | it.effect("skips membership enrichment for empty pages and enterprise hosts", () => | |
| 1468 | Effect.gen(function* () { | |
| 1469 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1470 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptyList())); | |
| 1471 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7)))); | |
| 1472 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1473 | const input = { | |
| 1474 | cwd: "/w", | |
| 1475 | repository: "acme/web", | |
| 1476 | state: "open" as const, | |
| 1477 | involvement: "all" as const, | |
| 1478 | viewer: "bilal", | |
| 1479 | limit: 2, | |
| 1480 | }; | |
| 1481 | const empty = yield* cli.listPullRequests({ ...input, host: "github.com" }); | |
| 1482 | const enterprise = yield* cli.listPullRequests({ ...input, host: "github.acme.test" }); | |
| 1483 | expect(empty.items).toEqual([]); | |
| 1484 | expect(enterprise.items.map((item) => item.number)).toEqual([7]); | |
| 1485 | expect(mockedStackMemberships).not.toHaveBeenCalled(); | |
| 1486 | }), | |
| 1487 | ); | |
| 1488 | ||
| 1489 | it.effect("reads the line counts in chunks, and files them back by position", () => | |
| 1490 | Effect.gen(function* () { | |
| 1491 | const changeRequests = Array.from({ length: 26 }, (_, index) => ({ | |
| 1492 | repository: "acme/web", | |
| 1493 | number: index + 1, | |
| 1494 | })); | |
| 1495 | mockedExecute.mockImplementation(() => | |
| 1496 | // Every chunk answers for its first alias only, so a row GitHub said nothing about is | |
| 1497 | // dropped rather than shown as a change of no size. | |
| 1498 | Effect.succeed( | |
| 1499 | output(encodeJson({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })), | |
| 1500 | ), | |
| 1501 | ); | |
| 1502 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1503 | ||
| 1504 | const stats = yield* cli.listPullRequestStats({ | |
| 1505 | cwd: "/w", | |
| 1506 | host: "github.com", | |
| 1507 | changeRequests, | |
| 1508 | }); | |
| 1509 | ||
| 1510 | // Twenty-five aliases a request, so twenty-six rows are two requests. | |
| 1511 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 1512 | assert.deepStrictEqual(stats, [ | |
| 1513 | { repository: "acme/web", number: 1, additions: 4, deletions: 1 }, | |
| 1514 | { repository: "acme/web", number: 26, additions: 4, deletions: 1 }, | |
| 1515 | ]); | |
| 1516 | const document = queryAt(0); | |
| 1517 | expect(document).toContain('s0: repository(owner: "acme", name: "web")'); | |
| 1518 | expect(document).toContain("pullRequest(number: 25)"); | |
| 1519 | }), | |
| 1520 | ); | |
| 1521 | ||
| 1522 | it.effect("refuses to look up counts for a repository GitHub cannot address", () => | |
| 1523 | Effect.gen(function* () { | |
| 1524 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1525 | ||
| 1526 | const failure = yield* Effect.flip( | |
| 1527 | cli.listPullRequestStats({ | |
| 1528 | cwd: "/w", | |
| 1529 | host: "github.com", | |
| 1530 | changeRequests: [{ repository: 'acme/web") { x } #', number: 1 }], | |
| 1531 | }), | |
| 1532 | ); | |
| 1533 | ||
| 1534 | assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); | |
| 1535 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 1536 | }), | |
| 1537 | ); | |
| 1538 | ||
| 1539 | it.effect("hands a search to GitHub rather than to the rows already read", () => | |
| 1540 | Effect.gen(function* () { | |
| 1541 | mockedExecute.mockImplementation((call) => | |
| 1542 | Effect.succeed( | |
| 1543 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1544 | ? emptyList() | |
| 1545 | : emptySearch(), | |
| 1546 | ), | |
| 1547 | ); | |
| 1548 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1549 | ||
| 1550 | yield* cli.listPullRequests({ | |
| 1551 | cwd: "/w", | |
| 1552 | repository: "acme/web", | |
| 1553 | host: "github.com", | |
| 1554 | state: "open", | |
| 1555 | involvement: "all", | |
| 1556 | viewer: "bilal", | |
| 1557 | limit: 10, | |
| 1558 | query: "pull requests page", | |
| 1559 | }); | |
| 1560 | ||
| 1561 | // The recency qualifier rides along, because free text would otherwise reorder the page | |
| 1562 | // by relevance and truncation would drop the newest matches. | |
| 1563 | expect(searchOfCall(0)).toBe( | |
| 1564 | 'is:pr is:open "pull requests page" sort:updated-desc repo:acme/web', | |
| 1565 | ); | |
| 1566 | }), | |
| 1567 | ); | |
| 1568 | ||
| 1569 | it.effect("joins a search onto the tab's own qualifiers instead of replacing them", () => | |
| 1570 | Effect.gen(function* () { | |
| 1571 | mockedExecute.mockImplementation((call) => | |
| 1572 | Effect.succeed( | |
| 1573 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1574 | ? emptyList() | |
| 1575 | : emptySearch(), | |
| 1576 | ), | |
| 1577 | ); | |
| 1578 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1579 | ||
| 1580 | yield* cli.listPullRequests({ | |
| 1581 | cwd: "/w", | |
| 1582 | repository: "acme/web", | |
| 1583 | host: "github.com", | |
| 1584 | state: "closed", | |
| 1585 | involvement: "reviewing", | |
| 1586 | viewer: "bilal", | |
| 1587 | limit: 10, | |
| 1588 | query: "page", | |
| 1589 | }); | |
| 1590 | ||
| 1591 | expect(searchOfCall(0)).toBe( | |
| 1592 | 'is:pr is:closed is:unmerged review-requested:bilal "page" sort:updated-desc repo:acme/web', | |
| 1593 | ); | |
| 1594 | }), | |
| 1595 | ); | |
| 1596 | ||
| 1597 | it.effect("carries the further narrowings into the search as qualifiers", () => | |
| 1598 | Effect.gen(function* () { | |
| 1599 | mockedExecute.mockImplementation((call) => | |
| 1600 | Effect.succeed( | |
| 1601 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1602 | ? emptyList() | |
| 1603 | : emptySearch(), | |
| 1604 | ), | |
| 1605 | ); | |
| 1606 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1607 | ||
| 1608 | yield* cli.listPullRequests({ | |
| 1609 | cwd: "/w", | |
| 1610 | repository: "acme/web", | |
| 1611 | host: "github.com", | |
| 1612 | state: "open", | |
| 1613 | involvement: "all", | |
| 1614 | viewer: "bilal", | |
| 1615 | limit: 10, | |
| 1616 | filters: { | |
| 1617 | draft: "hide", | |
| 1618 | review: "changes-requested", | |
| 1619 | checks: "failing", | |
| 1620 | labels: [["needs design"], ['quo"te']], | |
| 1621 | excludedLabels: ["wip"], | |
| 1622 | author: "octocat", | |
| 1623 | }, | |
| 1624 | }); | |
| 1625 | ||
| 1626 | // Quotes around anything a reader typed, and the one character that could end a quoted | |
| 1627 | // value early dropped rather than escaped. | |
| 1628 | expect(searchOfCall(0)).toBe( | |
| 1629 | 'is:pr is:open label:"needs design" label:"quote" -label:"wip" author:"octocat" ' + | |
| 1630 | "draft:false review:changes_requested status:failure sort:updated-desc repo:acme/web", | |
| 1631 | ); | |
| 1632 | }), | |
| 1633 | ); | |
| 1634 | ||
| 1635 | it.effect('resolves an author filter of "me" to the viewer, not the literal word', () => | |
| 1636 | Effect.gen(function* () { | |
| 1637 | mockedExecute.mockImplementation((call) => | |
| 1638 | Effect.succeed( | |
| 1639 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1640 | ? emptyList() | |
| 1641 | : emptySearch(), | |
| 1642 | ), | |
| 1643 | ); | |
| 1644 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1645 | ||
| 1646 | yield* cli.listPullRequests({ | |
| 1647 | cwd: "/w", | |
| 1648 | repository: "acme/web", | |
| 1649 | host: "github.com", | |
| 1650 | state: "open", | |
| 1651 | involvement: "all", | |
| 1652 | viewer: "bilal", | |
| 1653 | limit: 10, | |
| 1654 | filters: { author: "me" }, | |
| 1655 | }); | |
| 1656 | ||
| 1657 | expect(searchOfCall(0)).toBe('is:pr is:open author:"bilal" sort:updated-desc repo:acme/web'); | |
| 1658 | }), | |
| 1659 | ); | |
| 1660 | ||
| 1661 | it.effect("sends one label qualifier per group, its names joined the way GitHub ors them", () => | |
| 1662 | Effect.gen(function* () { | |
| 1663 | mockedExecute.mockImplementation((call) => | |
| 1664 | Effect.succeed( | |
| 1665 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1666 | ? emptyList() | |
| 1667 | : emptySearch(), | |
| 1668 | ), | |
| 1669 | ); | |
| 1670 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1671 | ||
| 1672 | yield* cli.listPullRequests({ | |
| 1673 | cwd: "/w", | |
| 1674 | repository: "acme/web", | |
| 1675 | host: "github.com", | |
| 1676 | state: "open", | |
| 1677 | involvement: "all", | |
| 1678 | viewer: "bilal", | |
| 1679 | limit: 10, | |
| 1680 | filters: { labels: [["size:S", "size:XS"], ["bug"]] }, | |
| 1681 | }); | |
| 1682 | ||
| 1683 | // One qualifier satisfied by either size, and a second one that must hold as well. | |
| 1684 | expect(searchOfCall(0)).toBe( | |
| 1685 | 'is:pr is:open label:"size:S","size:XS" label:"bug" sort:updated-desc repo:acme/web', | |
| 1686 | ); | |
| 1687 | }), | |
| 1688 | ); | |
| 1689 | ||
| 1690 | it.effect( | |
| 1691 | "falls back for a repository the index does not cover under a checks filter, keeping only the matching rows", | |
| 1692 | () => | |
| 1693 | Effect.gen(function* () { | |
| 1694 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1695 | mockedExecute.mockReturnValueOnce( | |
| 1696 | Effect.succeed( | |
| 1697 | output( | |
| 1698 | listedPullRequests(2, 1, (number) => ({ | |
| 1699 | checks: number === 1 ? "SUCCESS" : "FAILURE", | |
| 1700 | })), | |
| 1701 | ), | |
| 1702 | ), | |
| 1703 | ); | |
| 1704 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1705 | ||
| 1706 | const batch = yield* cli.listPullRequests({ | |
| 1707 | cwd: "/w", | |
| 1708 | repository: "acme/web", | |
| 1709 | host: "github.com", | |
| 1710 | state: "open", | |
| 1711 | involvement: "all", | |
| 1712 | viewer: "bilal", | |
| 1713 | limit: 10, | |
| 1714 | filters: { checks: "passing" }, | |
| 1715 | }); | |
| 1716 | ||
| 1717 | // The fallback's rows carry `checksState` exactly as a search's rows do, so `checks` is | |
| 1718 | // now a filter the fallback judges itself, the same as `draft`: an empty search answer | |
| 1719 | // under it is still ambiguous, and the row picked out afterwards is the one whose own | |
| 1720 | // `checksState` reads "passing". | |
| 1721 | expect(searchOfCall(1)).toBeUndefined(); | |
| 1722 | assert.deepStrictEqual( | |
| 1723 | batch.items.map((item) => item.number), | |
| 1724 | [1], | |
| 1725 | ); | |
| 1726 | }), | |
| 1727 | ); | |
| 1728 | ||
| 1729 | it.effect("fails a checks filter for a row whose checks are still pending", () => | |
| 1730 | Effect.gen(function* () { | |
| 1731 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1732 | mockedExecute.mockReturnValueOnce( | |
| 1733 | Effect.succeed(output(listedPullRequests(1, 1, () => ({ checks: "PENDING" })))), | |
| 1734 | ); | |
| 1735 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1736 | ||
| 1737 | const batch = yield* cli.listPullRequests({ | |
| 1738 | cwd: "/w", | |
| 1739 | repository: "acme/web", | |
| 1740 | host: "github.com", | |
| 1741 | state: "open", | |
| 1742 | involvement: "all", | |
| 1743 | viewer: "bilal", | |
| 1744 | limit: 10, | |
| 1745 | filters: { checks: "passing" }, | |
| 1746 | }); | |
| 1747 | ||
| 1748 | // Pending equals neither "passing" nor "failing", so it satisfies neither filter value — | |
| 1749 | // the same row would also be dropped by `checks: "failing"`. | |
| 1750 | assert.deepStrictEqual(batch.items, []); | |
| 1751 | }), | |
| 1752 | ); | |
| 1753 | ||
| 1754 | it.effect( | |
| 1755 | "falls back for a repository the index does not cover even under a judgeable filter", | |
| 1756 | () => | |
| 1757 | Effect.gen(function* () { | |
| 1758 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1759 | mockedExecute.mockReturnValueOnce( | |
| 1760 | Effect.succeed(output(listedPullRequests(2, 1, (number) => ({ isDraft: number === 1 })))), | |
| 1761 | ); | |
| 1762 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1763 | ||
| 1764 | const batch = yield* cli.listPullRequests({ | |
| 1765 | cwd: "/w", | |
| 1766 | repository: "acme/web", | |
| 1767 | host: "github.com", | |
| 1768 | state: "open", | |
| 1769 | involvement: "all", | |
| 1770 | viewer: "bilal", | |
| 1771 | limit: 10, | |
| 1772 | filters: { draft: "hide" }, | |
| 1773 | }); | |
| 1774 | ||
| 1775 | // `draft` is a filter the fallback can judge over its own rows just as search judges it, | |
| 1776 | // so an empty search answer under it alone is still ambiguous between "nothing matches" | |
| 1777 | // and "this repository is not indexed" — and the fallback applies the filter itself, | |
| 1778 | // keeping only the non-draft row. | |
| 1779 | expect(searchOfCall(1)).toBeUndefined(); | |
| 1780 | expect(batch.items.map((item) => item.number)).toEqual([2]); | |
| 1781 | }), | |
| 1782 | ); | |
| 1783 | ||
| 1784 | it.effect("carries the further narrowings into a batched search", () => | |
| 1785 | Effect.gen(function* () { | |
| 1786 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); | |
| 1787 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1788 | ||
| 1789 | yield* cli.searchPullRequests({ | |
| 1790 | cwd: "/w", | |
| 1791 | host: "github.com", | |
| 1792 | repositories: ["acme/web"], | |
| 1793 | state: "open", | |
| 1794 | involvement: "all", | |
| 1795 | viewer: "bilal", | |
| 1796 | limit: 10, | |
| 1797 | filters: { draft: "only", review: "none", labels: [["bug"]] }, | |
| 1798 | }); | |
| 1799 | ||
| 1800 | assert.strictEqual( | |
| 1801 | searchQueryOfCall(0), | |
| 1802 | 'is:pr is:open label:"bug" draft:true review:none sort:updated-desc repo:acme/web', | |
| 1803 | ); | |
| 1804 | }), | |
| 1805 | ); | |
| 1806 | ||
| 1807 | it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () => | |
| 1808 | Effect.gen(function* () { | |
| 1809 | mockedExecute.mockImplementation((call) => | |
| 1810 | Effect.succeed( | |
| 1811 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1812 | ? emptyList() | |
| 1813 | : emptySearch(), | |
| 1814 | ), | |
| 1815 | ); | |
| 1816 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1817 | ||
| 1818 | yield* cli.listPullRequests({ | |
| 1819 | cwd: "/w", | |
| 1820 | repository: "acme/web", | |
| 1821 | host: "github.com", | |
| 1822 | state: "open", | |
| 1823 | involvement: "all", | |
| 1824 | viewer: "bilal", | |
| 1825 | limit: 10, | |
| 1826 | query: '-- is:merged label:secret "widen me"', | |
| 1827 | }); | |
| 1828 | ||
| 1829 | // Every word stays inside one phrase: nothing before it, nothing after it, and the | |
| 1830 | // leading dashes are text rather than the start of another argument. | |
| 1831 | expect(searchOfCall(0)).toBe( | |
| 1832 | String.raw`is:pr is:open "-- is:merged label:secret \"widen me\"" sort:updated-desc repo:acme/web`, | |
| 1833 | ); | |
| 1834 | }), | |
| 1835 | ); | |
| 1836 | ||
| 1837 | it.effect("escapes a backslash before the quote it would otherwise let out", () => | |
| 1838 | Effect.gen(function* () { | |
| 1839 | mockedExecute.mockImplementation((call) => | |
| 1840 | Effect.succeed( | |
| 1841 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1842 | ? emptyList() | |
| 1843 | : emptySearch(), | |
| 1844 | ), | |
| 1845 | ); | |
| 1846 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1847 | ||
| 1848 | yield* cli.listPullRequests({ | |
| 1849 | cwd: "/w", | |
| 1850 | repository: "acme/web", | |
| 1851 | host: "github.com", | |
| 1852 | state: "open", | |
| 1853 | involvement: "all", | |
| 1854 | viewer: "bilal", | |
| 1855 | limit: 10, | |
| 1856 | query: String.raw`a\" is:merged`, | |
| 1857 | }); | |
| 1858 | ||
| 1859 | // GitHub reads `\\` as one backslash and `\"` as one quote, so the phrase ends where | |
| 1860 | // this says it does; escaping the quote alone would have closed it early. | |
| 1861 | expect(searchOfCall(0)).toBe( | |
| 1862 | String.raw`is:pr is:open "a\\\" is:merged" sort:updated-desc repo:acme/web`, | |
| 1863 | ); | |
| 1864 | }), | |
| 1865 | ); | |
| 1866 | ||
| 1867 | it.effect("asks for nothing but the order when the reader typed only spaces", () => | |
| 1868 | Effect.gen(function* () { | |
| 1869 | mockedExecute.mockImplementation((call) => | |
| 1870 | Effect.succeed( | |
| 1871 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1872 | ? emptyList() | |
| 1873 | : emptySearch(), | |
| 1874 | ), | |
| 1875 | ); | |
| 1876 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1877 | ||
| 1878 | yield* cli.listPullRequests({ | |
| 1879 | cwd: "/w", | |
| 1880 | repository: "acme/web", | |
| 1881 | host: "github.com", | |
| 1882 | state: "open", | |
| 1883 | involvement: "all", | |
| 1884 | viewer: "bilal", | |
| 1885 | limit: 10, | |
| 1886 | query: " ", | |
| 1887 | }); | |
| 1888 | ||
| 1889 | // An empty phrase would match nothing rather than everything, so it is left out; the | |
| 1890 | // order the page reads rows in is asked for whether or not anything was typed. | |
| 1891 | expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); | |
| 1892 | }), | |
| 1893 | ); | |
| 1894 | ||
| 1895 | it.effect("carries on from the instant the last slice ended on", () => | |
| 1896 | Effect.gen(function* () { | |
| 1897 | mockedExecute.mockReturnValue(Effect.succeed(output(pullRequests(3, 1)))); | |
| 1898 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1899 | ||
| 1900 | const batch = yield* cli.listPullRequests({ | |
| 1901 | cwd: "/w", | |
| 1902 | repository: "acme/web", | |
| 1903 | host: "github.com", | |
| 1904 | state: "open", | |
| 1905 | involvement: "all", | |
| 1906 | viewer: "bilal", | |
| 1907 | limit: 10, | |
| 1908 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, | |
| 1909 | }); | |
| 1910 | ||
| 1911 | // Inclusive, so the rows already sent at that instant come back for the caller to drop — | |
| 1912 | // which is what keeps the ones beside them from being skipped. | |
| 1913 | expect(searchOfCall(0)).toBe( | |
| 1914 | "is:pr is:open updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web", | |
| 1915 | ); | |
| 1916 | assert.isTrue(batch.continues); | |
| 1917 | }), | |
| 1918 | ); | |
| 1919 | ||
| 1920 | it.effect("answers a search that found nothing with nothing, not with the whole repository", () => | |
| 1921 | Effect.gen(function* () { | |
| 1922 | // The fallback is for a repository the index does not cover. Under a text search an empty | |
| 1923 | // answer means the text matched nothing, and listing everything instead would fill the | |
| 1924 | // page with rows the reader did not search for. | |
| 1925 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1926 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1927 | ||
| 1928 | const batch = yield* cli.listPullRequests({ | |
| 1929 | cwd: "/w", | |
| 1930 | repository: "acme/web", | |
| 1931 | host: "github.com", | |
| 1932 | state: "open", | |
| 1933 | involvement: "all", | |
| 1934 | viewer: "bilal", | |
| 1935 | limit: 10, | |
| 1936 | query: "fdsfklj", | |
| 1937 | }); | |
| 1938 | ||
| 1939 | assert.strictEqual(batch.items.length, 0); | |
| 1940 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 1941 | }), | |
| 1942 | ); | |
| 1943 | ||
| 1944 | it.effect("reads a repository GitHub will not search from its own list", () => | |
| 1945 | Effect.gen(function* () { | |
| 1946 | // GitHub answers for a repository outside its search index with no rows and no error. | |
| 1947 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1948 | mockedExecute.mockReturnValueOnce( | |
| 1949 | Effect.succeed(output(listedPullRequests(3, 1, () => ({ state: "CLOSED" })))), | |
| 1950 | ); | |
| 1951 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1952 | ||
| 1953 | const batch = yield* cli.listPullRequests({ | |
| 1954 | cwd: "/w", | |
| 1955 | repository: "acme/web", | |
| 1956 | host: "github.com", | |
| 1957 | state: "closed", | |
| 1958 | involvement: "all", | |
| 1959 | viewer: "bilal", | |
| 1960 | limit: 10, | |
| 1961 | }); | |
| 1962 | ||
| 1963 | assert.strictEqual(batch.items.length, 3); | |
| 1964 | // The fallback itself uses no search, then narrows the decoded rows locally. They arrive | |
| 1965 | // newest-created first, so nothing can carry on from them. | |
| 1966 | expect(searchOfCall(1)).toBeUndefined(); | |
| 1967 | expect(varsAt(1)).toMatchObject({ owner: "acme", name: "web", states: ["CLOSED", "MERGED"] }); | |
| 1968 | assert.isFalse(batch.continues); | |
| 1969 | }), | |
| 1970 | ); | |
| 1971 | ||
| 1972 | it.effect("keeps state and involvement filters on the search-free fallback", () => | |
| 1973 | Effect.gen(function* () { | |
| 1974 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1975 | mockedExecute.mockReturnValueOnce( | |
| 1976 | Effect.succeed( | |
| 1977 | output( | |
| 1978 | listedPullRequests(4, 1, (number) => ({ | |
| 1979 | state: number === 4 ? "OPEN" : "CLOSED", | |
| 1980 | ...(number === 3 ? { mergedAt: "2026-07-03T00:00:00Z" } : {}), | |
| 1981 | reviewRequests: | |
| 1982 | number === 2 ? [{ slug: "platform", name: "Platform" }] : [{ login: "bilal" }], | |
| 1983 | })), | |
| 1984 | ), | |
| 1985 | ), | |
| 1986 | ); | |
| 1987 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 1988 | ||
| 1989 | const batch = yield* cli.listPullRequests({ | |
| 1990 | cwd: "/w", | |
| 1991 | repository: "acme/web", | |
| 1992 | host: "github.com", | |
| 1993 | state: "closed", | |
| 1994 | involvement: "reviewing", | |
| 1995 | viewer: "bilal", | |
| 1996 | limit: 10, | |
| 1997 | }); | |
| 1998 | ||
| 1999 | // Individual requests for this viewer and team requests survive. The fallback cannot | |
| 2000 | // resolve team membership, so dropping team-routed reviews would hide legitimate work. | |
| 2001 | expect(batch.items.map((item) => item.number)).toEqual([1, 2]); | |
| 2002 | expect(searchOfCall(1)).toBeUndefined(); | |
| 2003 | assert.isFalse(batch.continues); | |
| 2004 | }), | |
| 2005 | ); | |
| 2006 | ||
| 2007 | it.effect("grows the search-free fallback until it fills the filtered page", () => | |
| 2008 | Effect.gen(function* () { | |
| 2009 | const unrelated = () => ({ reviewRequests: [{ login: "somebody-else" }] }); | |
| 2010 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 2011 | mockedExecute.mockReturnValueOnce( | |
| 2012 | Effect.succeed(output(listedPullRequests(3, 1, unrelated))), | |
| 2013 | ); | |
| 2014 | mockedExecute.mockReturnValueOnce( | |
| 2015 | Effect.succeed( | |
| 2016 | output( | |
| 2017 | listedPullRequests(4, 1, (number) => | |
| 2018 | number === 4 ? { reviewRequests: [{ login: "bilal" }] } : unrelated(), | |
| 2019 | ), | |
| 2020 | ), | |
| 2021 | ), | |
| 2022 | ); | |
| 2023 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2024 | ||
| 2025 | const batch = yield* cli.listPullRequests({ | |
| 2026 | cwd: "/w", | |
| 2027 | repository: "acme/web", | |
| 2028 | host: "github.com", | |
| 2029 | state: "open", | |
| 2030 | involvement: "reviewing", | |
| 2031 | viewer: "bilal", | |
| 2032 | limit: 2, | |
| 2033 | }); | |
| 2034 | ||
| 2035 | expect(batch.items.map((item) => item.number)).toEqual([4]); | |
| 2036 | expect(queryAt(1)).toContain("first: 3,"); | |
| 2037 | expect(queryAt(2)).toContain("first: 6,"); | |
| 2038 | assert.isFalse(batch.truncated); | |
| 2039 | }), | |
| 2040 | ); | |
| 2041 | ||
| 2042 | it.effect("bounds a sparse search-free fallback and reports the unread tail", () => | |
| 2043 | Effect.gen(function* () { | |
| 2044 | // A repository with far more pull requests than the bound, none of them for this reader. | |
| 2045 | let listed = 0; | |
| 2046 | mockedExecute.mockImplementation((call) => { | |
| 2047 | if (call.kind !== "graphql" || !call.query.includes("pullRequests(")) { | |
| 2048 | return Effect.succeed(emptySearch()); | |
| 2049 | } | |
| 2050 | const first = Number(/first: (\d+)/.exec(call.query)?.[1]); | |
| 2051 | const nodes = rows(first, listed + 1, () => ({ | |
| 2052 | reviewRequests: [{ login: "somebody-else" }], | |
| 2053 | })); | |
| 2054 | listed += first; | |
| 2055 | return Effect.succeed( | |
| 2056 | output( | |
| 2057 | encodeJson({ | |
| 2058 | data: { | |
| 2059 | repository: { | |
| 2060 | pullRequests: { pageInfo: { hasNextPage: true, endCursor: `c${listed}` }, nodes }, | |
| 2061 | }, | |
| 2062 | }, | |
| 2063 | }), | |
| 2064 | ), | |
| 2065 | ); | |
| 2066 | }); | |
| 2067 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2068 | ||
| 2069 | const batch = yield* cli.listPullRequests({ | |
| 2070 | cwd: "/w", | |
| 2071 | repository: "acme/web", | |
| 2072 | host: "github.com", | |
| 2073 | state: "open", | |
| 2074 | involvement: "reviewing", | |
| 2075 | viewer: "bilal", | |
| 2076 | limit: 2, | |
| 2077 | }); | |
| 2078 | ||
| 2079 | // The last widening reads a thousand rows, a page of a hundred at a time, and stops there. | |
| 2080 | const last = mockedExecute.mock.calls.length - 1; | |
| 2081 | expect(varsAt(last)["after"]).toBe(`c${listed - 100}`); | |
| 2082 | expect(listed - (3 + 6 + 12 + 24 + 48 + 96 + 192 + 384 + 768)).toBe(1000); | |
| 2083 | assert.strictEqual(batch.items.length, 0); | |
| 2084 | assert.isTrue(batch.truncated); | |
| 2085 | }), | |
| 2086 | ); | |
| 2087 | ||
| 2088 | it.effect("takes an empty slice for a repository that has run out, not one to read again", () => | |
| 2089 | Effect.gen(function* () { | |
| 2090 | mockedExecute.mockImplementation((call) => | |
| 2091 | Effect.succeed( | |
| 2092 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 2093 | ? emptyList() | |
| 2094 | : emptySearch(), | |
| 2095 | ), | |
| 2096 | ); | |
| 2097 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2098 | ||
| 2099 | yield* cli.listPullRequests({ | |
| 2100 | cwd: "/w", | |
| 2101 | repository: "acme/web", | |
| 2102 | host: "github.com", | |
| 2103 | state: "open", | |
| 2104 | involvement: "all", | |
| 2105 | viewer: "bilal", | |
| 2106 | limit: 10, | |
| 2107 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, | |
| 2108 | }); | |
| 2109 | ||
| 2110 | // A repository that answered the search once answers it again, so an empty slice under a | |
| 2111 | // cursor is the end of it rather than a repository search cannot reach. | |
| 2112 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 2113 | }), | |
| 2114 | ); | |
| 2115 | ||
| 2116 | /** The state a merge or branch update reads first: behind its base and blocked on checks. */ | |
| 2117 | const actionState = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({ | |
| 2118 | data: { | |
| 2119 | repository: { | |
| 2120 | pullRequest: { | |
| 2121 | id: "PR_7", | |
| 2122 | headRefOid: "abc123", | |
| 2123 | isMergeQueueEnabled: false, | |
| 2124 | mergeStateStatus: "BLOCKED", | |
| 2125 | baseRef: { compare: { behindBy: 2 } }, | |
| 2126 | ...pullRequest, | |
| 2127 | }, | |
| 2128 | }, | |
| 2129 | }, | |
| 2130 | }); | |
| 2131 | const mergeMessage = (body: string, isMergeQueueEnabled = false) => ({ | |
| 2132 | data: { | |
| 2133 | repository: { | |
| 2134 | pullRequest: { isMergeQueueEnabled, headRefOid: "abc123", viewerMergeBodyText: body }, | |
| 2135 | }, | |
| 2136 | }, | |
| 2137 | }); | |
| 2138 | ||
| 2139 | it.effect("updates a stale branch with a merge commit unless asked to rebase", () => | |
| 2140 | Effect.gen(function* () { | |
| 2141 | route(["query PullRequestActionState", actionState()]); | |
| 2142 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2143 | ||
| 2144 | yield* cli.runPullRequestAction({ | |
| 2145 | cwd: "/w", | |
| 2146 | repository: "acme/web", | |
| 2147 | host: "github.com", | |
| 2148 | number: 7, | |
| 2149 | action: "update-branch", | |
| 2150 | }); | |
| 2151 | yield* cli.runPullRequestAction({ | |
| 2152 | cwd: "/w", | |
| 2153 | repository: "acme/web", | |
| 2154 | host: "github.com", | |
| 2155 | number: 7, | |
| 2156 | action: "update-branch", | |
| 2157 | updateMethod: "rebase", | |
| 2158 | }); | |
| 2159 | ||
| 2160 | // GitHub's own default: a merge commit unless asked to rebase, pinned to the head it read. | |
| 2161 | expect(variablesOf("updatePullRequestBranch(")).toEqual([ | |
| 2162 | { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "MERGE" }, | |
| 2163 | { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "REBASE" }, | |
| 2164 | ]); | |
| 2165 | }), | |
| 2166 | ); | |
| 2167 | ||
| 2168 | it.effect("merges with the strategy it was asked for", () => | |
| 2169 | Effect.gen(function* () { | |
| 2170 | route(["query PullRequestActionState", actionState()]); | |
| 2171 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2172 | ||
| 2173 | yield* cli.runPullRequestAction({ | |
| 2174 | cwd: "/w", | |
| 2175 | repository: "acme/web", | |
| 2176 | host: "github.com", | |
| 2177 | number: 7, | |
| 2178 | action: "merge", | |
| 2179 | mergeMethod: "squash", | |
| 2180 | }); | |
| 2181 | ||
| 2182 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2183 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, | |
| 2184 | ]); | |
| 2185 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); | |
| 2186 | }), | |
| 2187 | ); | |
| 2188 | ||
| 2189 | it.effect.each(["merge", "enable-auto-merge"] as const)( | |
| 2190 | "removes agent credits from the proposed message for %s", | |
| 2191 | (action) => | |
| 2192 | Effect.gen(function* () { | |
| 2193 | route( | |
| 2194 | [ | |
| 2195 | "query PullRequestMergeMessage", | |
| 2196 | mergeMessage( | |
| 2197 | "Details\n\nCo-authored-by: Alice <alice@example.com>\nCo-authored-by: Claude <noreply@anthropic.com>", | |
| 2198 | ), | |
| 2199 | ], | |
| 2200 | ["query PullRequestActionState", actionState()], | |
| 2201 | ); | |
| 2202 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2203 | yield* cli.runPullRequestAction({ | |
| 2204 | cwd: "/w", | |
| 2205 | repository: "acme/web", | |
| 2206 | host: "github.com", | |
| 2207 | number: 7, | |
| 2208 | action, | |
| 2209 | mergeMethod: "squash", | |
| 2210 | removeAgentCreditsOnMerge: true, | |
| 2211 | }); | |
| 2212 | expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("SQUASH"); | |
| 2213 | expect( | |
| 2214 | variablesOf(action === "merge" ? "mergePullRequest(" : "enablePullRequestAutoMerge("), | |
| 2215 | ).toEqual([ | |
| 2216 | { | |
| 2217 | input: { | |
| 2218 | pullRequestId: "PR_7", | |
| 2219 | mergeMethod: "SQUASH", | |
| 2220 | // Pinned to the head the message was read from, so it cannot describe other commits. | |
| 2221 | expectedHeadOid: "abc123", | |
| 2222 | commitBody: "Details\n\nCo-authored-by: Alice <alice@example.com>", | |
| 2223 | }, | |
| 2224 | }, | |
| 2225 | ]); | |
| 2226 | }), | |
| 2227 | ); | |
| 2228 | ||
| 2229 | it.effect.each([ | |
| 2230 | { | |
| 2231 | description: "an unchanged message", | |
| 2232 | body: "Details\n\nCo-authored-by: Alice <alice@example.com>", | |
| 2233 | queued: false, | |
| 2234 | }, | |
| 2235 | { | |
| 2236 | description: "a merge queue", | |
| 2237 | body: "Co-authored-by: Claude <noreply@anthropic.com>", | |
| 2238 | queued: true, | |
| 2239 | }, | |
| 2240 | ] as const)("keeps GitHub's default message for $description", ({ body, queued }) => | |
| 2241 | Effect.gen(function* () { | |
| 2242 | route( | |
| 2243 | ["query PullRequestMergeMessage", mergeMessage(body, queued)], | |
| 2244 | ["query PullRequestActionState", actionState({ isMergeQueueEnabled: queued })], | |
| 2245 | ); | |
| 2246 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2247 | yield* cli.runPullRequestAction({ | |
| 2248 | cwd: "/w", | |
| 2249 | repository: "acme/web", | |
| 2250 | host: "github.com", | |
| 2251 | number: 7, | |
| 2252 | action: "merge", | |
| 2253 | removeAgentCreditsOnMerge: true, | |
| 2254 | }); | |
| 2255 | expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("MERGE"); | |
| 2256 | // A merge queue takes the pull request through auto-merge, with its own message. | |
| 2257 | expect(variablesOf(queued ? "enablePullRequestAutoMerge(" : "mergePullRequest(")).toEqual([ | |
| 2258 | { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, | |
| 2259 | ]); | |
| 2260 | }), | |
| 2261 | ); | |
| 2262 | ||
| 2263 | it.effect("passes an explicitly empty body when the proposed message only credits an agent", () => | |
| 2264 | Effect.gen(function* () { | |
| 2265 | route( | |
| 2266 | [ | |
| 2267 | "query PullRequestMergeMessage", | |
| 2268 | mergeMessage("Co-authored-by: Claude <noreply@anthropic.com>"), | |
| 2269 | ], | |
| 2270 | ["query PullRequestActionState", actionState()], | |
| 2271 | ); | |
| 2272 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2273 | yield* cli.runPullRequestAction({ | |
| 2274 | cwd: "/w", | |
| 2275 | repository: "acme/web", | |
| 2276 | host: "github.com", | |
| 2277 | number: 7, | |
| 2278 | action: "merge", | |
| 2279 | removeAgentCreditsOnMerge: true, | |
| 2280 | }); | |
| 2281 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2282 | { | |
| 2283 | input: { | |
| 2284 | pullRequestId: "PR_7", | |
| 2285 | mergeMethod: "MERGE", | |
| 2286 | expectedHeadOid: "abc123", | |
| 2287 | commitBody: "", | |
| 2288 | }, | |
| 2289 | }, | |
| 2290 | ]); | |
| 2291 | }), | |
| 2292 | ); | |
| 2293 | ||
| 2294 | it.effect("does not fetch a message for rebase merges", () => | |
| 2295 | Effect.gen(function* () { | |
| 2296 | route(["query PullRequestActionState", actionState()]); | |
| 2297 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2298 | yield* cli.runPullRequestAction({ | |
| 2299 | cwd: "/w", | |
| 2300 | repository: "acme/web", | |
| 2301 | host: "github.com", | |
| 2302 | number: 7, | |
| 2303 | action: "merge", | |
| 2304 | mergeMethod: "rebase", | |
| 2305 | removeAgentCreditsOnMerge: true, | |
| 2306 | }); | |
| 2307 | expect(variablesOf("query PullRequestMergeMessage")).toEqual([]); | |
| 2308 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2309 | { input: { pullRequestId: "PR_7", mergeMethod: "REBASE" } }, | |
| 2310 | ]); | |
| 2311 | }), | |
| 2312 | ); | |
| 2313 | ||
| 2314 | it.effect("refuses to merge when the proposed message cannot be read", () => | |
| 2315 | Effect.gen(function* () { | |
| 2316 | route( | |
| 2317 | ["query PullRequestMergeMessage", { data: { repository: { pullRequest: null } } }], | |
| 2318 | ["query PullRequestActionState", actionState()], | |
| 2319 | ); | |
| 2320 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2321 | const result = yield* Effect.result( | |
| 2322 | cli.runPullRequestAction({ | |
| 2323 | cwd: "/w", | |
| 2324 | repository: "acme/web", | |
| 2325 | host: "github.com", | |
| 2326 | number: 7, | |
| 2327 | action: "merge", | |
| 2328 | removeAgentCreditsOnMerge: true, | |
| 2329 | }), | |
| 2330 | ); | |
| 2331 | expect(result._tag).toBe("Failure"); | |
| 2332 | expect(variablesOf("mergePullRequest(")).toEqual([]); | |
| 2333 | }), | |
| 2334 | ); | |
| 2335 | ||
| 2336 | it.effect("arms auto-merge with the same strategy a merge would have used", () => | |
| 2337 | Effect.gen(function* () { | |
| 2338 | route(["query PullRequestActionState", actionState()]); | |
| 2339 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2340 | ||
| 2341 | yield* cli.runPullRequestAction({ | |
| 2342 | cwd: "/w", | |
| 2343 | repository: "acme/web", | |
| 2344 | host: "github.com", | |
| 2345 | number: 7, | |
| 2346 | action: "enable-auto-merge", | |
| 2347 | mergeMethod: "squash", | |
| 2348 | }); | |
| 2349 | // No strategy asked for is GitHub's own default, exactly as it is for a merge now. | |
| 2350 | yield* cli.runPullRequestAction({ | |
| 2351 | cwd: "/w", | |
| 2352 | repository: "acme/web", | |
| 2353 | host: "github.com", | |
| 2354 | number: 7, | |
| 2355 | action: "enable-auto-merge", | |
| 2356 | }); | |
| 2357 | ||
| 2358 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([ | |
| 2359 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, | |
| 2360 | { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, | |
| 2361 | ]); | |
| 2362 | expect(variablesOf("mergePullRequest(")).toEqual([]); | |
| 2363 | }), | |
| 2364 | ); | |
| 2365 | ||
| 2366 | it.effect("merges at once when auto-merge is asked of a pull request that is ready now", () => | |
| 2367 | Effect.gen(function* () { | |
| 2368 | route(["query PullRequestActionState", actionState({ mergeStateStatus: "CLEAN" })]); | |
| 2369 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2370 | ||
| 2371 | yield* cli.runPullRequestAction({ | |
| 2372 | cwd: "/w", | |
| 2373 | repository: "acme/web", | |
| 2374 | host: "github.com", | |
| 2375 | number: 7, | |
| 2376 | action: "enable-auto-merge", | |
| 2377 | mergeMethod: "squash", | |
| 2378 | }); | |
| 2379 | ||
| 2380 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2381 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, | |
| 2382 | ]); | |
| 2383 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); | |
| 2384 | }), | |
| 2385 | ); | |
| 2386 | ||
| 2387 | it.effect("takes auto-merge back off without naming a strategy", () => | |
| 2388 | Effect.gen(function* () { | |
| 2389 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2390 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2391 | ||
| 2392 | yield* cli.runPullRequestAction({ | |
| 2393 | cwd: "/w", | |
| 2394 | repository: "acme/web", | |
| 2395 | host: "github.com", | |
| 2396 | number: 7, | |
| 2397 | action: "disable-auto-merge", | |
| 2398 | mergeMethod: "squash", | |
| 2399 | }); | |
| 2400 | ||
| 2401 | expect(variablesOf("disablePullRequestAutoMerge(")).toEqual([{ pullRequestId: "PR_7" }]); | |
| 2402 | }), | |
| 2403 | ); | |
| 2404 | ||
| 2405 | it.effect("opens a pull request that reverts a merged pull request", () => | |
| 2406 | Effect.gen(function* () { | |
| 2407 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2408 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2409 | ||
| 2410 | yield* cli.runPullRequestAction({ | |
| 2411 | cwd: "/w", | |
| 2412 | repository: "acme/web", | |
| 2413 | host: "github.com", | |
| 2414 | number: 7, | |
| 2415 | action: "revert", | |
| 2416 | }); | |
| 2417 | ||
| 2418 | expect(variablesOf("revertPullRequest(")).toEqual([{ pullRequestId: "PR_7" }]); | |
| 2419 | }), | |
| 2420 | ); | |
| 2421 | ||
| 2422 | it.effect("does not approve action-required runs for a same-repository pull request", () => | |
| 2423 | Effect.gen(function* () { | |
| 2424 | mockedExecute.mockReturnValueOnce( | |
| 2425 | Effect.succeed( | |
| 2426 | output( | |
| 2427 | encodeJson( | |
| 2428 | coreResponse({ | |
| 2429 | number: 7, | |
| 2430 | title: "Pull request 7", | |
| 2431 | url: "https://github.com/acme/web/pull/7", | |
| 2432 | headRefName: "feat/page", | |
| 2433 | headRefOid: "abc123", | |
| 2434 | isCrossRepository: false, | |
| 2435 | headRepositoryOwner: { login: "acme" }, | |
| 2436 | baseRefName: "main", | |
| 2437 | createdAt: "2026-07-01T00:00:00Z", | |
| 2438 | updatedAt: "2026-07-02T00:00:00Z", | |
| 2439 | }), | |
| 2440 | ), | |
| 2441 | ), | |
| 2442 | ), | |
| 2443 | ); | |
| 2444 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2445 | ||
| 2446 | yield* cli.runPullRequestAction({ | |
| 2447 | cwd: "/w", | |
| 2448 | repository: "acme/web", | |
| 2449 | host: "github.com", | |
| 2450 | number: 7, | |
| 2451 | action: "approve-workflows", | |
| 2452 | }); | |
| 2453 | ||
| 2454 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 2455 | }), | |
| 2456 | ); | |
| 2457 | ||
| 2458 | it.effect("finds and approves every workflow waiting on a maintainer", () => | |
| 2459 | Effect.gen(function* () { | |
| 2460 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([10, 11])); | |
| 2461 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2462 | ||
| 2463 | yield* cli.runPullRequestAction({ | |
| 2464 | cwd: "/w", | |
| 2465 | repository: "acme/web", | |
| 2466 | host: "github.com", | |
| 2467 | number: 7, | |
| 2468 | action: "approve-workflows", | |
| 2469 | }); | |
| 2470 | ||
| 2471 | // Open pull requests on this head branch, and runs scoped to this exact head. | |
| 2472 | expect(variablesOf("headRefName: $head")[0]).toEqual({ | |
| 2473 | owner: "acme", | |
| 2474 | name: "web", | |
| 2475 | head: "feat/page", | |
| 2476 | after: null, | |
| 2477 | }); | |
| 2478 | expect(restCallsTo("actions/runs?")[0]?.path).toBe( | |
| 2479 | "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fpage&event=pull_request&status=action_required&per_page=100&page=1", | |
| 2480 | ); | |
| 2481 | // Each run is approved only after the head is read again and still lists it. | |
| 2482 | expect(restCallsTo("/approve").map((call) => [call.method, call.path])).toEqual([ | |
| 2483 | ["POST", "repos/acme/web/actions/runs/10/approve"], | |
| 2484 | ["POST", "repos/acme/web/actions/runs/11/approve"], | |
| 2485 | ]); | |
| 2486 | assert.strictEqual(restCallsTo("actions/runs?").length, 3); | |
| 2487 | }), | |
| 2488 | ); | |
| 2489 | ||
| 2490 | it.effect("counts revalidated runs that wait on a maintainer as GitHub reports them", () => | |
| 2491 | Effect.gen(function* () { | |
| 2492 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([])); | |
| 2493 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2494 | ||
| 2495 | const runs = yield* cli | |
| 2496 | .listWorkflowRunsRequiringApproval({ | |
| 2497 | cwd: "/w", | |
| 2498 | repository: "acme/web", | |
| 2499 | host: "github.com", | |
| 2500 | number: 7, | |
| 2501 | headSha: "abc123", | |
| 2502 | headBranch: "feat/page", | |
| 2503 | headRepositoryOwner: "octocat", | |
| 2504 | isCrossRepository: true, | |
| 2505 | }) | |
| 2506 | .pipe( | |
| 2507 | Effect.provideService(KnownWorkflowRuns, { | |
| 2508 | headSha: "abc123", | |
| 2509 | runs: [ | |
| 2510 | // Live shape: waiting on approval is completed + action_required. | |
| 2511 | { | |
| 2512 | id: 10, | |
| 2513 | status: "completed", | |
| 2514 | conclusion: "action_required", | |
| 2515 | head_branch: "feat/page", | |
| 2516 | }, | |
| 2517 | { id: 11, status: "completed", conclusion: "success", head_branch: "feat/page" }, | |
| 2518 | ], | |
| 2519 | }), | |
| 2520 | ); | |
| 2521 | ||
| 2522 | expect(runs.map((run) => run.id)).toEqual([10]); | |
| 2523 | assert.strictEqual(restCallsTo("actions/runs?").length, 0); | |
| 2524 | }), | |
| 2525 | ); | |
| 2526 | ||
| 2527 | it.effect("refuses a stale workflow approval after the pull request head changes", () => | |
| 2528 | Effect.gen(function* () { | |
| 2529 | let detailReads = 0; | |
| 2530 | workflowApprovalRoutes( | |
| 2531 | () => crossRepositoryDetail(++detailReads === 1 ? "abc123" : "def456"), | |
| 2532 | heads([7]), | |
| 2533 | workflowRuns([10]), | |
| 2534 | ); | |
| 2535 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2536 | ||
| 2537 | const error = yield* Effect.flip( | |
| 2538 | cli.runPullRequestAction({ | |
| 2539 | cwd: "/w", | |
| 2540 | repository: "acme/web", | |
| 2541 | host: "github.com", | |
| 2542 | number: 7, | |
| 2543 | action: "approve-workflows", | |
| 2544 | }), | |
| 2545 | ); | |
| 2546 | ||
| 2547 | expect(error).toMatchObject({ | |
| 2548 | _tag: "GitHubWorkflowApprovalHeadChangedError", | |
| 2549 | number: 7, | |
| 2550 | }); | |
| 2551 | assert.strictEqual(detailReads, 2); | |
| 2552 | expect(restCallsTo("/approve")).toEqual([]); | |
| 2553 | }), | |
| 2554 | ); | |
| 2555 | ||
| 2556 | it.effect("reads workflow runs and their pull request scope concurrently", () => | |
| 2557 | Effect.gen(function* () { | |
| 2558 | const headsStarted = yield* Deferred.make<void>(); | |
| 2559 | const runsStarted = yield* Deferred.make<void>(); | |
| 2560 | mockedExecute.mockImplementation((call) => | |
| 2561 | call.kind === "graphql" | |
| 2562 | ? Deferred.succeed(headsStarted, undefined).pipe( | |
| 2563 | Effect.andThen(Deferred.await(runsStarted)), | |
| 2564 | Effect.as(output(encodeJson(heads([7])))), | |
| 2565 | ) | |
| 2566 | : Deferred.succeed(runsStarted, undefined).pipe( | |
| 2567 | Effect.andThen(Deferred.await(headsStarted)), | |
| 2568 | Effect.as(output(encodeJson(workflowRuns([10])))), | |
| 2569 | ), | |
| 2570 | ); | |
| 2571 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2572 | ||
| 2573 | const runs = yield* cli.listWorkflowRunsRequiringApproval({ | |
| 2574 | cwd: "/w", | |
| 2575 | repository: "acme/web", | |
| 2576 | host: "github.com", | |
| 2577 | number: 7, | |
| 2578 | headSha: "abc123", | |
| 2579 | headBranch: "feat/page", | |
| 2580 | headRepositoryOwner: "octocat", | |
| 2581 | isCrossRepository: true, | |
| 2582 | }); | |
| 2583 | ||
| 2584 | expect(runs).toEqual([{ id: 10, name: "build", url: "https://example.com/10" }]); | |
| 2585 | }), | |
| 2586 | ); | |
| 2587 | ||
| 2588 | it.effect("refuses workflow approval when one head belongs to several pull requests", () => | |
| 2589 | Effect.gen(function* () { | |
| 2590 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7, 8]), workflowRuns([])); | |
| 2591 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2592 | ||
| 2593 | const error = yield* Effect.flip( | |
| 2594 | cli.listWorkflowRunsRequiringApproval({ | |
| 2595 | cwd: "/w", | |
| 2596 | repository: "acme/web", | |
| 2597 | host: "github.com", | |
| 2598 | number: 7, | |
| 2599 | headSha: "abc123", | |
| 2600 | headBranch: "feat/page", | |
| 2601 | headRepositoryOwner: "octocat", | |
| 2602 | isCrossRepository: true, | |
| 2603 | }), | |
| 2604 | ); | |
| 2605 | ||
| 2606 | expect(error).toMatchObject({ | |
| 2607 | _tag: "GitHubWorkflowApprovalRefusedError", | |
| 2608 | reason: "head-not-unique", | |
| 2609 | number: 7, | |
| 2610 | observedCount: 2, | |
| 2611 | limit: 1_000, | |
| 2612 | }); | |
| 2613 | expect(error.message).toContain("instead of uniquely matching #7"); | |
| 2614 | }), | |
| 2615 | ); | |
| 2616 | ||
| 2617 | it.effect("refuses workflow approval when GitHub omits the head repository", () => | |
| 2618 | Effect.gen(function* () { | |
| 2619 | mockedExecute.mockReturnValueOnce( | |
| 2620 | Effect.succeed( | |
| 2621 | output( | |
| 2622 | encodeJson( | |
| 2623 | coreResponse({ | |
| 2624 | number: 7, | |
| 2625 | title: "Pull request 7", | |
| 2626 | url: "https://github.com/acme/web/pull/7", | |
| 2627 | headRefName: "feat/page", | |
| 2628 | headRefOid: "abc123", | |
| 2629 | isCrossRepository: true, | |
| 2630 | headRepositoryOwner: null, | |
| 2631 | baseRefName: "main", | |
| 2632 | createdAt: "2026-07-01T00:00:00Z", | |
| 2633 | updatedAt: "2026-07-02T00:00:00Z", | |
| 2634 | }), | |
| 2635 | ), | |
| 2636 | ), | |
| 2637 | ), | |
| 2638 | ); | |
| 2639 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2640 | ||
| 2641 | const error = yield* Effect.flip( | |
| 2642 | cli.runPullRequestAction({ | |
| 2643 | cwd: "/w", | |
| 2644 | repository: "acme/web", | |
| 2645 | host: "github.com", | |
| 2646 | number: 7, | |
| 2647 | action: "approve-workflows", | |
| 2648 | }), | |
| 2649 | ); | |
| 2650 | ||
| 2651 | expect(error).toMatchObject({ | |
| 2652 | _tag: "GitHubWorkflowApprovalHeadUnavailableError", | |
| 2653 | number: 7, | |
| 2654 | }); | |
| 2655 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 2656 | }), | |
| 2657 | ); | |
| 2658 | ||
| 2659 | it.effect("surfaces a workflow run list beyond the safe approval bound", () => | |
| 2660 | Effect.gen(function* () { | |
| 2661 | let runPages = 0; | |
| 2662 | mockedExecute.mockImplementation((call) => { | |
| 2663 | if (call.kind === "graphql") return Effect.succeed(output(encodeJson(heads([7])))); | |
| 2664 | const page = runPages++; | |
| 2665 | return Effect.succeed( | |
| 2666 | output( | |
| 2667 | encodeJson( | |
| 2668 | workflowRuns(Array.from({ length: 100 }, (_, index) => page * 100 + index + 1)), | |
| 2669 | ), | |
| 2670 | ), | |
| 2671 | ); | |
| 2672 | }); | |
| 2673 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2674 | ||
| 2675 | const error = yield* Effect.flip( | |
| 2676 | cli.listWorkflowRunsRequiringApproval({ | |
| 2677 | cwd: "/w", | |
| 2678 | repository: "acme/web", | |
| 2679 | host: "github.com", | |
| 2680 | number: 7, | |
| 2681 | headSha: "abc123", | |
| 2682 | headBranch: "feat/page", | |
| 2683 | headRepositoryOwner: "octocat", | |
| 2684 | isCrossRepository: true, | |
| 2685 | }), | |
| 2686 | ); | |
| 2687 | ||
| 2688 | // Paged a hundred at a time, it stops at the first page past the bound. | |
| 2689 | expect(error).toMatchObject({ | |
| 2690 | _tag: "GitHubWorkflowApprovalRefusedError", | |
| 2691 | reason: "run-list-truncated", | |
| 2692 | number: 7, | |
| 2693 | observedCount: 1_100, | |
| 2694 | limit: 1_000, | |
| 2695 | }); | |
| 2696 | expect(error.message).toContain("more than 1000 workflow runs"); | |
| 2697 | assert.strictEqual(runPages, 11); | |
| 2698 | }), | |
| 2699 | ); | |
| 2700 | ||
| 2701 | it.effect("returns a pull request to draft by converting it", () => | |
| 2702 | Effect.gen(function* () { | |
| 2703 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2704 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2705 | ||
| 2706 | yield* cli.runPullRequestAction({ | |
| 2707 | cwd: "/w", | |
| 2708 | repository: "acme/web", | |
| 2709 | host: "github.com", | |
| 2710 | number: 7, | |
| 2711 | action: "draft", | |
| 2712 | }); | |
| 2713 | ||
| 2714 | expect(variablesOf("convertPullRequestToDraft(")).toEqual([{ pullRequestId: "PR_7" }]); | |
| 2715 | expect(variablesOf("markPullRequestReadyForReview(")).toEqual([]); | |
| 2716 | }), | |
| 2717 | ); | |
| 2718 | ||
| 2719 | it.effect("sends a comment body as a variable, never inside the document", () => | |
| 2720 | Effect.gen(function* () { | |
| 2721 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2722 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2723 | ||
| 2724 | yield* cli.commentOnPullRequest({ | |
| 2725 | cwd: "/w", | |
| 2726 | repository: "acme/web", | |
| 2727 | host: "github.com", | |
| 2728 | number: 7, | |
| 2729 | body: "Looks good.", | |
| 2730 | }); | |
| 2731 | ||
| 2732 | expect(variablesOf("addComment(")).toEqual([{ subjectId: "PR_7", body: "Looks good." }]); | |
| 2733 | expect(queryAt(mockedExecute.mock.calls.length - 1)).not.toContain("Looks good."); | |
| 2734 | }), | |
| 2735 | ); | |
| 2736 | ||
| 2737 | it.effect("names the host on every repository it addresses", () => | |
| 2738 | Effect.gen(function* () { | |
| 2739 | mockedExecute.mockImplementation((call) => | |
| 2740 | Effect.succeed( | |
| 2741 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 2742 | ? emptyList() | |
| 2743 | : emptySearch(), | |
| 2744 | ), | |
| 2745 | ); | |
| 2746 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2747 | ||
| 2748 | yield* cli.listPullRequests({ | |
| 2749 | cwd: "/w", | |
| 2750 | repository: "acme/web", | |
| 2751 | host: "github.acme.dev", | |
| 2752 | state: "open", | |
| 2753 | involvement: "all", | |
| 2754 | viewer: "bilal", | |
| 2755 | limit: 10, | |
| 2756 | }); | |
| 2757 | ||
| 2758 | // A request sent to github.com would read a different repository of the same name. | |
| 2759 | assert.isAbove(mockedExecute.mock.calls.length, 0); | |
| 2760 | expect(new Set(mockedExecute.mock.calls.map(([call]) => call.host))).toEqual( | |
| 2761 | new Set(["github.acme.dev"]), | |
| 2762 | ); | |
| 2763 | expect(searchOfCall(0)).toContain("repo:acme/web"); | |
| 2764 | }), | |
| 2765 | ); | |
| 2766 | ||
| 2767 | it.effect("asks a GitHub Enterprise host for its own review threads", () => | |
| 2768 | Effect.gen(function* () { | |
| 2769 | mockedExecute.mockReturnValueOnce( | |
| 2770 | Effect.succeed( | |
| 2771 | output( | |
| 2772 | encodeJson({ | |
| 2773 | data: { | |
| 2774 | repository: { pullRequest: { reviewThreads: { totalCount: 0, nodes: [] } } }, | |
| 2775 | }, | |
| 2776 | }), | |
| 2777 | ), | |
| 2778 | ), | |
| 2779 | ); | |
| 2780 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2781 | ||
| 2782 | yield* cli.listReviewThreadComments({ | |
| 2783 | cwd: "/w", | |
| 2784 | repository: "acme/web", | |
| 2785 | host: "github.acme.dev", | |
| 2786 | number: 7, | |
| 2787 | }); | |
| 2788 | ||
| 2789 | expect(callAt(0).host).toBe("github.acme.dev"); | |
| 2790 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); | |
| 2791 | }), | |
| 2792 | ); | |
| 2793 | ||
| 2794 | it.effect("serves a diff GitHub hands over whole in one request, with no next slice", () => | |
| 2795 | Effect.gen(function* () { | |
| 2796 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("diff --git a/a b/a"))); | |
| 2797 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2798 | ||
| 2799 | const diff = yield* cli.getPullRequestDiff({ | |
| 2800 | cwd: "/w", | |
| 2801 | repository: "acme/web", | |
| 2802 | host: "github.com", | |
| 2803 | number: 7, | |
| 2804 | }); | |
| 2805 | ||
| 2806 | assert.strictEqual(diff.patch, "diff --git a/a b/a"); | |
| 2807 | assert.isNull(diff.nextCursor); | |
| 2808 | assert.isFalse(diff.truncated); | |
| 2809 | // The common case pays for one request and not the files API on top of it. | |
| 2810 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 2811 | // GitHub's combined pull-request diff: one section per changed file, not one per commit. | |
| 2812 | const call = callAt(0); | |
| 2813 | assert.strictEqual(call.kind, "rest"); | |
| 2814 | if (call.kind === "rest") { | |
| 2815 | assert.strictEqual(call.path, "repos/acme/web/pulls/7"); | |
| 2816 | assert.strictEqual(call.accept, "application/vnd.github.diff"); | |
| 2817 | } | |
| 2818 | }), | |
| 2819 | ); | |
| 2820 | ||
| 2821 | it.effect("reads one files page when GitHub refuses the diff, and says it is the last", () => | |
| 2822 | Effect.gen(function* () { | |
| 2823 | // GitHub answers 406 rather than a diff past 300 changed files. | |
| 2824 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 2825 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1)))); | |
| 2826 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2827 | ||
| 2828 | const diff = yield* cli.getPullRequestDiff({ | |
| 2829 | cwd: "/w", | |
| 2830 | repository: "acme/web", | |
| 2831 | host: "github.acme.dev", | |
| 2832 | number: 7, | |
| 2833 | }); | |
| 2834 | ||
| 2835 | assert.isFalse(diff.truncated); | |
| 2836 | // A short page is the end of the change set, so there is nothing to carry on from. | |
| 2837 | assert.isNull(diff.nextCursor); | |
| 2838 | expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); | |
| 2839 | expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts"); | |
| 2840 | assert.strictEqual(callAt(1).host, "github.acme.dev"); | |
| 2841 | assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); | |
| 2842 | }), | |
| 2843 | ); | |
| 2844 | ||
| 2845 | it.effect("hands back a cursor for the next page rather than walking on by itself", () => | |
| 2846 | Effect.gen(function* () { | |
| 2847 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 2848 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); | |
| 2849 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2850 | ||
| 2851 | const diff = yield* cli.getPullRequestDiff({ | |
| 2852 | cwd: "/w", | |
| 2853 | repository: "acme/web", | |
| 2854 | host: "github.com", | |
| 2855 | number: 7, | |
| 2856 | }); | |
| 2857 | ||
| 2858 | // A full page means more files, which the reader asks for; it is not a truncated slice. | |
| 2859 | assert.isFalse(diff.truncated); | |
| 2860 | assert.isNotNull(diff.nextCursor); | |
| 2861 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 2862 | }), | |
| 2863 | ); | |
| 2864 | ||
| 2865 | it.effect("carries on from a cursor without asking for the whole diff again", () => | |
| 2866 | Effect.gen(function* () { | |
| 2867 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 2868 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); | |
| 2869 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2870 | const target = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }; | |
| 2871 | ||
| 2872 | const first = yield* cli.getPullRequestDiff(target); | |
| 2873 | assert.isNotNull(first.nextCursor); | |
| 2874 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(4, 100)))); | |
| 2875 | const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); | |
| 2876 | ||
| 2877 | assert.isNull(second.nextCursor); | |
| 2878 | expect(second.patch).toContain("diff --git a/src/file100.ts b/src/file100.ts"); | |
| 2879 | // The second slice is one request: the cursor already says where to read. | |
| 2880 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | |
| 2881 | assert.strictEqual(pathAt(2), "repos/acme/web/pulls/7/files?per_page=100&page=2"); | |
| 2882 | }), | |
| 2883 | ); | |
| 2884 | ||
| 2885 | it.effect("refuses a cursor it never handed out rather than reading it into a request", () => | |
| 2886 | Effect.gen(function* () { | |
| 2887 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2888 | ||
| 2889 | const error = yield* Effect.flip( | |
| 2890 | cli.getPullRequestDiff({ | |
| 2891 | cwd: "/w", | |
| 2892 | repository: "acme/web", | |
| 2893 | host: "github.com", | |
| 2894 | number: 7, | |
| 2895 | cursor: "1&per_page=1", | |
| 2896 | }), | |
| 2897 | ); | |
| 2898 | ||
| 2899 | assert.strictEqual(error._tag, "GitHubDiffCursorError"); | |
| 2900 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 2901 | }), | |
| 2902 | ); | |
| 2903 | ||
| 2904 | it.effect("reads a named commit from the commit endpoint rather than the whole diff", () => | |
| 2905 | Effect.gen(function* () { | |
| 2906 | mockedExecute.mockReturnValueOnce( | |
| 2907 | Effect.succeed(output(`{"files":${pullRequestFiles(2, 1)}}`)), | |
| 2908 | ); | |
| 2909 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2910 | ||
| 2911 | const diff = yield* cli.getPullRequestDiff({ | |
| 2912 | cwd: "/w", | |
| 2913 | repository: "acme/web", | |
| 2914 | host: "github.com", | |
| 2915 | number: 7, | |
| 2916 | commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", | |
| 2917 | }); | |
| 2918 | ||
| 2919 | // One request: the commit's own changes never take the whole-diff road. | |
| 2920 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 2921 | assert.isNull(diff.nextCursor); | |
| 2922 | // The commit endpoint wraps its files in an object, which the decoder unwraps. | |
| 2923 | expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); | |
| 2924 | assert.strictEqual( | |
| 2925 | pathAt(0), | |
| 2926 | "repos/acme/web/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0?per_page=100&page=1", | |
| 2927 | ); | |
| 2928 | }), | |
| 2929 | ); | |
| 2930 | ||
| 2931 | it.effect("pages inside a commit the way it pages the pull request's own files", () => | |
| 2932 | Effect.gen(function* () { | |
| 2933 | mockedExecute.mockReturnValueOnce( | |
| 2934 | Effect.succeed(output(`{"files":${pullRequestFiles(100, 0)}}`)), | |
| 2935 | ); | |
| 2936 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2937 | const target = { | |
| 2938 | cwd: "/w", | |
| 2939 | repository: "acme/web", | |
| 2940 | host: "github.com", | |
| 2941 | number: 7, | |
| 2942 | commit: "a1b2c3d", | |
| 2943 | }; | |
| 2944 | ||
| 2945 | const first = yield* cli.getPullRequestDiff(target); | |
| 2946 | assert.isNotNull(first.nextCursor); | |
| 2947 | mockedExecute.mockReturnValueOnce( | |
| 2948 | Effect.succeed(output(`{"files":${pullRequestFiles(4, 100)}}`)), | |
| 2949 | ); | |
| 2950 | const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); | |
| 2951 | ||
| 2952 | assert.isNull(second.nextCursor); | |
| 2953 | expect(second.patch).toContain("src/file100.ts"); | |
| 2954 | assert.strictEqual(pathAt(1), "repos/acme/web/commits/a1b2c3d?per_page=100&page=2"); | |
| 2955 | }), | |
| 2956 | ); | |
| 2957 | ||
| 2958 | it.effect("refuses a commit that is not a sha rather than reading it into a request", () => | |
| 2959 | Effect.gen(function* () { | |
| 2960 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2961 | ||
| 2962 | const error = yield* Effect.flip( | |
| 2963 | cli.getPullRequestDiff({ | |
| 2964 | cwd: "/w", | |
| 2965 | repository: "acme/web", | |
| 2966 | host: "github.com", | |
| 2967 | number: 7, | |
| 2968 | commit: "../../pulls/8/files", | |
| 2969 | }), | |
| 2970 | ); | |
| 2971 | ||
| 2972 | assert.strictEqual(error._tag, "GitHubDiffCommitError"); | |
| 2973 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 2974 | }), | |
| 2975 | ); | |
| 2976 | ||
| 2977 | it.effect("expands a new file from a root commit without requiring a parent", () => | |
| 2978 | Effect.gen(function* () { | |
| 2979 | mockedExecute.mockReturnValueOnce( | |
| 2980 | Effect.succeed(output(encodeJson({ sha: "a1b2c3d", parents: [] }))), | |
| 2981 | ); | |
| 2982 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n"))); | |
| 2983 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 2984 | ||
| 2985 | const contents = yield* cli.getPullRequestDiffFileContents({ | |
| 2986 | cwd: "/w", | |
| 2987 | repository: "acme/web", | |
| 2988 | host: "github.com", | |
| 2989 | number: 7, | |
| 2990 | commit: "a1b2c3d", | |
| 2991 | changeType: "new", | |
| 2992 | oldPath: "src/root.ts", | |
| 2993 | newPath: "src/root.ts", | |
| 2994 | }); | |
| 2995 | ||
| 2996 | expect(contents).toEqual({ oldContents: "", newContents: "root contents\n" }); | |
| 2997 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 2998 | assert.strictEqual(pathAt(0), "repos/acme/web/commits/a1b2c3d"); | |
| 2999 | assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/root.ts?ref=a1b2c3d"); | |
| 3000 | }), | |
| 3001 | ); | |
| 3002 | ||
| 3003 | it.effect("reports unusable diff revisions as a structured error", () => | |
| 3004 | Effect.gen(function* () { | |
| 3005 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("not-a-sha\tstill-not-a-sha\n"))); | |
| 3006 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3007 | ||
| 3008 | const error = yield* Effect.flip( | |
| 3009 | cli.getPullRequestDiffFileContents({ | |
| 3010 | cwd: "/w", | |
| 3011 | repository: "acme/web", | |
| 3012 | host: "github.com", | |
| 3013 | number: 7, | |
| 3014 | commit: "a1b2c3d", | |
| 3015 | changeType: "change", | |
| 3016 | oldPath: "src/a.ts", | |
| 3017 | newPath: "src/a.ts", | |
| 3018 | }), | |
| 3019 | ); | |
| 3020 | ||
| 3021 | assert.strictEqual(error._tag, "GitHubDiffRevisionsUnavailableError"); | |
| 3022 | if (error._tag === "GitHubDiffRevisionsUnavailableError") { | |
| 3023 | assert.strictEqual(error.number, 7); | |
| 3024 | assert.strictEqual(error.commit, "a1b2c3d"); | |
| 3025 | } | |
| 3026 | }), | |
| 3027 | ); | |
| 3028 | ||
| 3029 | it.effect("reports an oversized diff file with its path and reason", () => | |
| 3030 | Effect.gen(function* () { | |
| 3031 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); | |
| 3032 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); | |
| 3033 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3034 | ||
| 3035 | const error = yield* Effect.flip( | |
| 3036 | cli.getPullRequestDiffFileContents({ | |
| 3037 | cwd: "/w", | |
| 3038 | repository: "acme/web", | |
| 3039 | host: "github.com", | |
| 3040 | number: 7, | |
| 3041 | changeType: "deleted", | |
| 3042 | oldPath: "src/large.ts", | |
| 3043 | newPath: "src/large.ts", | |
| 3044 | }), | |
| 3045 | ); | |
| 3046 | ||
| 3047 | assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError"); | |
| 3048 | if (error._tag === "GitHubDiffFileContentsUnavailableError") { | |
| 3049 | assert.strictEqual(error.path, "src/large.ts"); | |
| 3050 | assert.strictEqual(error.reason, "oversized"); | |
| 3051 | } | |
| 3052 | // A deleted file is read at the base revision only. | |
| 3053 | assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/large.ts?ref=a1b2c3d"); | |
| 3054 | }), | |
| 3055 | ); | |
| 3056 | ||
| 3057 | it.effect("reports undecodable diff file contents as binary", () => | |
| 3058 | Effect.gen(function* () { | |
| 3059 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); | |
| 3060 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("binary�contents", false, true))); | |
| 3061 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3062 | ||
| 3063 | const error = yield* Effect.flip( | |
| 3064 | cli.getPullRequestDiffFileContents({ | |
| 3065 | cwd: "/w", | |
| 3066 | repository: "acme/web", | |
| 3067 | host: "github.com", | |
| 3068 | number: 7, | |
| 3069 | changeType: "deleted", | |
| 3070 | oldPath: "assets/logo.png", | |
| 3071 | newPath: "assets/logo.png", | |
| 3072 | }), | |
| 3073 | ); | |
| 3074 | ||
| 3075 | assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError"); | |
| 3076 | if (error._tag === "GitHubDiffFileContentsUnavailableError") { | |
| 3077 | assert.strictEqual(error.path, "assets/logo.png"); | |
| 3078 | assert.strictEqual(error.reason, "binary"); | |
| 3079 | } | |
| 3080 | }), | |
| 3081 | ); | |
| 3082 | ||
| 3083 | it.effect("returns valid text containing a literal replacement character", () => | |
| 3084 | Effect.gen(function* () { | |
| 3085 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); | |
| 3086 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("before�after"))); | |
| 3087 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3088 | ||
| 3089 | const contents = yield* cli.getPullRequestDiffFileContents({ | |
| 3090 | cwd: "/w", | |
| 3091 | repository: "acme/web", | |
| 3092 | host: "github.com", | |
| 3093 | number: 7, | |
| 3094 | changeType: "deleted", | |
| 3095 | oldPath: "docs/encoding.md", | |
| 3096 | newPath: "docs/encoding.md", | |
| 3097 | }); | |
| 3098 | ||
| 3099 | assert.strictEqual(contents.oldContents, "before�after"); | |
| 3100 | }), | |
| 3101 | ); | |
| 3102 | ||
| 3103 | it.effect("ends the diff on a page with no files rather than asking for it again", () => | |
| 3104 | Effect.gen(function* () { | |
| 3105 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | |
| 3106 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3107 | ||
| 3108 | const diff = yield* cli.getPullRequestDiff({ | |
| 3109 | cwd: "/w", | |
| 3110 | repository: "acme/web", | |
| 3111 | host: "github.com", | |
| 3112 | number: 7, | |
| 3113 | cursor: "4", | |
| 3114 | }); | |
| 3115 | ||
| 3116 | assert.strictEqual(diff.patch, ""); | |
| 3117 | assert.isNull(diff.nextCursor); | |
| 3118 | }), | |
| 3119 | ); | |
| 3120 | ||
| 3121 | it.effect("reports the refused diff when the files API cannot answer either", () => | |
| 3122 | Effect.gen(function* () { | |
| 3123 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 3124 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("not json"))); | |
| 3125 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3126 | ||
| 3127 | const error = yield* Effect.flip( | |
| 3128 | cli.getPullRequestDiff({ | |
| 3129 | cwd: "/w", | |
| 3130 | repository: "acme/web", | |
| 3131 | host: "github.com", | |
| 3132 | number: 7, | |
| 3133 | }), | |
| 3134 | ); | |
| 3135 | ||
| 3136 | assert.strictEqual(error, diffRefused); | |
| 3137 | }), | |
| 3138 | ); | |
| 3139 | ||
| 3140 | it.effect("skips the avatar lookup when a listing named nobody", () => | |
| 3141 | Effect.gen(function* () { | |
| 3142 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3143 | ||
| 3144 | const avatars = yield* cli.listActorAvatars({ | |
| 3145 | cwd: "/w", | |
| 3146 | repository: "acme/web", | |
| 3147 | host: "github.com", | |
| 3148 | ids: [], | |
| 3149 | }); | |
| 3150 | ||
| 3151 | assert.strictEqual(avatars.size, 0); | |
| 3152 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 3153 | }), | |
| 3154 | ); | |
| 3155 | ||
| 3156 | it.effect("accounts for the avatar lookup in the GraphQL budget", () => | |
| 3157 | Effect.gen(function* () { | |
| 3158 | mockedExecute.mockReturnValueOnce( | |
| 3159 | Effect.succeed( | |
| 3160 | output( | |
| 3161 | encodeJson({ | |
| 3162 | data: { | |
| 3163 | nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }], | |
| 3164 | rateLimit: { | |
| 3165 | cost: 1, | |
| 3166 | limit: 5_000, | |
| 3167 | remaining: 4_999, | |
| 3168 | resetAt: "2099-08-13T14:00:00Z", | |
| 3169 | }, | |
| 3170 | }, | |
| 3171 | }), | |
| 3172 | ), | |
| 3173 | ), | |
| 3174 | ); | |
| 3175 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3176 | ||
| 3177 | const avatars = yield* cli.listActorAvatars({ | |
| 3178 | cwd: "/w", | |
| 3179 | repository: "acme/web", | |
| 3180 | host: "github.com", | |
| 3181 | ids: ["MDQ6VXNlcjE="], | |
| 3182 | }); | |
| 3183 | ||
| 3184 | expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] }); | |
| 3185 | expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); | |
| 3186 | expect(avatars.get("octocat")).toBe("https://avatars/octocat"); | |
| 3187 | }), | |
| 3188 | ); | |
| 3189 | ||
| 3190 | it.effect("fails when the authenticated account has no login", () => | |
| 3191 | Effect.gen(function* () { | |
| 3192 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(" "))); | |
| 3193 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3194 | ||
| 3195 | const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w", host: "github.com" })); | |
| 3196 | ||
| 3197 | assert.strictEqual(error._tag, "GitHubViewerLoginUnavailableError"); | |
| 3198 | }), | |
| 3199 | ); | |
| 3200 | ||
| 3201 | it.effect("looks up the authenticated account on the requested enterprise host", () => | |
| 3202 | Effect.gen(function* () { | |
| 3203 | mockedExecute.mockReturnValueOnce( | |
| 3204 | Effect.succeed(output('{"id":456,"login":"enterprise-user"}')), | |
| 3205 | ); | |
| 3206 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3207 | ||
| 3208 | const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" }); | |
| 3209 | ||
| 3210 | expect(login).toBe("enterprise-user"); | |
| 3211 | expect(mockedCredential).toHaveBeenCalledWith("github.acme.com"); | |
| 3212 | expect(callAt(0)).toMatchObject({ kind: "rest", host: "github.acme.com", path: "user" }); | |
| 3213 | }), | |
| 3214 | ); | |
| 3215 | ||
| 3216 | it.effect("reuses verified credentials offline and refuses an unverified replacement", () => | |
| 3217 | Effect.gen(function* () { | |
| 3218 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3219 | const input = { cwd: "/w", host: "github.identity-cache.test" }; | |
| 3220 | mockedCredential.mockImplementation((host) => | |
| 3221 | Effect.succeed({ token: Redacted.make("test-credential-a"), fingerprint: `${host}:a` }), | |
| 3222 | ); | |
| 3223 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}'))); | |
| 3224 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | |
| 3225 | accountId: "123", | |
| 3226 | viewer: "maria-rcks", | |
| 3227 | }); | |
| 3228 | ||
| 3229 | // The same credential again is answered from what was verified, without asking GitHub. | |
| 3230 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | |
| 3231 | accountId: "123", | |
| 3232 | viewer: "maria-rcks", | |
| 3233 | }); | |
| 3234 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 3235 | ||
| 3236 | // A switched credential is not trusted on the strength of the old one's answer. | |
| 3237 | mockedCredential.mockImplementation((host) => | |
| 3238 | Effect.succeed({ token: Redacted.make("test-credential-b"), fingerprint: `${host}:b` }), | |
| 3239 | ); | |
| 3240 | mockedExecute.mockReturnValueOnce( | |
| 3241 | Effect.fail( | |
| 3242 | new GitHubApi.GitHubApiResponseError({ | |
| 3243 | host: "github.com", | |
| 3244 | operation: "getRoutingIdentity", | |
| 3245 | status: 502, | |
| 3246 | }), | |
| 3247 | ), | |
| 3248 | ); | |
| 3249 | // GitHub's own refusal is reported as itself, so the page can say what went wrong. | |
| 3250 | const failure = yield* cli.getRoutingIdentity(input).pipe(Effect.flip); | |
| 3251 | expect(failure._tag).toBe("GitHubApiResponseError"); | |
| 3252 | expect(String(failure)).not.toContain("test-credential-b"); | |
| 3253 | expect(mockedExecute).toHaveBeenCalledTimes(2); | |
| 3254 | ||
| 3255 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}'))); | |
| 3256 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | |
| 3257 | accountId: "456", | |
| 3258 | viewer: "maria-rcks", | |
| 3259 | }); | |
| 3260 | }), | |
| 3261 | ); | |
| 3262 | ||
| 3263 | it.effect("sends a whole review as one request", () => | |
| 3264 | Effect.gen(function* () { | |
| 3265 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 3266 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3267 | ||
| 3268 | yield* cli.submitReview({ | |
| 3269 | cwd: "/w", | |
| 3270 | repository: "acme/web", | |
| 3271 | host: "github.com", | |
| 3272 | number: 7, | |
| 3273 | verdict: "approve", | |
| 3274 | body: "Looks right.", | |
| 3275 | comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], | |
| 3276 | }); | |
| 3277 | ||
| 3278 | // One request, so nothing is on the pull request until the verdict is. | |
| 3279 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 3280 | expect(restCallsTo("/reviews")).toMatchObject([ | |
| 3281 | { | |
| 3282 | method: "POST", | |
| 3283 | path: "repos/acme/web/pulls/7/reviews", | |
| 3284 | body: { | |
| 3285 | event: "APPROVE", | |
| 3286 | body: "Looks right.", | |
| 3287 | comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }], | |
| 3288 | }, | |
| 3289 | }, | |
| 3290 | ]); | |
| 3291 | }), | |
| 3292 | ); | |
| 3293 | ||
| 3294 | it.effect("sends a reply body as a variable, never inside the document", () => | |
| 3295 | Effect.gen(function* () { | |
| 3296 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 3297 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3298 | ||
| 3299 | yield* cli.replyToReviewThread({ | |
| 3300 | cwd: "/w", | |
| 3301 | repository: "acme/web", | |
| 3302 | host: "github.com", | |
| 3303 | threadId: "PRRT_1", | |
| 3304 | body: "Fixed in 42ff8ec.", | |
| 3305 | }); | |
| 3306 | ||
| 3307 | expect(variablesOf("addPullRequestReviewThreadReply(")).toEqual([ | |
| 3308 | { threadId: "PRRT_1", body: "Fixed in 42ff8ec." }, | |
| 3309 | ]); | |
| 3310 | expect(queryAt(0)).not.toContain("Fixed in 42ff8ec."); | |
| 3311 | }), | |
| 3312 | ); | |
| 3313 | ||
| 3314 | it.effect("resolves and unresolves through the mutation each one needs", () => | |
| 3315 | Effect.gen(function* () { | |
| 3316 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 3317 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3318 | ||
| 3319 | yield* cli.setReviewThreadResolution({ | |
| 3320 | cwd: "/w", | |
| 3321 | repository: "acme/web", | |
| 3322 | host: "github.acme.dev", | |
| 3323 | threadId: "PRRT_1", | |
| 3324 | resolved: true, | |
| 3325 | }); | |
| 3326 | yield* cli.setReviewThreadResolution({ | |
| 3327 | cwd: "/w", | |
| 3328 | repository: "acme/web", | |
| 3329 | host: "github.acme.dev", | |
| 3330 | threadId: "PRRT_1", | |
| 3331 | resolved: false, | |
| 3332 | }); | |
| 3333 | ||
| 3334 | expect(queryAt(0)).toContain("resolveReviewThread("); | |
| 3335 | expect(queryAt(0)).not.toContain("unresolveReviewThread("); | |
| 3336 | expect(queryAt(1)).toContain("unresolveReviewThread("); | |
| 3337 | expect([varsAt(0), varsAt(1)]).toEqual([{ threadId: "PRRT_1" }, { threadId: "PRRT_1" }]); | |
| 3338 | // A GitHub Enterprise thread is resolved on its own host, not on github.com. | |
| 3339 | expect([callAt(0).host, callAt(1).host]).toEqual(["github.acme.dev", "github.acme.dev"]); | |
| 3340 | }), | |
| 3341 | ); | |
| 3342 | ||
| 3343 | it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () => | |
| 3344 | Effect.gen(function* () { | |
| 3345 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); | |
| 3346 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3347 | ||
| 3348 | yield* cli.setReaction({ | |
| 3349 | cwd: "/w", | |
| 3350 | repository: "acme/web", | |
| 3351 | host: "github.com", | |
| 3352 | number: 7, | |
| 3353 | subjectId: "IC_1", | |
| 3354 | content: "heart", | |
| 3355 | reacted: true, | |
| 3356 | }); | |
| 3357 | ||
| 3358 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3359 | expect(variablesOf(SUBJECT_SCOPE_QUERY)).toEqual([ | |
| 3360 | { owner: "acme", name: "web", number: 7, subjectId: "IC_1" }, | |
| 3361 | ]); | |
| 3362 | expect(variablesOf("addReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); | |
| 3363 | }), | |
| 3364 | ); | |
| 3365 | ||
| 3366 | it.effect("refuses a given subject that belongs to a different pull request", () => | |
| 3367 | Effect.gen(function* () { | |
| 3368 | mockedExecute.mockReturnValueOnce( | |
| 3369 | Effect.succeed( | |
| 3370 | output( | |
| 3371 | encodeJson({ | |
| 3372 | data: { | |
| 3373 | repository: { pullRequest: { id: "PR_thisOne" } }, | |
| 3374 | // A comment on pull request #99 of a different repository, named as though it | |
| 3375 | // belonged to #7 here. | |
| 3376 | node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, | |
| 3377 | }, | |
| 3378 | }), | |
| 3379 | ), | |
| 3380 | ), | |
| 3381 | ); | |
| 3382 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3383 | ||
| 3384 | const error = yield* Effect.flip( | |
| 3385 | cli.setReaction({ | |
| 3386 | cwd: "/w", | |
| 3387 | repository: "acme/web", | |
| 3388 | host: "github.com", | |
| 3389 | number: 7, | |
| 3390 | subjectId: "IC_99", | |
| 3391 | content: "heart", | |
| 3392 | reacted: true, | |
| 3393 | }), | |
| 3394 | ); | |
| 3395 | ||
| 3396 | assert.strictEqual(error._tag, "GitHubSubjectScopeError"); | |
| 3397 | // Refused before any mutation was sent. | |
| 3398 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 3399 | }), | |
| 3400 | ); | |
| 3401 | ||
| 3402 | it.effect("looks up the pull request's own node id when no subject was given", () => | |
| 3403 | Effect.gen(function* () { | |
| 3404 | route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); | |
| 3405 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3406 | ||
| 3407 | // Its own pull request: a node id looked up once is remembered for the life of the service. | |
| 3408 | yield* cli.setReaction({ | |
| 3409 | cwd: "/w", | |
| 3410 | repository: "acme/web", | |
| 3411 | host: "github.com", | |
| 3412 | number: 21, | |
| 3413 | content: "rocket", | |
| 3414 | reacted: true, | |
| 3415 | }); | |
| 3416 | ||
| 3417 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3418 | expect(variablesOf(NODE_ID_QUERY)).toEqual([{ owner: "acme", name: "web", number: 21 }]); | |
| 3419 | expect(variablesOf("addReaction(")).toEqual([{ subjectId: "PR_kwDOA", content: "ROCKET" }]); | |
| 3420 | }), | |
| 3421 | ); | |
| 3422 | ||
| 3423 | it.effect("takes a reaction back through the remove mutation", () => | |
| 3424 | Effect.gen(function* () { | |
| 3425 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); | |
| 3426 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3427 | ||
| 3428 | yield* cli.setReaction({ | |
| 3429 | cwd: "/w", | |
| 3430 | repository: "acme/web", | |
| 3431 | host: "github.com", | |
| 3432 | number: 7, | |
| 3433 | subjectId: "IC_1", | |
| 3434 | content: "heart", | |
| 3435 | reacted: false, | |
| 3436 | }); | |
| 3437 | ||
| 3438 | expect(variablesOf("removeReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); | |
| 3439 | expect(variablesOf("addReaction(")).toEqual([]); | |
| 3440 | }), | |
| 3441 | ); | |
| 3442 | ||
| 3443 | it.effect("rewrites only the words a request named", () => | |
| 3444 | Effect.gen(function* () { | |
| 3445 | route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); | |
| 3446 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3447 | const rewrite = (fields: { readonly title?: string; readonly body?: string }) => | |
| 3448 | cli.updatePullRequest({ | |
| 3449 | cwd: "/w", | |
| 3450 | repository: "acme/web", | |
| 3451 | host: "github.com", | |
| 3452 | number: 22, | |
| 3453 | ...fields, | |
| 3454 | }); | |
| 3455 | ||
| 3456 | yield* rewrite({ title: "A better title" }); | |
| 3457 | yield* rewrite({ body: "A better description." }); | |
| 3458 | yield* rewrite({ title: "Both", body: "at once." }); | |
| 3459 | ||
| 3460 | // One node id lookup for the pull request, then a mutation per rewrite. | |
| 3461 | assert.strictEqual(variablesOf(NODE_ID_QUERY).length, 1); | |
| 3462 | expect(variablesOf("updatePullRequest(")).toEqual([ | |
| 3463 | { pullRequestId: "PR_kwDOA", title: "A better title" }, | |
| 3464 | { pullRequestId: "PR_kwDOA", body: "A better description." }, | |
| 3465 | { pullRequestId: "PR_kwDOA", title: "Both", body: "at once." }, | |
| 3466 | ]); | |
| 3467 | }), | |
| 3468 | ); | |
| 3469 | ||
| 3470 | it.effect("rewrites a remark through the mutation its kind needs", () => | |
| 3471 | Effect.gen(function* () { | |
| 3472 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); | |
| 3473 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3474 | const rewrite = (kind: "issue-comment" | "review-comment") => | |
| 3475 | cli.updateComment({ | |
| 3476 | cwd: "/w", | |
| 3477 | repository: "acme/web", | |
| 3478 | host: "github.com", | |
| 3479 | number: 7, | |
| 3480 | commentId: "IC_1", | |
| 3481 | kind, | |
| 3482 | body: "Reworded.", | |
| 3483 | }); | |
| 3484 | ||
| 3485 | yield* rewrite("issue-comment"); | |
| 3486 | yield* rewrite("review-comment"); | |
| 3487 | ||
| 3488 | expect(variablesOf(SUBJECT_SCOPE_QUERY).map((variables) => variables["subjectId"])).toEqual([ | |
| 3489 | "IC_1", | |
| 3490 | "IC_1", | |
| 3491 | ]); | |
| 3492 | expect(variablesOf("updateIssueComment(")).toEqual([ | |
| 3493 | { commentId: "IC_1", body: "Reworded." }, | |
| 3494 | ]); | |
| 3495 | expect(variablesOf("updatePullRequestReviewComment(")).toEqual([ | |
| 3496 | { commentId: "IC_1", body: "Reworded." }, | |
| 3497 | ]); | |
| 3498 | }), | |
| 3499 | ); | |
| 3500 | ||
| 3501 | it.effect("refuses a comment that belongs to a different pull request", () => | |
| 3502 | Effect.gen(function* () { | |
| 3503 | mockedExecute.mockReturnValueOnce( | |
| 3504 | Effect.succeed( | |
| 3505 | output( | |
| 3506 | encodeJson({ | |
| 3507 | data: { | |
| 3508 | repository: { pullRequest: { id: "PR_thisOne" } }, | |
| 3509 | node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, | |
| 3510 | }, | |
| 3511 | }), | |
| 3512 | ), | |
| 3513 | ), | |
| 3514 | ); | |
| 3515 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3516 | ||
| 3517 | const error = yield* Effect.flip( | |
| 3518 | cli.updateComment({ | |
| 3519 | cwd: "/w", | |
| 3520 | repository: "acme/web", | |
| 3521 | host: "github.com", | |
| 3522 | number: 7, | |
| 3523 | commentId: "IC_99", | |
| 3524 | kind: "issue-comment", | |
| 3525 | body: "Reworded.", | |
| 3526 | }), | |
| 3527 | ); | |
| 3528 | ||
| 3529 | assert.strictEqual(error._tag, "GitHubSubjectScopeError"); | |
| 3530 | if (error._tag === "GitHubSubjectScopeError") | |
| 3531 | assert.strictEqual(error.operation, "updateComment"); | |
| 3532 | // Refused before any mutation was sent. | |
| 3533 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 3534 | }), | |
| 3535 | ); | |
| 3536 | ||
| 3537 | it.effect("fails the read when gh returns something unreadable", () => | |
| 3538 | Effect.gen(function* () { | |
| 3539 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); | |
| 3540 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3541 | ||
| 3542 | const error = yield* Effect.flip( | |
| 3543 | cli.getPullRequestDetail({ | |
| 3544 | cwd: "/w", | |
| 3545 | repository: "acme/web", | |
| 3546 | host: "github.com", | |
| 3547 | number: 7, | |
| 3548 | }), | |
| 3549 | ); | |
| 3550 | ||
| 3551 | assert.strictEqual(error._tag, "GitHubPullRequestReadError"); | |
| 3552 | }), | |
| 3553 | ); | |
| 3554 | ||
| 3555 | it.effect("keeps the core detail read separate from conversation activity", () => | |
| 3556 | Effect.gen(function* () { | |
| 3557 | mockedExecute.mockReturnValueOnce( | |
| 3558 | Effect.succeed( | |
| 3559 | output( | |
| 3560 | encodeJson( | |
| 3561 | coreResponse({ | |
| 3562 | title: "Progressive detail", | |
| 3563 | author: { login: "octocat" }, | |
| 3564 | body: "Core body", | |
| 3565 | changedFiles: 2, | |
| 3566 | }), | |
| 3567 | ), | |
| 3568 | ), | |
| 3569 | ), | |
| 3570 | ); | |
| 3571 | mockedExecute.mockReturnValueOnce( | |
| 3572 | Effect.succeed( | |
| 3573 | output( | |
| 3574 | encodeJson({ | |
| 3575 | data: { | |
| 3576 | repository: { | |
| 3577 | pullRequest: { | |
| 3578 | author: { __typename: "User", login: "octocat", avatarUrl: "https://a/o" }, | |
| 3579 | commits: { nodes: [] }, | |
| 3580 | comments: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, | |
| 3581 | reviews: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, | |
| 3582 | }, | |
| 3583 | }, | |
| 3584 | }, | |
| 3585 | }), | |
| 3586 | ), | |
| 3587 | ), | |
| 3588 | ); | |
| 3589 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3590 | const input = { | |
| 3591 | cwd: "/w", | |
| 3592 | repository: "acme/web", | |
| 3593 | host: "github.com", | |
| 3594 | number: 7, | |
| 3595 | } as const; | |
| 3596 | ||
| 3597 | const detail = yield* cli.getPullRequestDetail(input); | |
| 3598 | const activity = yield* cli.getPullRequestActivity(input); | |
| 3599 | ||
| 3600 | expect(detail.body).toBe("Core body"); | |
| 3601 | expect(activity.author?.login).toBe("octocat"); | |
| 3602 | expect(varsAt(0)).toMatchObject({ number: 7, headRef: "refs/pull/7/head" }); | |
| 3603 | expect(queryAt(0)).toContain("viewerCanUpdateBranch"); | |
| 3604 | expect(queryAt(0)).not.toContain("reviews("); | |
| 3605 | expect(detail.viewerAccess.mergeCapabilities).toEqual({ | |
| 3606 | merge: true, | |
| 3607 | squash: false, | |
| 3608 | rebase: true, | |
| 3609 | }); | |
| 3610 | expect(detail.comparison).toEqual({ behindBy: 2, viewerCanUpdate: true }); | |
| 3611 | // Conversation activity is its own read, and asks for the head of the conversation once. | |
| 3612 | expect(queryAt(1)).toContain("reviews("); | |
| 3613 | expect(varsAt(1)).toMatchObject({ head: true, withComments: true, withReviews: true }); | |
| 3614 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3615 | }), | |
| 3616 | ); | |
| 3617 | ||
| 3618 | it.effect("decodes reviewers, labels and workflow checks without another detail read", () => | |
| 3619 | Effect.gen(function* () { | |
| 3620 | mockedExecute.mockReturnValueOnce( | |
| 3621 | Effect.succeed( | |
| 3622 | output( | |
| 3623 | encodeJson( | |
| 3624 | coreResponse({ | |
| 3625 | baseRef: null, | |
| 3626 | reviewRequests: { | |
| 3627 | nodes: [ | |
| 3628 | { requestedReviewer: { login: "reviewer" } }, | |
| 3629 | { requestedReviewer: { slug: "maintainers", name: "Maintainers" } }, | |
| 3630 | ], | |
| 3631 | }, | |
| 3632 | labels: { nodes: [{ name: "bug", color: "ff0000" }] }, | |
| 3633 | commits: { | |
| 3634 | nodes: [ | |
| 3635 | { | |
| 3636 | commit: { | |
| 3637 | statusCheckRollup: { | |
| 3638 | contexts: { | |
| 3639 | nodes: [ | |
| 3640 | { | |
| 3641 | __typename: "CheckRun", | |
| 3642 | name: "build", | |
| 3643 | status: "COMPLETED", | |
| 3644 | conclusion: "SUCCESS", | |
| 3645 | checkSuite: { workflowRun: { workflow: { name: "linux" } } }, | |
| 3646 | }, | |
| 3647 | { | |
| 3648 | __typename: "CheckRun", | |
| 3649 | name: "build", | |
| 3650 | status: "COMPLETED", | |
| 3651 | conclusion: "FAILURE", | |
| 3652 | checkSuite: { workflowRun: { workflow: { name: "windows" } } }, | |
| 3653 | }, | |
| 3654 | ], | |
| 3655 | pageInfo: { hasNextPage: false }, | |
| 3656 | }, | |
| 3657 | }, | |
| 3658 | }, | |
| 3659 | }, | |
| 3660 | ], | |
| 3661 | }, | |
| 3662 | }), | |
| 3663 | ), | |
| 3664 | ), | |
| 3665 | ), | |
| 3666 | ); | |
| 3667 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3668 | const detail = yield* cli.getPullRequestDetail({ | |
| 3669 | cwd: "/w", | |
| 3670 | repository: "acme/web", | |
| 3671 | host: "github.com", | |
| 3672 | number: 7, | |
| 3673 | }); | |
| 3674 | expect(mockedExecute).toHaveBeenCalledOnce(); | |
| 3675 | expect(detail.comparison).toBeNull(); | |
| 3676 | expect(detail.reviewRequestLogins).toEqual(["reviewer"]); | |
| 3677 | expect(detail.hasTeamReviewRequest).toBe(true); | |
| 3678 | expect(detail.labels).toEqual([{ name: "bug", color: "ff0000" }]); | |
| 3679 | expect(detail.checks).toHaveLength(2); | |
| 3680 | expect(detail.checksState).toBe("failing"); | |
| 3681 | }), | |
| 3682 | ); | |
| 3683 | ||
| 3684 | it.effect("reads every check when the combined response has another page", () => | |
| 3685 | Effect.gen(function* () { | |
| 3686 | mockedExecute.mockReturnValueOnce( | |
| 3687 | Effect.succeed( | |
| 3688 | output( | |
| 3689 | encodeJson( | |
| 3690 | coreResponse({ | |
| 3691 | commits: { | |
| 3692 | nodes: [ | |
| 3693 | { | |
| 3694 | commit: { | |
| 3695 | statusCheckRollup: { | |
| 3696 | contexts: { | |
| 3697 | nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }], | |
| 3698 | pageInfo: { hasNextPage: true, endCursor: "c1" }, | |
| 3699 | }, | |
| 3700 | }, | |
| 3701 | }, | |
| 3702 | }, | |
| 3703 | ], | |
| 3704 | }, | |
| 3705 | }), | |
| 3706 | ), | |
| 3707 | ), | |
| 3708 | ), | |
| 3709 | ); | |
| 3710 | mockedExecute.mockReturnValueOnce( | |
| 3711 | Effect.succeed( | |
| 3712 | output( | |
| 3713 | checkContextsPage( | |
| 3714 | [ | |
| 3715 | { name: "first", status: "COMPLETED", conclusion: "SUCCESS" }, | |
| 3716 | { name: "last", status: "COMPLETED", conclusion: "FAILURE" }, | |
| 3717 | ], | |
| 3718 | null, | |
| 3719 | ), | |
| 3720 | ), | |
| 3721 | ), | |
| 3722 | ); | |
| 3723 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3724 | const detail = yield* cli.getPullRequestDetail({ | |
| 3725 | cwd: "/w", | |
| 3726 | repository: "acme/web", | |
| 3727 | host: "github.com", | |
| 3728 | number: 7, | |
| 3729 | }); | |
| 3730 | expect(detail.checks).toHaveLength(2); | |
| 3731 | expect(detail.checksState).toBe("failing"); | |
| 3732 | expect(detail.checksTruncated).toBe(false); | |
| 3733 | // The whole rollup is walked from its start, so no check is counted twice or skipped. | |
| 3734 | expect(varsAt(1)).toMatchObject({ number: 7, after: null }); | |
| 3735 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3736 | }), | |
| 3737 | ); | |
| 3738 | ||
| 3739 | it.effect("refuses to combine checks from different head revisions", () => | |
| 3740 | Effect.gen(function* () { | |
| 3741 | const response = coreResponse({ | |
| 3742 | commits: { | |
| 3743 | nodes: [ | |
| 3744 | { | |
| 3745 | commit: { | |
| 3746 | statusCheckRollup: { | |
| 3747 | contexts: { | |
| 3748 | nodes: [], | |
| 3749 | pageInfo: { hasNextPage: true }, | |
| 3750 | }, | |
| 3751 | }, | |
| 3752 | }, | |
| 3753 | }, | |
| 3754 | ], | |
| 3755 | }, | |
| 3756 | }); | |
| 3757 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response)))); | |
| 3758 | mockedExecute.mockReturnValueOnce( | |
| 3759 | Effect.succeed( | |
| 3760 | output( | |
| 3761 | encodeJson({ | |
| 3762 | ...response.data.repository.pullRequest, | |
| 3763 | headRefOid: "new-head", | |
| 3764 | reviewRequests: [], | |
| 3765 | labels: [], | |
| 3766 | statusCheckRollup: [], | |
| 3767 | }), | |
| 3768 | ), | |
| 3769 | ), | |
| 3770 | ); | |
| 3771 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3772 | const error = yield* Effect.flip( | |
| 3773 | cli.getPullRequestDetail({ | |
| 3774 | cwd: "/w", | |
| 3775 | repository: "acme/web", | |
| 3776 | host: "github.com", | |
| 3777 | number: 7, | |
| 3778 | }), | |
| 3779 | ); | |
| 3780 | expect(error._tag).toBe("GitHubPullRequestReadError"); | |
| 3781 | }), | |
| 3782 | ); | |
| 3783 | ||
| 3784 | it.effect("preserves the reserve for automatic detail reads and allows manual checks", () => | |
| 3785 | Effect.gen(function* () { | |
| 3786 | const response = coreResponse(); | |
| 3787 | mockedExecute.mockReturnValue( | |
| 3788 | Effect.succeed( | |
| 3789 | output( | |
| 3790 | encodeJson({ | |
| 3791 | ...response, | |
| 3792 | data: { | |
| 3793 | ...response.data, | |
| 3794 | rateLimit: { | |
| 3795 | cost: 1, | |
| 3796 | limit: 5000, | |
| 3797 | remaining: 500, | |
| 3798 | resetAt: "2099-08-13T14:00:00Z", | |
| 3799 | }, | |
| 3800 | }, | |
| 3801 | }), | |
| 3802 | ), | |
| 3803 | ), | |
| 3804 | ); | |
| 3805 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3806 | const input = { | |
| 3807 | cwd: "/w", | |
| 3808 | repository: "acme/web", | |
| 3809 | host: "github.core-reserve.test", | |
| 3810 | number: 7, | |
| 3811 | }; | |
| 3812 | yield* cli.getPullRequestDetail(input); | |
| 3813 | const error = yield* Effect.flip(cli.getPullRequestDetail(input)); | |
| 3814 | expect(error._tag).toBe("SourceControlRateLimitPausedError"); | |
| 3815 | expect(mockedExecute).toHaveBeenCalledOnce(); | |
| 3816 | yield* cli.getPullRequestDetail(input).pipe(Effect.provideService(AllowGitHubReserve, true)); | |
| 3817 | expect(mockedExecute).toHaveBeenCalledTimes(2); | |
| 3818 | }), | |
| 3819 | ); | |
| 3820 | ||
| 3821 | it.effect("fails a files page too large to read rather than calling the diff whole", () => | |
| 3822 | Effect.gen(function* () { | |
| 3823 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 3824 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1), true))); | |
| 3825 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3826 | ||
| 3827 | const error = yield* Effect.flip( | |
| 3828 | cli.getPullRequestDiff({ | |
| 3829 | cwd: "/w", | |
| 3830 | repository: "acme/web", | |
| 3831 | host: "github.com", | |
| 3832 | number: 7, | |
| 3833 | }), | |
| 3834 | ); | |
| 3835 | ||
| 3836 | // What matters is that it fails at all: an empty patch with no cursor would render as a | |
| 3837 | // change with no files and report the rest of it as already read. The refusal that sent | |
| 3838 | // the read down this road is the one reported, by design. | |
| 3839 | assert.strictEqual(error, diffRefused); | |
| 3840 | }), | |
| 3841 | ); | |
| 3842 | ||
| 3843 | it.effect("pages an oversized patch by file rather than handing back a severed one", () => | |
| 3844 | Effect.gen(function* () { | |
| 3845 | // The whole diff came back, but cut at a byte, which lands mid-file. | |
| 3846 | mockedExecute.mockReturnValueOnce( | |
| 3847 | Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)), | |
| 3848 | ); | |
| 3849 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1)))); | |
| 3850 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3851 | ||
| 3852 | const slice = yield* cli.getPullRequestDiff({ | |
| 3853 | cwd: "/w", | |
| 3854 | repository: "acme/web", | |
| 3855 | host: "github.com", | |
| 3856 | number: 7, | |
| 3857 | }); | |
| 3858 | ||
| 3859 | // The severed patch is thrown away; what comes back is assembled from whole files. | |
| 3860 | assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); | |
| 3861 | expect(slice.patch).toContain("src/file1.ts"); | |
| 3862 | expect(slice.patch).not.toContain("diff --git a/a b/a"); | |
| 3863 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3864 | }), | |
| 3865 | ); | |
| 3866 | ||
| 3867 | it.effect("follows the cursor to the review threads the first page left behind", () => | |
| 3868 | Effect.gen(function* () { | |
| 3869 | mockedExecute.mockReturnValueOnce( | |
| 3870 | Effect.succeed(output(reviewThreadsPage([thread("PRRT_1", "c1")], "Y3Vyc29yOjE"))), | |
| 3871 | ); | |
| 3872 | mockedExecute.mockReturnValueOnce( | |
| 3873 | Effect.succeed(output(reviewThreadsPage([thread("PRRT_2", "c2")], null))), | |
| 3874 | ); | |
| 3875 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3876 | ||
| 3877 | const conversation = yield* cli.listReviewThreadComments({ | |
| 3878 | cwd: "/w", | |
| 3879 | repository: "acme/web", | |
| 3880 | host: "github.com", | |
| 3881 | number: 7, | |
| 3882 | }); | |
| 3883 | ||
| 3884 | // The first page asks from the beginning; the next carries on from where it stopped. | |
| 3885 | expect(varsAt(0)["cursor"]).toBeNull(); | |
| 3886 | expect(varsAt(1)["cursor"]).toBe("Y3Vyc29yOjE"); | |
| 3887 | expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2"]); | |
| 3888 | assert.isFalse(conversation.truncated); | |
| 3889 | }), | |
| 3890 | ); | |
| 3891 | ||
| 3892 | it.effect("stops at the thread bound and says the conversation was cut short", () => | |
| 3893 | Effect.gen(function* () { | |
| 3894 | // A host that never runs out of pages: the walk has to end itself. | |
| 3895 | mockedExecute.mockReturnValue( | |
| 3896 | Effect.succeed( | |
| 3897 | output( | |
| 3898 | reviewThreadsPage( | |
| 3899 | [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }], | |
| 3900 | "Y3Vyc29yOjE", | |
| 3901 | ), | |
| 3902 | ), | |
| 3903 | ), | |
| 3904 | ); | |
| 3905 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3906 | ||
| 3907 | const conversation = yield* cli.listReviewThreadComments({ | |
| 3908 | cwd: "/w", | |
| 3909 | repository: "acme/web", | |
| 3910 | host: "github.com", | |
| 3911 | number: 7, | |
| 3912 | }); | |
| 3913 | ||
| 3914 | assert.strictEqual(mockedExecute.mock.calls.length, 10); | |
| 3915 | assert.isTrue(conversation.truncated); | |
| 3916 | assert.isTrue(conversation.reviewThreadsTruncated); | |
| 3917 | }), | |
| 3918 | ); | |
| 3919 | ||
| 3920 | it.effect("leaves a long thread paged until the reader asks for more", () => | |
| 3921 | Effect.gen(function* () { | |
| 3922 | mockedExecute.mockReturnValueOnce( | |
| 3923 | Effect.succeed( | |
| 3924 | output( | |
| 3925 | reviewThreadsPage( | |
| 3926 | [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }], | |
| 3927 | null, | |
| 3928 | ), | |
| 3929 | ), | |
| 3930 | ), | |
| 3931 | ); | |
| 3932 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3933 | ||
| 3934 | const conversation = yield* cli.listReviewThreadComments({ | |
| 3935 | cwd: "/w", | |
| 3936 | repository: "acme/web", | |
| 3937 | host: "github.com", | |
| 3938 | number: 7, | |
| 3939 | }); | |
| 3940 | ||
| 3941 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 3942 | expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1"]); | |
| 3943 | expect(conversation.reviewThreads[0]).toMatchObject({ | |
| 3944 | commentCount: 3, | |
| 3945 | nextCommentsCursor: "Y3Vyc29yOjI", | |
| 3946 | }); | |
| 3947 | assert.isTrue(conversation.truncated); | |
| 3948 | assert.isFalse(conversation.reviewThreadsTruncated); | |
| 3949 | }), | |
| 3950 | ); | |
| 3951 | ||
| 3952 | it.effect("reads one requested page from a review thread cursor", () => | |
| 3953 | Effect.gen(function* () { | |
| 3954 | mockedExecute.mockReturnValueOnce( | |
| 3955 | Effect.succeed(output(threadCommentsPage(["c2", "c3"], null, 3))), | |
| 3956 | ); | |
| 3957 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3958 | ||
| 3959 | const page = yield* cli.getReviewThreadComments({ | |
| 3960 | cwd: "/w", | |
| 3961 | repository: "acme/web", | |
| 3962 | host: "github.com", | |
| 3963 | number: 7, | |
| 3964 | threadId: "PRRT_1", | |
| 3965 | cursor: "Y3Vyc29yOjI", | |
| 3966 | }); | |
| 3967 | ||
| 3968 | expect(varsAt(0)).toMatchObject({ | |
| 3969 | owner: "acme", | |
| 3970 | name: "web", | |
| 3971 | number: 7, | |
| 3972 | threadId: "PRRT_1", | |
| 3973 | cursor: "Y3Vyc29yOjI", | |
| 3974 | }); | |
| 3975 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 3976 | expect(page.comments.map((comment) => comment.id)).toEqual(["c2", "c3"]); | |
| 3977 | expect(page.nextCursor).toBeNull(); | |
| 3978 | }), | |
| 3979 | ); | |
| 3980 | ||
| 3981 | it.effect("refuses a review thread from another pull request", () => | |
| 3982 | Effect.gen(function* () { | |
| 3983 | mockedExecute.mockReturnValueOnce( | |
| 3984 | Effect.succeed(output(threadCommentsPage(["foreign"], null, 1, "PR_8"))), | |
| 3985 | ); | |
| 3986 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 3987 | ||
| 3988 | const error = yield* Effect.flip( | |
| 3989 | cli.getReviewThreadComments({ | |
| 3990 | cwd: "/w", | |
| 3991 | repository: "acme/web", | |
| 3992 | host: "github.com", | |
| 3993 | number: 7, | |
| 3994 | threadId: "PRRT_FOREIGN", | |
| 3995 | cursor: "Y3Vyc29yOjI", | |
| 3996 | }), | |
| 3997 | ); | |
| 3998 | ||
| 3999 | assert.strictEqual(error._tag, "GitHubSubjectScopeError"); | |
| 4000 | }), | |
| 4001 | ); | |
| 4002 | ||
| 4003 | it.effect( | |
| 4004 | "asks for the reader's standing on the repository and on the pull request at once", | |
| 4005 | () => | |
| 4006 | Effect.gen(function* () { | |
| 4007 | mockedExecute.mockReturnValue( | |
| 4008 | Effect.succeed( | |
| 4009 | output( | |
| 4010 | encodeJson({ | |
| 4011 | data: { | |
| 4012 | repository: { | |
| 4013 | mergeCommitAllowed: true, | |
| 4014 | squashMergeAllowed: false, | |
| 4015 | rebaseMergeAllowed: true, | |
| 4016 | viewerPermission: "READ", | |
| 4017 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | |
| 4018 | }, | |
| 4019 | }, | |
| 4020 | }), | |
| 4021 | ), | |
| 4022 | ), | |
| 4023 | ); | |
| 4024 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4025 | ||
| 4026 | const access = yield* cli.getViewerAccess({ | |
| 4027 | cwd: "/w", | |
| 4028 | repository: "acme/web", | |
| 4029 | host: "github.com", | |
| 4030 | number: 7, | |
| 4031 | }); | |
| 4032 | ||
| 4033 | // One request, because both answers hang off the same repository object. | |
| 4034 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 4035 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); | |
| 4036 | expect(queryAt(0)).toContain("mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed"); | |
| 4037 | expect(access).toEqual({ | |
| 4038 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | |
| 4039 | canWrite: false, | |
| 4040 | canTriage: false, | |
| 4041 | canUpdate: true, | |
| 4042 | didAuthor: true, | |
| 4043 | }); | |
| 4044 | }), | |
| 4045 | ); | |
| 4046 | ||
| 4047 | it.effect("stops GraphQL reads at the protected reserve until reset", () => | |
| 4048 | Effect.gen(function* () { | |
| 4049 | mockedExecute.mockReturnValue( | |
| 4050 | Effect.succeed( | |
| 4051 | output( | |
| 4052 | encodeJson({ | |
| 4053 | data: { | |
| 4054 | repository: { | |
| 4055 | mergeCommitAllowed: true, | |
| 4056 | squashMergeAllowed: false, | |
| 4057 | rebaseMergeAllowed: true, | |
| 4058 | viewerPermission: "READ", | |
| 4059 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | |
| 4060 | }, | |
| 4061 | rateLimit: { | |
| 4062 | cost: 1, | |
| 4063 | limit: 5_000, | |
| 4064 | remaining: 500, | |
| 4065 | resetAt: "2099-08-13T14:00:00Z", | |
| 4066 | }, | |
| 4067 | }, | |
| 4068 | }), | |
| 4069 | ), | |
| 4070 | ), | |
| 4071 | ); | |
| 4072 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4073 | const input = { | |
| 4074 | cwd: "/w", | |
| 4075 | repository: "acme/web", | |
| 4076 | host: "github.com", | |
| 4077 | number: 7, | |
| 4078 | } as const; | |
| 4079 | ||
| 4080 | yield* cli.getViewerAccess(input); | |
| 4081 | expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); | |
| 4082 | ||
| 4083 | const error = yield* Effect.flip(cli.getViewerAccess(input)); | |
| 4084 | ||
| 4085 | assert.strictEqual(error._tag, "SourceControlRateLimitPausedError"); | |
| 4086 | if (error._tag !== "SourceControlRateLimitPausedError") return; | |
| 4087 | assert.strictEqual(error.host, "github.com"); | |
| 4088 | assert.strictEqual(error.retryAt, Date.parse("2099-08-13T14:00:00Z")); | |
| 4089 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 4090 | yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z")); | |
| 4091 | }), | |
| 4092 | ); | |
| 4093 | ||
| 4094 | it.effect("lets an interactive permission read use the protected reserve", () => | |
| 4095 | Effect.gen(function* () { | |
| 4096 | mockedExecute | |
| 4097 | .mockReturnValueOnce( | |
| 4098 | Effect.succeed( | |
| 4099 | output( | |
| 4100 | encodeJson({ | |
| 4101 | data: { | |
| 4102 | repository: { | |
| 4103 | mergeCommitAllowed: true, | |
| 4104 | squashMergeAllowed: false, | |
| 4105 | rebaseMergeAllowed: true, | |
| 4106 | viewerPermission: "READ", | |
| 4107 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | |
| 4108 | }, | |
| 4109 | rateLimit: { | |
| 4110 | cost: 1, | |
| 4111 | limit: 5_000, | |
| 4112 | remaining: 500, | |
| 4113 | resetAt: "2099-08-13T14:00:00Z", | |
| 4114 | }, | |
| 4115 | }, | |
| 4116 | }), | |
| 4117 | ), | |
| 4118 | ), | |
| 4119 | ) | |
| 4120 | .mockReturnValueOnce( | |
| 4121 | Effect.succeed( | |
| 4122 | output( | |
| 4123 | encodeJson({ | |
| 4124 | data: { | |
| 4125 | repository: { | |
| 4126 | mergeCommitAllowed: true, | |
| 4127 | squashMergeAllowed: false, | |
| 4128 | rebaseMergeAllowed: true, | |
| 4129 | viewerPermission: "READ", | |
| 4130 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | |
| 4131 | }, | |
| 4132 | }, | |
| 4133 | }), | |
| 4134 | ), | |
| 4135 | ), | |
| 4136 | ); | |
| 4137 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4138 | ||
| 4139 | yield* cli.getViewerAccess({ | |
| 4140 | cwd: "/w", | |
| 4141 | repository: "acme/web", | |
| 4142 | host: "github.com", | |
| 4143 | number: 7, | |
| 4144 | }); | |
| 4145 | const access = yield* cli.getViewerAccess({ | |
| 4146 | cwd: "/w", | |
| 4147 | repository: "acme/web", | |
| 4148 | host: "github.com", | |
| 4149 | number: 7, | |
| 4150 | allowReserve: true, | |
| 4151 | }); | |
| 4152 | ||
| 4153 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 4154 | expect(access).toEqual({ | |
| 4155 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | |
| 4156 | canWrite: false, | |
| 4157 | canTriage: false, | |
| 4158 | canUpdate: true, | |
| 4159 | didAuthor: true, | |
| 4160 | }); | |
| 4161 | yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z")); | |
| 4162 | }), | |
| 4163 | ); | |
| 4164 | ||
| 4165 | it.effect("asks GitHub to review, naming the collection a request is added to", () => | |
| 4166 | Effect.gen(function* () { | |
| 4167 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 4168 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4169 | ||
| 4170 | yield* cli.setReviewerRequest({ | |
| 4171 | cwd: "/w", | |
| 4172 | repository: "acme/web", | |
| 4173 | host: "github.com", | |
| 4174 | number: 7, | |
| 4175 | reviewers: [ | |
| 4176 | { id: "octocat", kind: "user" }, | |
| 4177 | { id: "reviewers", kind: "team" }, | |
| 4178 | ], | |
| 4179 | requested: true, | |
| 4180 | }); | |
| 4181 | ||
| 4182 | expect(callAt(0)).toMatchObject({ | |
| 4183 | kind: "rest", | |
| 4184 | method: "POST", | |
| 4185 | path: "repos/acme/web/pulls/7/requested_reviewers", | |
| 4186 | body: { reviewers: ["octocat"], team_reviewers: ["reviewers"] }, | |
| 4187 | }); | |
| 4188 | }), | |
| 4189 | ); | |
| 4190 | ||
| 4191 | it.effect("takes a request back by deleting from the same collection it was added to", () => | |
| 4192 | Effect.gen(function* () { | |
| 4193 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 4194 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4195 | ||
| 4196 | yield* cli.setReviewerRequest({ | |
| 4197 | cwd: "/w", | |
| 4198 | repository: "acme/web", | |
| 4199 | host: "github.com", | |
| 4200 | number: 7, | |
| 4201 | reviewers: [{ id: "octocat", kind: "user" }], | |
| 4202 | requested: false, | |
| 4203 | }); | |
| 4204 | ||
| 4205 | expect(callAt(0)).toMatchObject({ | |
| 4206 | kind: "rest", | |
| 4207 | method: "DELETE", | |
| 4208 | path: "repos/acme/web/pulls/7/requested_reviewers", | |
| 4209 | body: { reviewers: ["octocat"], team_reviewers: [] }, | |
| 4210 | }); | |
| 4211 | }), | |
| 4212 | ); | |
| 4213 | ||
| 4214 | it.effect("reads who may review and who already has in one request", () => | |
| 4215 | Effect.gen(function* () { | |
| 4216 | mockedExecute.mockReturnValue( | |
| 4217 | Effect.succeed( | |
| 4218 | output( | |
| 4219 | encodeJson({ | |
| 4220 | data: { | |
| 4221 | repository: { | |
| 4222 | assignableUsers: { | |
| 4223 | pageInfo: { hasNextPage: false }, | |
| 4224 | nodes: [{ login: "bilal" }, { login: "octocat" }, { login: "hubot" }], | |
| 4225 | }, | |
| 4226 | pullRequest: { | |
| 4227 | author: { login: "bilal" }, | |
| 4228 | reviewRequests: { nodes: [{ requestedReviewer: { login: "octocat" } }] }, | |
| 4229 | }, | |
| 4230 | }, | |
| 4231 | }, | |
| 4232 | }), | |
| 4233 | ), | |
| 4234 | ), | |
| 4235 | ); | |
| 4236 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4237 | ||
| 4238 | const list = yield* cli.listReviewerCandidates({ | |
| 4239 | cwd: "/w", | |
| 4240 | repository: "acme/web", | |
| 4241 | host: "github.com", | |
| 4242 | number: 7, | |
| 4243 | }); | |
| 4244 | ||
| 4245 | // The people, who has been asked and who opened the pull request all hang off the same | |
| 4246 | // repository object, so the menu costs one request. | |
| 4247 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 4248 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); | |
| 4249 | expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ | |
| 4250 | ["octocat", true], | |
| 4251 | ["hubot", false], | |
| 4252 | ]); | |
| 4253 | }), | |
| 4254 | ); | |
| 4255 | ||
| 4256 | it.effect("puts labels on by posting to the issue's own collection, all at once", () => | |
| 4257 | Effect.gen(function* () { | |
| 4258 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | |
| 4259 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4260 | ||
| 4261 | yield* cli.setLabels({ | |
| 4262 | cwd: "/w", | |
| 4263 | repository: "acme/web", | |
| 4264 | host: "github.com", | |
| 4265 | number: 7, | |
| 4266 | labels: ["bug", "size:XL"], | |
| 4267 | applied: true, | |
| 4268 | }); | |
| 4269 | ||
| 4270 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 4271 | expect(callAt(0)).toMatchObject({ | |
| 4272 | kind: "rest", | |
| 4273 | method: "POST", | |
| 4274 | path: "repos/acme/web/issues/7/labels", | |
| 4275 | body: { labels: ["bug", "size:XL"] }, | |
| 4276 | }); | |
| 4277 | }), | |
| 4278 | ); | |
| 4279 | ||
| 4280 | it.effect("takes labels off one at a time, naming each in the path encoded", () => | |
| 4281 | Effect.gen(function* () { | |
| 4282 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | |
| 4283 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4284 | ||
| 4285 | yield* cli.setLabels({ | |
| 4286 | cwd: "/w", | |
| 4287 | repository: "acme/web", | |
| 4288 | host: "github.com", | |
| 4289 | number: 7, | |
| 4290 | labels: ["good first issue", "area/web"], | |
| 4291 | applied: false, | |
| 4292 | }); | |
| 4293 | ||
| 4294 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 4295 | expect(callAt(0)).toMatchObject({ | |
| 4296 | method: "DELETE", | |
| 4297 | path: "repos/acme/web/issues/7/labels/good%20first%20issue", | |
| 4298 | }); | |
| 4299 | expect(callAt(1)).toMatchObject({ | |
| 4300 | method: "DELETE", | |
| 4301 | path: "repos/acme/web/issues/7/labels/area%2Fweb", | |
| 4302 | }); | |
| 4303 | }), | |
| 4304 | ); | |
| 4305 | ||
| 4306 | it.effect("reads every page of viewed files, and says so when there are too many", () => | |
| 4307 | Effect.gen(function* () { | |
| 4308 | const page = (index: number, hasNextPage: boolean) => | |
| 4309 | Effect.succeed( | |
| 4310 | output( | |
| 4311 | encodeJson({ | |
| 4312 | data: { | |
| 4313 | repository: { | |
| 4314 | pullRequest: { | |
| 4315 | files: { | |
| 4316 | pageInfo: { hasNextPage, endCursor: `cursor-${index}` }, | |
| 4317 | nodes: [ | |
| 4318 | { path: `src/file${index}.ts`, viewerViewedState: "VIEWED" }, | |
| 4319 | { path: `src/other${index}.ts`, viewerViewedState: "UNVIEWED" }, | |
| 4320 | ], | |
| 4321 | }, | |
| 4322 | }, | |
| 4323 | }, | |
| 4324 | }, | |
| 4325 | }), | |
| 4326 | ), | |
| 4327 | ); | |
| 4328 | mockedExecute | |
| 4329 | .mockReturnValueOnce(page(0, true)) | |
| 4330 | .mockReturnValueOnce(page(1, true)) | |
| 4331 | .mockReturnValueOnce(page(2, false)); | |
| 4332 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4333 | ||
| 4334 | const viewed = yield* cli.getPullRequestFilesViewed({ | |
| 4335 | cwd: "/w", | |
| 4336 | repository: "acme/web", | |
| 4337 | host: "github.com", | |
| 4338 | number: 7, | |
| 4339 | }); | |
| 4340 | ||
| 4341 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | |
| 4342 | // The first page asks from the start; each one after it carries the cursor before it. | |
| 4343 | expect([varsAt(0)["after"], varsAt(1)["after"], varsAt(2)["after"]]).toEqual([ | |
| 4344 | null, | |
| 4345 | "cursor-0", | |
| 4346 | "cursor-1", | |
| 4347 | ]); | |
| 4348 | assert.isFalse(viewed.truncated); | |
| 4349 | expect(viewed.files.map((file) => [file.path, file.state])).toEqual([ | |
| 4350 | ["src/file0.ts", "viewed"], | |
| 4351 | ["src/other0.ts", "unviewed"], | |
| 4352 | ["src/file1.ts", "viewed"], | |
| 4353 | ["src/other1.ts", "unviewed"], | |
| 4354 | ["src/file2.ts", "viewed"], | |
| 4355 | ["src/other2.ts", "unviewed"], | |
| 4356 | ]); | |
| 4357 | }), | |
| 4358 | ); | |
| 4359 | ||
| 4360 | it.effect("stops paging viewed files rather than following a change without end", () => | |
| 4361 | Effect.gen(function* () { | |
| 4362 | mockedExecute.mockReturnValue( | |
| 4363 | Effect.succeed( | |
| 4364 | output( | |
| 4365 | encodeJson({ | |
| 4366 | data: { | |
| 4367 | repository: { | |
| 4368 | pullRequest: { | |
| 4369 | files: { | |
| 4370 | pageInfo: { hasNextPage: true, endCursor: "cursor" }, | |
| 4371 | nodes: [{ path: "src/file.ts", viewerViewedState: "VIEWED" }], | |
| 4372 | }, | |
| 4373 | }, | |
| 4374 | }, | |
| 4375 | }, | |
| 4376 | }), | |
| 4377 | ), | |
| 4378 | ), | |
| 4379 | ); | |
| 4380 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4381 | ||
| 4382 | const viewed = yield* cli.getPullRequestFilesViewed({ | |
| 4383 | cwd: "/w", | |
| 4384 | repository: "acme/web", | |
| 4385 | host: "github.com", | |
| 4386 | number: 7, | |
| 4387 | }); | |
| 4388 | ||
| 4389 | assert.strictEqual(mockedExecute.mock.calls.length, 5); | |
| 4390 | assert.isTrue(viewed.truncated); | |
| 4391 | assert.strictEqual(viewed.files.length, 5); | |
| 4392 | }), | |
| 4393 | ); | |
| 4394 | ||
| 4395 | it.effect("clears and restores a burst of files in one request", () => | |
| 4396 | Effect.gen(function* () { | |
| 4397 | mockedExecute | |
| 4398 | .mockReturnValueOnce( | |
| 4399 | Effect.succeed( | |
| 4400 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_1" } } } })), | |
| 4401 | ), | |
| 4402 | ) | |
| 4403 | .mockReturnValueOnce(Effect.succeed(output("{}"))); | |
| 4404 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4405 | ||
| 4406 | yield* cli.setPullRequestFilesViewed({ | |
| 4407 | cwd: "/w", | |
| 4408 | repository: "acme/web", | |
| 4409 | host: "github.com", | |
| 4410 | number: 23, | |
| 4411 | files: [ | |
| 4412 | { path: "src/a.ts", viewed: true }, | |
| 4413 | { path: "src/b.ts", viewed: false }, | |
| 4414 | ], | |
| 4415 | }); | |
| 4416 | ||
| 4417 | // One request to learn the pull request's node id, one for every press together. | |
| 4418 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 4419 | expect(queryAt(1)).toContain("f0: markFileAsViewed"); | |
| 4420 | expect(queryAt(1)).toContain("f1: unmarkFileAsViewed"); | |
| 4421 | expect(varsAt(1)).toEqual({ | |
| 4422 | pullRequestId: "PR_1", | |
| 4423 | path0: "src/a.ts", | |
| 4424 | path1: "src/b.ts", | |
| 4425 | }); | |
| 4426 | }), | |
| 4427 | ); | |
| 4428 | ||
| 4429 | it.effect("asks the host nothing when nothing was pressed", () => | |
| 4430 | Effect.gen(function* () { | |
| 4431 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4432 | ||
| 4433 | yield* cli.setPullRequestFilesViewed({ | |
| 4434 | cwd: "/w", | |
| 4435 | repository: "acme/web", | |
| 4436 | host: "github.com", | |
| 4437 | number: 7, | |
| 4438 | files: [], | |
| 4439 | }); | |
| 4440 | ||
| 4441 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 4442 | }), | |
| 4443 | ); | |
| 4444 | ||
| 4445 | it.effect("looks a pull request's node id up once, however often it is written to", () => | |
| 4446 | Effect.gen(function* () { | |
| 4447 | mockedExecute | |
| 4448 | .mockReturnValueOnce( | |
| 4449 | Effect.succeed( | |
| 4450 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_24" } } } })), | |
| 4451 | ), | |
| 4452 | ) | |
| 4453 | .mockReturnValue(Effect.succeed(output("{}"))); | |
| 4454 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4455 | const pullRequest = { cwd: "/w", repository: "acme/web", host: "github.com", number: 24 }; | |
| 4456 | ||
| 4457 | yield* cli.setPullRequestFilesViewed({ | |
| 4458 | ...pullRequest, | |
| 4459 | files: [{ path: "src/a.ts", viewed: true }], | |
| 4460 | }); | |
| 4461 | yield* cli.setPullRequestFilesViewed({ | |
| 4462 | ...pullRequest, | |
| 4463 | files: [{ path: "src/b.ts", viewed: true }], | |
| 4464 | }); | |
| 4465 | yield* cli.updatePullRequest({ ...pullRequest, title: "Ticked through" }); | |
| 4466 | ||
| 4467 | // One lookup, then a mutation per write, every one of them addressed by the id it answered. | |
| 4468 | assert.strictEqual(mockedExecute.mock.calls.length, 4); | |
| 4469 | expect(varsAt(0)).toEqual({ owner: "acme", name: "web", number: 24 }); | |
| 4470 | expect([1, 2, 3].map((index) => varsAt(index)["pullRequestId"])).toEqual([ | |
| 4471 | "PR_24", | |
| 4472 | "PR_24", | |
| 4473 | "PR_24", | |
| 4474 | ]); | |
| 4475 | }), | |
| 4476 | ); | |
| 4477 | ||
| 4478 | it.effect("does not remember a node id lookup that failed", () => | |
| 4479 | Effect.gen(function* () { | |
| 4480 | mockedExecute | |
| 4481 | .mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))) | |
| 4482 | .mockReturnValueOnce( | |
| 4483 | Effect.succeed( | |
| 4484 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_25" } } } })), | |
| 4485 | ), | |
| 4486 | ) | |
| 4487 | .mockReturnValueOnce(Effect.succeed(output("{}"))); | |
| 4488 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4489 | const write = () => | |
| 4490 | cli.setPullRequestFilesViewed({ | |
| 4491 | cwd: "/w", | |
| 4492 | repository: "acme/web", | |
| 4493 | host: "github.com", | |
| 4494 | number: 25, | |
| 4495 | files: [{ path: "src/a.ts", viewed: true }], | |
| 4496 | }); | |
| 4497 | ||
| 4498 | const error = yield* Effect.flip(write()); | |
| 4499 | assert.strictEqual(error._tag, "GitHubPullRequestReadError"); | |
| 4500 | ||
| 4501 | yield* write(); | |
| 4502 | ||
| 4503 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | |
| 4504 | expect(varsAt(2)["pullRequestId"]).toEqual("PR_25"); | |
| 4505 | }), | |
| 4506 | ); | |
| 4507 | it.effect("keeps the pull request being ticked through, not the one looked up first", () => | |
| 4508 | Effect.gen(function* () { | |
| 4509 | // Ordered by insertion alone, a hit does not renew its entry, so the review the reader is | |
| 4510 | // working down is the first thing evicted once a listing has walked a cache's worth of cold | |
| 4511 | // pull requests, and every press after that pays a round trip again. | |
| 4512 | // This block shares one cache, so these numbers are its own and it runs last. | |
| 4513 | const HOT = 9_000; | |
| 4514 | const lookupsOf = new Map<number, number>(); | |
| 4515 | mockedExecute.mockImplementation((input) => { | |
| 4516 | if (input.kind !== "graphql" || !input.query.includes(NODE_ID_QUERY)) { | |
| 4517 | return Effect.succeed(output("{}")); | |
| 4518 | } | |
| 4519 | const number = Number(input.variables?.["number"]); | |
| 4520 | lookupsOf.set(number, (lookupsOf.get(number) ?? 0) + 1); | |
| 4521 | return Effect.succeed( | |
| 4522 | output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })), | |
| 4523 | ); | |
| 4524 | }); | |
| 4525 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 4526 | const tick = (number: number) => | |
| 4527 | cli.setPullRequestFilesViewed({ | |
| 4528 | cwd: "/w", | |
| 4529 | repository: "acme/web", | |
| 4530 | host: "github.com", | |
| 4531 | number, | |
| 4532 | files: [{ path: "src/a.ts", viewed: true }], | |
| 4533 | }); | |
| 4534 | ||
| 4535 | yield* tick(HOT); | |
| 4536 | // A cache's worth of cold pull requests, with the open one pressed in between each of them. | |
| 4537 | for (let filled = 0; filled < GitHubPullRequestApi.NODE_ID_CACHE_CAPACITY; filled += 1) { | |
| 4538 | yield* tick(HOT + 1 + filled); | |
| 4539 | yield* tick(HOT); | |
| 4540 | } | |
| 4541 | ||
| 4542 | assert.strictEqual(lookupsOf.get(HOT), 1); | |
| 4543 | }), | |
| 4544 | ); | |
| 4545 | }); |
apps/server/src/pullRequest/GitHubPullRequestApi.ts created+2847| ... | ... | @@ -0,0 +1,2847 @@ |
| 1 | import { removeAgentCredits } from "./mergeMessage.ts"; | |
| 2 | import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; | |
| 3 | import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; | |
| 4 | import * as Cause from "effect/Cause"; | |
| 5 | import * as Context from "effect/Context"; | |
| 6 | import * as Clock from "effect/Clock"; | |
| 7 | import * as Duration from "effect/Duration"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as FileSystem from "effect/FileSystem"; | |
| 10 | import * as Layer from "effect/Layer"; | |
| 11 | import * as Exit from "effect/Exit"; | |
| 12 | import * as Option from "effect/Option"; | |
| 13 | import * as Request from "effect/Request"; | |
| 14 | import * as RequestResolver from "effect/RequestResolver"; | |
| 15 | import * as Result from "effect/Result"; | |
| 16 | import * as Schema from "effect/Schema"; | |
| 17 | import * as Semaphore from "effect/Semaphore"; | |
| 18 | import { | |
| 19 | resolvePullRequestAuthorFilter, | |
| 20 | PositiveInt, | |
| 21 | TrimmedNonEmptyString, | |
| 22 | type PullRequestAction, | |
| 23 | type PullRequestStackHead, | |
| 24 | type PullRequestActor, | |
| 25 | type PullRequestComment, | |
| 26 | type PullRequestCommit, | |
| 27 | type PullRequestFileViewed, | |
| 28 | type PullRequestInvolvement, | |
| 29 | type PullRequestListFilters, | |
| 30 | type PullRequestListState, | |
| 31 | type PullRequestMergeMethod, | |
| 32 | type PullRequestOmittedFileStat, | |
| 33 | type PullRequestReaction, | |
| 34 | type PullRequestReactionContent, | |
| 35 | type PullRequestReviewCommentDraft, | |
| 36 | type PullRequestReviewVerdict, | |
| 37 | type PullRequestReviewerCandidateList, | |
| 38 | type PullRequestReviewerKind, | |
| 39 | type PullRequestLabelCandidateList, | |
| 40 | type PullRequestThreadCommentsResult, | |
| 41 | type PullRequestUpdateMethod, | |
| 42 | type PullRequestPreview, | |
| 43 | } from "@t3tools/contracts"; | |
| 44 | ||
| 45 | import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; | |
| 46 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 47 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 48 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; | |
| 49 | import { | |
| 50 | ACTOR_AVATARS_GRAPHQL_QUERY, | |
| 51 | ADD_REACTION_GRAPHQL_MUTATION, | |
| 52 | buildReviewSubmission, | |
| 53 | buildReviewerRequest, | |
| 54 | buildSetFilesViewedGraphQlMutation, | |
| 55 | decodeActorAvatarsJson, | |
| 56 | decodePullRequestActivityJson, | |
| 57 | decodePullRequestCheckContextsJson, | |
| 58 | decodePullRequestCoreJson, | |
| 59 | decodeCommitFilesJson, | |
| 60 | pullRequestCheckContextsGraphQlQuery, | |
| 61 | pullRequestChecksFromContexts, | |
| 62 | pullRequestListGraphQlQuery, | |
| 63 | pullRequestSummaryGraphQlQuery, | |
| 64 | PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, | |
| 65 | PULL_REQUEST_HEADS_GRAPHQL_QUERY, | |
| 66 | decodeWorkflowRunsJson, | |
| 67 | type GitHubCheckContext, | |
| 68 | pullRequestCoreGraphQlQuery, | |
| 69 | type GitHubPullRequestCore, | |
| 70 | type GitHubPullRequestSummary, | |
| 71 | type GitHubPullRequestWatchFingerprint, | |
| 72 | decodePullRequestPreviewJson, | |
| 73 | PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, | |
| 74 | decodePullRequestFilesJson, | |
| 75 | decodePullRequestFilesViewedJson, | |
| 76 | decodePullRequestHeadsJson, | |
| 77 | decodePullRequestListJson, | |
| 78 | decodePullRequestNodeIdJson, | |
| 79 | decodePullRequestSearchJson, | |
| 80 | decodePullRequestStacksJson, | |
| 81 | decodePullRequestStatsJson, | |
| 82 | decodePullRequestSummariesJson, | |
| 83 | decodePullRequestWatchFingerprintsJson, | |
| 84 | decodeReactionSubjectScopeJson, | |
| 85 | decodeReviewerCandidatesJson, | |
| 86 | decodeLabelCandidatesJson, | |
| 87 | buildLabelRequest, | |
| 88 | LABEL_CANDIDATES_GRAPHQL_QUERY, | |
| 89 | decodeReviewDismissalsJson, | |
| 90 | decodeReviewThreadCommentsJson, | |
| 91 | decodeReviewThreadsJson, | |
| 92 | buildPullRequestStatsGraphQlQuery, | |
| 93 | buildPullRequestSummariesGraphQlQuery, | |
| 94 | buildPullRequestWatchFingerprintsGraphQlQuery, | |
| 95 | buildPullRequestStackMembershipsGraphQlQuery, | |
| 96 | decodePullRequestStackMembershipsJson, | |
| 97 | pullRequestSearchGraphQlQuery, | |
| 98 | PULL_REQUEST_SEARCH_MAX_ROWS, | |
| 99 | PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, | |
| 100 | PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, | |
| 101 | REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, | |
| 102 | REMOVE_REACTION_GRAPHQL_MUTATION, | |
| 103 | REVERT_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 104 | gitHubReactionContent, | |
| 105 | RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, | |
| 106 | REVIEWER_CANDIDATES_GRAPHQL_QUERY, | |
| 107 | REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, | |
| 108 | REVIEW_DISMISSALS_GRAPHQL_QUERY, | |
| 109 | REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, | |
| 110 | REVIEW_THREADS_GRAPHQL_QUERY, | |
| 111 | reviewThreadConversation, | |
| 112 | UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, | |
| 113 | UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION, | |
| 114 | UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 115 | UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, | |
| 116 | VIEWER_PERMISSIONS_GRAPHQL_QUERY, | |
| 117 | decodeViewerPermissionsJson, | |
| 118 | type GitHubPullRequestActivity, | |
| 119 | type GitHubPullRequestActivityPage, | |
| 120 | type GitHubWorkflowRunPage, | |
| 121 | type GitHubPullRequestHead, | |
| 122 | type GitHubPullRequestListItem, | |
| 123 | type GitHubPullRequestSearchItem, | |
| 124 | type GitHubPullRequestStack, | |
| 125 | type GitHubReviewThreadComments, | |
| 126 | type GitHubRepositoryAccess, | |
| 127 | type GitHubWorkflowRunApproval, | |
| 128 | type GitHubReviewThreadEntry, | |
| 129 | type GitHubReviewThreadPage, | |
| 130 | type GitHubViewerAccess, | |
| 131 | } from "./gitHubPullRequestJson.ts"; | |
| 132 | import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullRequestProvider.ts"; | |
| 133 | ||
| 134 | /** | |
| 135 | * Names the read that produced unusable output, so a failure reports the call it came from | |
| 136 | * rather than borrowing another operation's message. | |
| 137 | */ | |
| 138 | export class GitHubPullRequestReadError extends Schema.TaggedError<GitHubPullRequestReadError>()( | |
| 139 | "GitHubPullRequestReadError", | |
| 140 | { | |
| 141 | cwd: Schema.String, | |
| 142 | operation: Schema.String, | |
| 143 | cause: Schema.Defect(), | |
| 144 | }, | |
| 145 | ) { | |
| 146 | override get message(): string { | |
| 147 | return `GitHub returned an unreadable ${this.operation} response.`; | |
| 148 | } | |
| 149 | } | |
| 150 | ||
| 151 | /** Not a decode failure: GitHub answered, the account it answered for just has no login. */ | |
| 152 | export class GitHubViewerLoginUnavailableError extends Schema.TaggedError<GitHubViewerLoginUnavailableError>()( | |
| 153 | "GitHubViewerLoginUnavailableError", | |
| 154 | { | |
| 155 | cwd: Schema.String, | |
| 156 | }, | |
| 157 | ) { | |
| 158 | override get message(): string { | |
| 159 | return "GitHub returned no login for the authenticated account."; | |
| 160 | } | |
| 161 | } | |
| 162 | ||
| 163 | /** Not a decode failure: GitHub answered, but the pull request carried no update time. */ | |
| 164 | export class GitHubPullRequestUpdatedAtUnavailableError extends Schema.TaggedError<GitHubPullRequestUpdatedAtUnavailableError>()( | |
| 165 | "GitHubPullRequestUpdatedAtUnavailableError", | |
| 166 | { | |
| 167 | cwd: Schema.String, | |
| 168 | repository: Schema.String, | |
| 169 | number: Schema.Int, | |
| 170 | }, | |
| 171 | ) { | |
| 172 | override get message(): string { | |
| 173 | return `Pull request ${this.repository}#${this.number} reported no update time.`; | |
| 174 | } | |
| 175 | } | |
| 176 | ||
| 177 | /** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */ | |
| 178 | export class GitHubDiffCursorError extends Schema.TaggedError<GitHubDiffCursorError>()( | |
| 179 | "GitHubDiffCursorError", | |
| 180 | { | |
| 181 | cwd: Schema.String, | |
| 182 | }, | |
| 183 | ) { | |
| 184 | override get message(): string { | |
| 185 | return "The diff cursor was not one this pull request handed out."; | |
| 186 | } | |
| 187 | } | |
| 188 | ||
| 189 | /** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */ | |
| 190 | export class GitHubDiffCommitError extends Schema.TaggedError<GitHubDiffCommitError>()( | |
| 191 | "GitHubDiffCommitError", | |
| 192 | { | |
| 193 | cwd: Schema.String, | |
| 194 | }, | |
| 195 | ) { | |
| 196 | override get message(): string { | |
| 197 | return "The named commit was not a commit sha."; | |
| 198 | } | |
| 199 | } | |
| 200 | ||
| 201 | /** The revisions read successfully, but cannot name both sides this file needs. */ | |
| 202 | export class GitHubDiffRevisionsUnavailableError extends Schema.TaggedError<GitHubDiffRevisionsUnavailableError>()( | |
| 203 | "GitHubDiffRevisionsUnavailableError", | |
| 204 | { | |
| 205 | cwd: Schema.String, | |
| 206 | number: Schema.Int, | |
| 207 | commit: Schema.optional(Schema.String), | |
| 208 | }, | |
| 209 | ) { | |
| 210 | override get message(): string { | |
| 211 | return this.commit === undefined | |
| 212 | ? `Pull request #${this.number} reported no usable base and head revisions.` | |
| 213 | : `Commit ${this.commit} reported no usable revisions for this file.`; | |
| 214 | } | |
| 215 | } | |
| 216 | ||
| 217 | /** A blob exists, but expanding it would be unsafe or would not produce text. */ | |
| 218 | export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedError<GitHubDiffFileContentsUnavailableError>()( | |
| 219 | "GitHubDiffFileContentsUnavailableError", | |
| 220 | { | |
| 221 | cwd: Schema.String, | |
| 222 | path: Schema.String, | |
| 223 | reason: Schema.Literals(["oversized", "binary"]), | |
| 224 | }, | |
| 225 | ) { | |
| 226 | override get message(): string { | |
| 227 | return this.reason === "oversized" | |
| 228 | ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.` | |
| 229 | : `The diff file '${this.path}' is binary.`; | |
| 230 | } | |
| 231 | } | |
| 232 | ||
| 233 | /** | |
| 234 | * Not a decode failure: a repository was named that cannot go into a search or into a GraphQL | |
| 235 | * document as itself. Every qualifier and every alias below is composed from `owner/name`, so a | |
| 236 | * name that is not one is refused here rather than escaped into something GitHub might read as a | |
| 237 | * qualifier of its own. | |
| 238 | */ | |
| 239 | export class GitHubRepositorySelectorError extends Schema.TaggedError<GitHubRepositorySelectorError>()( | |
| 240 | "GitHubRepositorySelectorError", | |
| 241 | { | |
| 242 | cwd: Schema.String, | |
| 243 | operation: Schema.String, | |
| 244 | }, | |
| 245 | ) { | |
| 246 | override get message(): string { | |
| 247 | return "A repository was named that GitHub cannot address."; | |
| 248 | } | |
| 249 | } | |
| 250 | ||
| 251 | /** Not a decode failure: the reader named a subject this pull request never handed out. */ | |
| 252 | export class GitHubSubjectScopeError extends Schema.TaggedError<GitHubSubjectScopeError>()( | |
| 253 | "GitHubSubjectScopeError", | |
| 254 | { | |
| 255 | cwd: Schema.String, | |
| 256 | operation: Schema.String, | |
| 257 | }, | |
| 258 | ) { | |
| 259 | override get message(): string { | |
| 260 | return "The named subject did not belong to the named pull request."; | |
| 261 | } | |
| 262 | } | |
| 263 | ||
| 264 | /** GitHub answered successfully, but approving every returned workflow would be unsafe. */ | |
| 265 | export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError<GitHubWorkflowApprovalRefusedError>()( | |
| 266 | "GitHubWorkflowApprovalRefusedError", | |
| 267 | { | |
| 268 | cwd: Schema.String, | |
| 269 | number: Schema.Int, | |
| 270 | reason: Schema.Literals(["head-list-truncated", "head-not-unique", "run-list-truncated"]), | |
| 271 | observedCount: Schema.Int, | |
| 272 | limit: Schema.Int, | |
| 273 | }, | |
| 274 | ) { | |
| 275 | override get message(): string { | |
| 276 | if (this.reason === "head-list-truncated") { | |
| 277 | return `GitHub returned more than ${this.limit} pull requests for this head branch.`; | |
| 278 | } | |
| 279 | if (this.reason === "head-not-unique") { | |
| 280 | return `The head revision matched ${this.observedCount} pull requests instead of uniquely matching #${this.number}.`; | |
| 281 | } | |
| 282 | return `GitHub returned more than ${this.limit} workflow runs awaiting approval.`; | |
| 283 | } | |
| 284 | } | |
| 285 | ||
| 286 | /** GitHub omitted the immutable head identity needed to scope an approval safely. */ | |
| 287 | export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedError<GitHubWorkflowApprovalHeadUnavailableError>()( | |
| 288 | "GitHubWorkflowApprovalHeadUnavailableError", | |
| 289 | { | |
| 290 | cwd: Schema.String, | |
| 291 | number: Schema.Int, | |
| 292 | }, | |
| 293 | ) { | |
| 294 | override get message(): string { | |
| 295 | return `GitHub did not report a complete head revision for #${this.number}.`; | |
| 296 | } | |
| 297 | } | |
| 298 | ||
| 299 | /** The pull request moved after its approval candidates were read. */ | |
| 300 | export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError<GitHubWorkflowApprovalHeadChangedError>()( | |
| 301 | "GitHubWorkflowApprovalHeadChangedError", | |
| 302 | { | |
| 303 | cwd: Schema.String, | |
| 304 | number: Schema.Int, | |
| 305 | }, | |
| 306 | ) { | |
| 307 | override get message(): string { | |
| 308 | return `The head revision of #${this.number} changed before its workflows could be approved.`; | |
| 309 | } | |
| 310 | } | |
| 311 | ||
| 312 | export type GitHubPullRequestApiError = | |
| 313 | | GitHubStackActionError | |
| 314 | | GitHubApi.GitHubApiError | |
| 315 | | GitHubPullRequestReadError | |
| 316 | | GitHubDiffCursorError | |
| 317 | | GitHubDiffCommitError | |
| 318 | | GitHubDiffRevisionsUnavailableError | |
| 319 | | GitHubDiffFileContentsUnavailableError | |
| 320 | | GitHubRepositorySelectorError | |
| 321 | | GitHubSubjectScopeError | |
| 322 | | GitHubWorkflowApprovalRefusedError | |
| 323 | | GitHubWorkflowApprovalHeadUnavailableError | |
| 324 | | GitHubWorkflowApprovalHeadChangedError | |
| 325 | | SourceControlRateLimit.SourceControlRateLimitPausedError | |
| 326 | | GitHubViewerLoginUnavailableError | |
| 327 | | GitHubPullRequestUpdatedAtUnavailableError; | |
| 328 | ||
| 329 | /** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ | |
| 330 | const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; | |
| 331 | const DIFF_TIMEOUT = Duration.seconds(60); | |
| 332 | /** Pierre expansion is for source files, not blobs large enough to stall a review surface. */ | |
| 333 | const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; | |
| 334 | ||
| 335 | /** What `gh pr list --state` asked a repository's own list for, without search. */ | |
| 336 | const LIST_STATES: Record<PullRequestListState, ReadonlyArray<"OPEN" | "CLOSED" | "MERGED">> = { | |
| 337 | all: ["OPEN", "CLOSED", "MERGED"], | |
| 338 | open: ["OPEN"], | |
| 339 | // Closed includes merged here, the way GitHub's own list reads it; merged rows are filtered out | |
| 340 | // locally by `matchesUnsortedListing`. | |
| 341 | closed: ["CLOSED", "MERGED"], | |
| 342 | merged: ["MERGED"], | |
| 343 | }; | |
| 344 | ||
| 345 | interface GitHubPullRequestListPage { | |
| 346 | readonly items: ReadonlyArray<GitHubPullRequestListItem>; | |
| 347 | readonly rawCount: number; | |
| 348 | readonly endCursor: string | null; | |
| 349 | } | |
| 350 | ||
| 351 | /** A search-free fallback may scan older rows for local filters, but never the whole repository. */ | |
| 352 | const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; | |
| 353 | ||
| 354 | /** What the files API serves at most in one response, which is what one slice is made of. */ | |
| 355 | const DIFF_FILES_PAGE_SIZE = 100; | |
| 356 | /** | |
| 357 | * How many hundred-file pages of viewed state one read will walk. A point of the hourly GraphQL | |
| 358 | * budget per page, against a change request nobody reviews in one sitting past the first few | |
| 359 | * hundred files: beyond this the read stops and says it was cut short. | |
| 360 | */ | |
| 361 | const FILES_VIEWED_MAX_PAGES = 5; | |
| 362 | ||
| 363 | /** | |
| 364 | * How many pull requests' node ids are remembered at once. A long-lived server sees far more of | |
| 365 | * them than a reader ever has open, and least recently used rather than first in: a listing | |
| 366 | * walking cold pull requests must not evict the review being ticked through. | |
| 367 | */ | |
| 368 | export const NODE_ID_CACHE_CAPACITY = 128; | |
| 369 | ||
| 370 | /** | |
| 371 | * Pages of review threads to follow before the conversation is reported as truncated. GitHub | |
| 372 | * serves a hundred threads a page, so this is a thousand threads — past anything a pull request | |
| 373 | * a person is reading has, and short of walking a repository-sized conversation forever. | |
| 374 | */ | |
| 375 | const REVIEW_THREAD_PAGES = 10; | |
| 376 | ||
| 377 | /** Pages of a hundred check contexts to walk for one head before the rollup says it was cut. */ | |
| 378 | const CHECK_CONTEXT_PAGES = 10; | |
| 379 | ||
| 380 | export interface GitHubPullRequestListBatch { | |
| 381 | readonly items: ReadonlyArray<GitHubPullRequestListItem>; | |
| 382 | readonly truncated: boolean; | |
| 383 | /** False for a page GitHub would not search, which came back in `gh`'s own order instead. */ | |
| 384 | readonly continues: boolean; | |
| 385 | } | |
| 386 | ||
| 387 | export interface GitHubPullRequestStat { | |
| 388 | readonly repository: string; | |
| 389 | readonly number: number; | |
| 390 | readonly additions: number; | |
| 391 | readonly deletions: number; | |
| 392 | } | |
| 393 | ||
| 394 | /** | |
| 395 | * Aliased lookups per request, and requests at once. Measured over a hundred rows: one request | |
| 396 | * carrying all hundred takes ~5.2s, four of twenty-five in parallel ~2.1s. | |
| 397 | */ | |
| 398 | const STAT_ALIASES_PER_REQUEST = 25; | |
| 399 | const STAT_REQUEST_CONCURRENCY = 4; | |
| 400 | /** | |
| 401 | * How long a summary read waits for company. The background sync asks for every linked pull | |
| 402 | * request at once, and each read reaches the resolver after its own cache check, so a batch | |
| 403 | * needs a moment longer than one scheduler tick to gather them. | |
| 404 | */ | |
| 405 | const SUMMARY_BATCH_WINDOW = "10 millis"; | |
| 406 | ||
| 407 | class PullRequestSummaryRead extends Request.Class< | |
| 408 | { | |
| 409 | readonly cwd: string; | |
| 410 | readonly repository: string; | |
| 411 | readonly host: string; | |
| 412 | readonly number: number; | |
| 413 | }, | |
| 414 | ProviderChangeRequestSummary, | |
| 415 | GitHubPullRequestApiError | |
| 416 | > {} | |
| 417 | ||
| 418 | class PullRequestWatchFingerprintRead extends Request.Class< | |
| 419 | { | |
| 420 | readonly cwd: string; | |
| 421 | readonly repository: string; | |
| 422 | readonly host: string; | |
| 423 | readonly number: number; | |
| 424 | }, | |
| 425 | GitHubPullRequestWatchFingerprint | null, | |
| 426 | GitHubPullRequestApiError | |
| 427 | > {} | |
| 428 | ||
| 429 | export interface GitHubPullRequestSearchBatch { | |
| 430 | /** Rows across every repository asked for, newest update first, each naming its own. */ | |
| 431 | readonly items: ReadonlyArray<GitHubPullRequestSearchItem>; | |
| 432 | readonly truncated: boolean; | |
| 433 | } | |
| 434 | ||
| 435 | export interface GitHubPullRequestDiffSlice { | |
| 436 | readonly patch: string; | |
| 437 | /** Files in this slice had their hunks withheld, as opposed to there being more slices. */ | |
| 438 | readonly truncated: boolean; | |
| 439 | /** Where the next slice starts, or null once the patch is whole. */ | |
| 440 | readonly nextCursor: string | null; | |
| 441 | /** GitHub's own counts for the files whose hunks it withheld from this slice. */ | |
| 442 | readonly omittedFileStats?: ReadonlyArray<PullRequestOmittedFileStat>; | |
| 443 | } | |
| 444 | ||
| 445 | export interface GitHubPullRequestFilesViewed { | |
| 446 | readonly files: ReadonlyArray<PullRequestFileViewed>; | |
| 447 | /** GitHub had more files than the page budget below would read. */ | |
| 448 | readonly truncated: boolean; | |
| 449 | } | |
| 450 | ||
| 451 | export class GitHubPullRequestApi extends Context.Service< | |
| 452 | GitHubPullRequestApi, | |
| 453 | { | |
| 454 | readonly withVerifiedCredential: <A, E, R>( | |
| 455 | input: { readonly cwd: string; readonly host: string }, | |
| 456 | use: (identity: { | |
| 457 | readonly accountId: string; | |
| 458 | readonly viewer: string; | |
| 459 | readonly credentialFingerprint: string; | |
| 460 | }) => Effect.Effect<A, E, R>, | |
| 461 | ) => Effect.Effect<A, E | GitHubPullRequestApiError, R>; | |
| 462 | readonly getRoutingIdentity: (input: { | |
| 463 | readonly cwd: string; | |
| 464 | readonly host: string; | |
| 465 | }) => Effect.Effect< | |
| 466 | { readonly accountId: string; readonly viewer: string }, | |
| 467 | GitHubPullRequestApiError | |
| 468 | >; | |
| 469 | readonly getViewerLogin: (input: { | |
| 470 | readonly cwd: string; | |
| 471 | readonly host: string; | |
| 472 | }) => Effect.Effect<string, GitHubPullRequestApiError>; | |
| 473 | ||
| 474 | readonly listPullRequests: (input: { | |
| 475 | readonly cwd: string; | |
| 476 | readonly repository: string; | |
| 477 | readonly host: string; | |
| 478 | readonly state: PullRequestListState; | |
| 479 | readonly involvement: PullRequestInvolvement; | |
| 480 | readonly viewer: string; | |
| 481 | readonly limit: number; | |
| 482 | /** Free text for `--search`, matched as one literal phrase. */ | |
| 483 | readonly query?: string | undefined; | |
| 484 | /** Where to carry on from, as a `updated:` qualifier on the same search. */ | |
| 485 | readonly cursor?: ProviderListCursor | undefined; | |
| 486 | /** Further narrowings, as qualifiers on the search and as a local pass on the fallback. */ | |
| 487 | readonly filters?: PullRequestListFilters | undefined; | |
| 488 | }) => Effect.Effect<GitHubPullRequestListBatch, GitHubPullRequestApiError>; | |
| 489 | ||
| 490 | /** | |
| 491 | * The same listing for a whole host in one search. `limit` is the size of the slice across | |
| 492 | * all of the repositories rather than per repository, because that is what a search answers: | |
| 493 | * the newest rows of the lot, which is exactly the page. | |
| 494 | */ | |
| 495 | readonly searchPullRequests: (input: { | |
| 496 | /** Any checkout on the host; the search names its repositories itself. */ | |
| 497 | readonly cwd: string; | |
| 498 | readonly host: string; | |
| 499 | readonly repositories: ReadonlyArray<string>; | |
| 500 | readonly state: PullRequestListState; | |
| 501 | readonly involvement: PullRequestInvolvement; | |
| 502 | readonly viewer: string; | |
| 503 | readonly limit: number; | |
| 504 | readonly query?: string | undefined; | |
| 505 | readonly cursor?: ProviderListCursor | undefined; | |
| 506 | readonly filters?: PullRequestListFilters | undefined; | |
| 507 | }) => Effect.Effect<GitHubPullRequestSearchBatch, GitHubPullRequestApiError>; | |
| 508 | ||
| 509 | /** The line counts the search leaves out, for rows already on the page. */ | |
| 510 | readonly listPullRequestStats: (input: { | |
| 511 | readonly cwd: string; | |
| 512 | readonly host: string; | |
| 513 | readonly changeRequests: ReadonlyArray<{ | |
| 514 | readonly repository: string; | |
| 515 | readonly number: number; | |
| 516 | }>; | |
| 517 | }) => Effect.Effect<ReadonlyArray<GitHubPullRequestStat>, GitHubPullRequestApiError>; | |
| 518 | ||
| 519 | readonly getPullRequestSummary: (input: { | |
| 520 | readonly cwd: string; | |
| 521 | readonly repository: string; | |
| 522 | readonly host: string; | |
| 523 | readonly number: number; | |
| 524 | }) => Effect.Effect<ProviderChangeRequestSummary, GitHubPullRequestApiError>; | |
| 525 | /** | |
| 526 | * What a watch compares between passes, batched like summaries. Null when GitHub gave no | |
| 527 | * answer for this pull request, so the watch reads it in full instead. | |
| 528 | */ | |
| 529 | readonly getPullRequestWatchFingerprint: (input: { | |
| 530 | readonly cwd: string; | |
| 531 | readonly repository: string; | |
| 532 | readonly host: string; | |
| 533 | readonly number: number; | |
| 534 | }) => Effect.Effect<GitHubPullRequestWatchFingerprint | null, GitHubPullRequestApiError>; | |
| 535 | ||
| 536 | readonly revalidateChecks: Effect.Success<typeof makeChecksRevalidator>; | |
| 537 | ||
| 538 | readonly getPullRequestDetail: (input: { | |
| 539 | readonly cwd: string; | |
| 540 | readonly repository: string; | |
| 541 | readonly host: string; | |
| 542 | readonly number: number; | |
| 543 | }) => Effect.Effect<GitHubPullRequestCore, GitHubPullRequestApiError>; | |
| 544 | ||
| 545 | readonly getPullRequestPreview: (input: { | |
| 546 | readonly cwd: string; | |
| 547 | readonly repository: string; | |
| 548 | readonly host: string; | |
| 549 | readonly number: number; | |
| 550 | }) => Effect.Effect< | |
| 551 | Omit<PullRequestPreview, "projectId" | "repository">, | |
| 552 | GitHubPullRequestApiError | |
| 553 | >; | |
| 554 | ||
| 555 | readonly listWorkflowRunsRequiringApproval: (input: { | |
| 556 | readonly cwd: string; | |
| 557 | readonly repository: string; | |
| 558 | readonly host: string; | |
| 559 | readonly number: number; | |
| 560 | readonly headSha: string; | |
| 561 | readonly headBranch: string; | |
| 562 | readonly headRepositoryOwner: string; | |
| 563 | readonly isCrossRepository: true; | |
| 564 | }) => Effect.Effect<ReadonlyArray<GitHubWorkflowRunApproval>, GitHubPullRequestApiError>; | |
| 565 | ||
| 566 | /** | |
| 567 | * The host-native stack this pull request is in, or null when it is in none — which is also | |
| 568 | * the answer for a host that refuses the stacks preview altogether. | |
| 569 | */ | |
| 570 | readonly getPullRequestStack: (input: { | |
| 571 | readonly cwd: string; | |
| 572 | readonly includeDetails?: boolean; | |
| 573 | readonly repository: string; | |
| 574 | readonly host: string; | |
| 575 | readonly number: number; | |
| 576 | }) => Effect.Effect<GitHubPullRequestStack | null, GitHubPullRequestApiError>; | |
| 577 | ||
| 578 | readonly getPullRequestActivity: (input: { | |
| 579 | readonly cwd: string; | |
| 580 | readonly repository: string; | |
| 581 | readonly host: string; | |
| 582 | readonly number: number; | |
| 583 | }) => Effect.Effect<GitHubPullRequestActivity, GitHubPullRequestApiError>; | |
| 584 | ||
| 585 | readonly getPullRequestDiff: (input: { | |
| 586 | readonly cwd: string; | |
| 587 | readonly repository: string; | |
| 588 | readonly host: string; | |
| 589 | readonly number: number; | |
| 590 | /** Absent asks for the first slice; anything else is a cursor a slice handed back. */ | |
| 591 | readonly cursor?: string | undefined; | |
| 592 | /** One commit's own changes, rather than everything the pull request carries. */ | |
| 593 | readonly commit?: string | undefined; | |
| 594 | }) => Effect.Effect<GitHubPullRequestDiffSlice, GitHubPullRequestApiError>; | |
| 595 | ||
| 596 | readonly getPullRequestDiffFileContents: (input: { | |
| 597 | readonly cwd: string; | |
| 598 | readonly repository: string; | |
| 599 | readonly host: string; | |
| 600 | readonly number: number; | |
| 601 | readonly commit?: string | undefined; | |
| 602 | readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; | |
| 603 | readonly oldPath: string; | |
| 604 | readonly newPath: string; | |
| 605 | }) => Effect.Effect< | |
| 606 | { readonly oldContents: string; readonly newContents: string }, | |
| 607 | GitHubPullRequestApiError | |
| 608 | >; | |
| 609 | ||
| 610 | /** | |
| 611 | * Which files of the pull request the signed-in account has cleared, and which of those have | |
| 612 | * been pushed to since. Read apart from the patch because GitHub only reports it over GraphQL. | |
| 613 | */ | |
| 614 | readonly getPullRequestFilesViewed: (input: { | |
| 615 | readonly cwd: string; | |
| 616 | readonly repository: string; | |
| 617 | readonly host: string; | |
| 618 | readonly number: number; | |
| 619 | }) => Effect.Effect<GitHubPullRequestFilesViewed, GitHubPullRequestApiError>; | |
| 620 | ||
| 621 | /** | |
| 622 | * Clears files, or puts them back, as one request. GitHub takes a single path per mutation, | |
| 623 | * so a burst is batched with aliases into one document rather than one subprocess per press. | |
| 624 | */ | |
| 625 | readonly setPullRequestFilesViewed: (input: { | |
| 626 | readonly cwd: string; | |
| 627 | readonly repository: string; | |
| 628 | readonly host: string; | |
| 629 | readonly number: number; | |
| 630 | readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>; | |
| 631 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 632 | ||
| 633 | readonly listReviewThreadComments: (input: { | |
| 634 | readonly cwd: string; | |
| 635 | readonly repository: string; | |
| 636 | readonly host: string; | |
| 637 | readonly number: number; | |
| 638 | }) => Effect.Effect<GitHubReviewThreadComments, GitHubPullRequestApiError>; | |
| 639 | ||
| 640 | /** One request for a listing's authors, since no `gh` JSON field reports an avatar. */ | |
| 641 | readonly listActorAvatars: (input: { | |
| 642 | readonly cwd: string; | |
| 643 | readonly repository: string; | |
| 644 | readonly host: string; | |
| 645 | readonly ids: ReadonlyArray<string>; | |
| 646 | }) => Effect.Effect<ReadonlyMap<string, string>, GitHubPullRequestApiError>; | |
| 647 | ||
| 648 | readonly getReviewThreadComments: (input: { | |
| 649 | readonly cwd: string; | |
| 650 | readonly repository: string; | |
| 651 | readonly host: string; | |
| 652 | readonly number: number; | |
| 653 | readonly threadId: string; | |
| 654 | readonly cursor: string; | |
| 655 | }) => Effect.Effect<PullRequestThreadCommentsResult, GitHubPullRequestApiError>; | |
| 656 | ||
| 657 | /** The viewer's standing on its own, for deciding a write without reading the whole detail. */ | |
| 658 | readonly getViewerAccess: (input: { | |
| 659 | readonly cwd: string; | |
| 660 | readonly repository: string; | |
| 661 | readonly host: string; | |
| 662 | readonly number: number; | |
| 663 | /** Manual action checks may use the quota held back from automatic reads. */ | |
| 664 | readonly allowReserve?: boolean | undefined; | |
| 665 | }) => Effect.Effect<GitHubViewerAccess & GitHubRepositoryAccess, GitHubPullRequestApiError>; | |
| 666 | ||
| 667 | /** Who this pull request may be sent to, and who it has already been sent to. */ | |
| 668 | readonly listReviewerCandidates: (input: { | |
| 669 | readonly cwd: string; | |
| 670 | readonly repository: string; | |
| 671 | readonly host: string; | |
| 672 | readonly number: number; | |
| 673 | }) => Effect.Effect<PullRequestReviewerCandidateList, GitHubPullRequestApiError>; | |
| 674 | ||
| 675 | readonly setReviewerRequest: (input: { | |
| 676 | readonly cwd: string; | |
| 677 | readonly repository: string; | |
| 678 | readonly host: string; | |
| 679 | readonly number: number; | |
| 680 | readonly reviewers: ReadonlyArray<{ | |
| 681 | readonly id: string; | |
| 682 | readonly kind: PullRequestReviewerKind; | |
| 683 | }>; | |
| 684 | /** False deletes the same collection a request posts to, which takes the request back. */ | |
| 685 | readonly requested: boolean; | |
| 686 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 687 | ||
| 688 | /** The repository's labels, and which of them this pull request already wears. */ | |
| 689 | readonly listLabelCandidates: (input: { | |
| 690 | readonly cwd: string; | |
| 691 | readonly repository: string; | |
| 692 | readonly host: string; | |
| 693 | readonly number: number; | |
| 694 | }) => Effect.Effect<PullRequestLabelCandidateList, GitHubPullRequestApiError>; | |
| 695 | ||
| 696 | readonly setLabels: (input: { | |
| 697 | readonly cwd: string; | |
| 698 | readonly repository: string; | |
| 699 | readonly host: string; | |
| 700 | readonly number: number; | |
| 701 | readonly labels: ReadonlyArray<string>; | |
| 702 | /** False takes each label off; true adds each to whatever is already there. */ | |
| 703 | readonly applied: boolean; | |
| 704 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 705 | ||
| 706 | readonly runPullRequestAction: (input: { | |
| 707 | readonly cwd: string; | |
| 708 | readonly repository: string; | |
| 709 | readonly host: string; | |
| 710 | readonly number: number; | |
| 711 | readonly action: PullRequestAction; | |
| 712 | readonly stackNumber?: number; | |
| 713 | readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>; | |
| 714 | readonly removeAgentCreditsOnMerge?: boolean; | |
| 715 | readonly mergeMethod?: PullRequestMergeMethod; | |
| 716 | readonly updateMethod?: PullRequestUpdateMethod; | |
| 717 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 718 | ||
| 719 | readonly commentOnPullRequest: (input: { | |
| 720 | readonly cwd: string; | |
| 721 | readonly repository: string; | |
| 722 | readonly host: string; | |
| 723 | readonly number: number; | |
| 724 | readonly body: string; | |
| 725 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 726 | ||
| 727 | readonly submitReview: (input: { | |
| 728 | readonly cwd: string; | |
| 729 | readonly repository: string; | |
| 730 | readonly host: string; | |
| 731 | readonly number: number; | |
| 732 | readonly verdict: PullRequestReviewVerdict; | |
| 733 | readonly body: string; | |
| 734 | readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>; | |
| 735 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 736 | ||
| 737 | readonly replyToReviewThread: (input: { | |
| 738 | readonly cwd: string; | |
| 739 | readonly repository: string; | |
| 740 | readonly host: string; | |
| 741 | readonly threadId: string; | |
| 742 | readonly body: string; | |
| 743 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 744 | ||
| 745 | readonly setReviewThreadResolution: (input: { | |
| 746 | readonly cwd: string; | |
| 747 | readonly repository: string; | |
| 748 | readonly host: string; | |
| 749 | readonly threadId: string; | |
| 750 | readonly resolved: boolean; | |
| 751 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 752 | ||
| 753 | /** | |
| 754 | * Adds a reaction to a remark, or takes it back. `subjectId` is any node GitHub calls | |
| 755 | * reactable — a comment, a review, or the pull request itself, which is looked up here | |
| 756 | * because nothing in the conversation names it. A given `subjectId` is confirmed to belong | |
| 757 | * to this pull request before the mutation runs, since nothing else ties the two together. | |
| 758 | */ | |
| 759 | readonly setReaction: (input: { | |
| 760 | readonly cwd: string; | |
| 761 | readonly repository: string; | |
| 762 | readonly host: string; | |
| 763 | readonly number: number; | |
| 764 | readonly subjectId?: string | undefined; | |
| 765 | readonly content: PullRequestReactionContent; | |
| 766 | readonly reacted: boolean; | |
| 767 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 768 | ||
| 769 | /** Rewrites the pull request's own words, leaving whichever of the two was not given. */ | |
| 770 | readonly updatePullRequest: (input: { | |
| 771 | readonly cwd: string; | |
| 772 | readonly repository: string; | |
| 773 | readonly host: string; | |
| 774 | readonly number: number; | |
| 775 | readonly title?: string | undefined; | |
| 776 | readonly body?: string | undefined; | |
| 777 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 778 | ||
| 779 | /** | |
| 780 | * Rewrites a remark. `commentId` is trusted to be whatever node it names, so it is confirmed | |
| 781 | * to belong to this pull request before the mutation runs, the way a reaction subject is. | |
| 782 | * Whether the remark is the reader's to rewrite is GitHub's own answer, not one asked here. | |
| 783 | */ | |
| 784 | readonly updateComment: (input: { | |
| 785 | readonly cwd: string; | |
| 786 | readonly repository: string; | |
| 787 | readonly host: string; | |
| 788 | readonly number: number; | |
| 789 | readonly commentId: string; | |
| 790 | readonly kind: "issue-comment" | "review-comment"; | |
| 791 | readonly body: string; | |
| 792 | }) => Effect.Effect<void, GitHubPullRequestApiError>; | |
| 793 | } | |
| 794 | >()("t3/pullRequest/GitHubPullRequestApi") {} | |
| 795 | ||
| 796 | /** | |
| 797 | * The GraphQL API takes owner and name as separate arguments, so `owner/repo` is split here. | |
| 798 | * The host is not read off the identity: it travels alongside it, because the identity a | |
| 799 | * project records is the path below its host and never names the host itself. | |
| 800 | */ | |
| 801 | function parseRepositorySelector(value: string): { | |
| 802 | readonly owner: string; | |
| 803 | readonly name: string; | |
| 804 | } { | |
| 805 | const parts = value.trim().split("/").filter(Boolean); | |
| 806 | return { name: parts.at(-1) ?? "", owner: parts.at(-2) ?? "" }; | |
| 807 | } | |
| 808 | ||
| 809 | /** | |
| 810 | * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor | |
| 811 | * arrives from the reader as a string and goes straight into a request path, so it is parsed | |
| 812 | * rather than trusted; the length bound keeps a page number out of exponential notation. | |
| 813 | */ | |
| 814 | function diffCursorPage(cursor: string): number | null { | |
| 815 | return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null; | |
| 816 | } | |
| 817 | ||
| 818 | /** | |
| 819 | * A commit sha arrives from the reader and goes straight into a request path, so it is checked | |
| 820 | * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a | |
| 821 | * whole sha. | |
| 822 | */ | |
| 823 | function isCommitSha(value: string): boolean { | |
| 824 | return /^[0-9a-f]{7,64}$/i.test(value); | |
| 825 | } | |
| 826 | ||
| 827 | /** | |
| 828 | * The reader's own words as one literal phrase of a GitHub search query. Quoting is the whole | |
| 829 | * defence: outside quotes GitHub reads `is:merged` as a qualifier and `label:x` as another, so | |
| 830 | * text typed into a search box could widen the very listing it is meant to narrow — inside them | |
| 831 | * it is only text. The two characters that could end the phrase early are therefore escaped | |
| 832 | * first, which GitHub reads back as themselves; an unbalanced quote is dropped instead, which | |
| 833 | * would let everything after it out of the phrase. | |
| 834 | * | |
| 835 | * The phrase is one argv element, so nothing in it can become a flag of its own either. | |
| 836 | */ | |
| 837 | function searchPhrase(query: string): string { | |
| 838 | return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; | |
| 839 | } | |
| 840 | ||
| 841 | /** GitHub's own spelling of a review state, which is not the contract's. */ | |
| 842 | const REVIEW_QUALIFIERS = { | |
| 843 | approved: "approved", | |
| 844 | "changes-requested": "changes_requested", | |
| 845 | "review-required": "required", | |
| 846 | none: "none", | |
| 847 | } as const; | |
| 848 | ||
| 849 | /** | |
| 850 | * The extra narrowings as GitHub search qualifiers. Values a reader typed are quoted, and the | |
| 851 | * one character that could end the quoted value early is dropped rather than escaped: no GitHub | |
| 852 | * label or login holds a double quote, so there is nothing to preserve and everything to lose. | |
| 853 | */ | |
| 854 | function qualifierValue(value: string): string { | |
| 855 | return `"${value.replaceAll('"', "").trim()}"`; | |
| 856 | } | |
| 857 | ||
| 858 | function filterQualifiers( | |
| 859 | filters: PullRequestListFilters | undefined, | |
| 860 | viewer: string, | |
| 861 | ): ReadonlyArray<string> { | |
| 862 | if (filters === undefined) return []; | |
| 863 | return [ | |
| 864 | // One qualifier per group, its names joined by commas — GitHub's own OR. | |
| 865 | ...(filters.labels ?? []).flatMap((group) => | |
| 866 | group.length === 0 ? [] : [`label:${group.map(qualifierValue).join(",")}`], | |
| 867 | ), | |
| 868 | ...(filters.excludedLabels ?? []).map((label) => `-label:${qualifierValue(label)}`), | |
| 869 | ...(filters.author === undefined | |
| 870 | ? [] | |
| 871 | : [`author:${qualifierValue(resolvePullRequestAuthorFilter(filters.author, viewer))}`]), | |
| 872 | ...(filters.draft === undefined ? [] : [`draft:${filters.draft === "only"}`]), | |
| 873 | ...(filters.review === undefined ? [] : [`review:${REVIEW_QUALIFIERS[filters.review]}`]), | |
| 874 | ...(filters.checks === undefined | |
| 875 | ? [] | |
| 876 | : [`status:${filters.checks === "passing" ? "success" : "failure"}`]), | |
| 877 | ]; | |
| 878 | } | |
| 879 | ||
| 880 | /** | |
| 881 | * The same narrowings over a row that has already arrived, for the search-free fallback. Every | |
| 882 | * listed row now carries its own `checksState`, so `checks` is judged the way `review` is: by | |
| 883 | * equality against the row's field. Unlike `review`, `checks` has no `"none"` value to catch an | |
| 884 | * absent state on purpose — a row with no checks configured, or whose checks are still `pending`, | |
| 885 | * equals neither `"passing"` nor `"failing"` and so fails both, the same as a row search would | |
| 886 | * not have surfaced for `status:success` or `status:failure`. | |
| 887 | */ | |
| 888 | function matchesFilters( | |
| 889 | item: GitHubPullRequestListItem, | |
| 890 | filters: PullRequestListFilters | undefined, | |
| 891 | viewer: string, | |
| 892 | ): boolean { | |
| 893 | if (filters === undefined) return true; | |
| 894 | const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase())); | |
| 895 | const holds = (label: string) => labels.has(label.trim().toLowerCase()); | |
| 896 | return ( | |
| 897 | (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && | |
| 898 | (filters.review === undefined || | |
| 899 | (filters.review === "none" | |
| 900 | ? item.reviewDecision === null | |
| 901 | : item.reviewDecision === filters.review)) && | |
| 902 | (filters.checks === undefined || item.checksState === filters.checks) && | |
| 903 | (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) && | |
| 904 | (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) && | |
| 905 | (filters.author === undefined || | |
| 906 | item.author?.login.toLowerCase() === | |
| 907 | resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase()) | |
| 908 | ); | |
| 909 | } | |
| 910 | ||
| 911 | /** The search-free fallback is wider than the request, so narrow its decoded rows locally. */ | |
| 912 | function matchesUnsortedListing( | |
| 913 | item: GitHubPullRequestListItem, | |
| 914 | input: { | |
| 915 | readonly state: PullRequestListState; | |
| 916 | readonly involvement: PullRequestInvolvement; | |
| 917 | readonly viewer: string; | |
| 918 | readonly filters?: PullRequestListFilters | undefined; | |
| 919 | }, | |
| 920 | ): boolean { | |
| 921 | const matchesState = input.state === "all" || item.state === input.state; | |
| 922 | const viewer = input.viewer.toLowerCase(); | |
| 923 | const matchesInvolvement = | |
| 924 | input.involvement === "all" || | |
| 925 | (input.involvement === "authored" | |
| 926 | ? item.author?.login.toLowerCase() === viewer | |
| 927 | : item.hasTeamReviewRequest || | |
| 928 | item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer)); | |
| 929 | return matchesState && matchesInvolvement && matchesFilters(item, input.filters, input.viewer); | |
| 930 | } | |
| 931 | ||
| 932 | /** What a repository selector may hold before it goes into a search as itself. */ | |
| 933 | const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; | |
| 934 | ||
| 935 | /** | |
| 936 | * A listing as one GitHub search, across one repository or several: the only way to read a | |
| 937 | * whole host in one request, and the only order (`updated`) a continuation can carry on from. | |
| 938 | * | |
| 939 | * `--state closed` in GitHub's own list includes merged pull requests, so the Closed tab is | |
| 940 | * `is:closed is:unmerged` here. | |
| 941 | * | |
| 942 | * Null where a repository is not `owner/name`. A name is written into the query as itself, and a | |
| 943 | * name holding a space could otherwise end the `repo:` qualifier and start a qualifier of its | |
| 944 | * own — so an unaddressable one refuses the whole read rather than being escaped into something | |
| 945 | * GitHub might still read. | |
| 946 | */ | |
| 947 | function searchQuery(input: { | |
| 948 | readonly repositories: ReadonlyArray<string>; | |
| 949 | readonly state: PullRequestListState; | |
| 950 | readonly involvement: PullRequestInvolvement; | |
| 951 | readonly viewer: string; | |
| 952 | readonly query?: string | undefined; | |
| 953 | readonly cursor?: ProviderListCursor | undefined; | |
| 954 | readonly filters?: PullRequestListFilters | undefined; | |
| 955 | }): string | null { | |
| 956 | if (input.repositories.length === 0) return null; | |
| 957 | const repositories = input.repositories.map((repository) => repository.trim()); | |
| 958 | if (!repositories.every((repository) => SEARCH_REPOSITORY.test(repository))) return null; | |
| 959 | const query = input.query?.trim() ?? ""; | |
| 960 | return [ | |
| 961 | "is:pr", | |
| 962 | // "all" is every state, which `is:pr` already is. | |
| 963 | ...(input.state === "open" ? ["is:open"] : []), | |
| 964 | ...(input.state === "closed" ? ["is:closed", "is:unmerged"] : []), | |
| 965 | ...(input.state === "merged" ? ["is:merged"] : []), | |
| 966 | ...(input.involvement === "authored" ? [`author:${input.viewer}`] : []), | |
| 967 | ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []), | |
| 968 | ...(query.length === 0 ? [] : [searchPhrase(query)]), | |
| 969 | // Inclusive, and de-duplicated by the caller, for the reason the per-repository read gives. | |
| 970 | ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]), | |
| 971 | ...filterQualifiers(input.filters, input.viewer), | |
| 972 | // The order the page reads its rows in, and the only order a continuation can carry on from. | |
| 973 | "sort:updated-desc", | |
| 974 | ...repositories.map((repository) => `repo:${repository}`), | |
| 975 | ].join(" "); | |
| 976 | } | |
| 977 | ||
| 978 | const MERGE_MESSAGE_GRAPHQL_QUERY = ` | |
| 979 | query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) { | |
| 980 | repository(owner: $owner, name: $name) { | |
| 981 | pullRequest(number: $number) { | |
| 982 | isMergeQueueEnabled | |
| 983 | headRefOid | |
| 984 | viewerMergeBodyText(mergeType: $method) | |
| 985 | } | |
| 986 | } | |
| 987 | }`; | |
| 988 | ||
| 989 | /** The two revisions a file is expanded between: a pull request's base and head, or a commit's parent and itself. */ | |
| 990 | const decodeRevisionRefs = Schema.decodeUnknownOption( | |
| 991 | Schema.fromJsonString( | |
| 992 | Schema.Struct({ | |
| 993 | sha: Schema.optional(Schema.String), | |
| 994 | parents: Schema.optional(Schema.Array(Schema.Struct({ sha: Schema.String }))), | |
| 995 | base: Schema.optional(Schema.Struct({ sha: Schema.String })), | |
| 996 | head: Schema.optional(Schema.Struct({ sha: Schema.String })), | |
| 997 | }), | |
| 998 | ), | |
| 999 | ); | |
| 1000 | ||
| 1001 | const decodeMergeMessageResponse = Schema.decodeUnknownResult( | |
| 1002 | Schema.fromJsonString( | |
| 1003 | Schema.Struct({ | |
| 1004 | data: Schema.Struct({ | |
| 1005 | repository: Schema.Struct({ | |
| 1006 | pullRequest: Schema.Struct({ | |
| 1007 | isMergeQueueEnabled: Schema.Boolean, | |
| 1008 | headRefOid: Schema.String, | |
| 1009 | viewerMergeBodyText: Schema.String, | |
| 1010 | }), | |
| 1011 | }), | |
| 1012 | }), | |
| 1013 | }), | |
| 1014 | ), | |
| 1015 | ); | |
| 1016 | const decodeMergeMessage = (raw: string) => | |
| 1017 | Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest); | |
| 1018 | ||
| 1019 | /** What `gh pr merge` and `gh pr update-branch` read before they act. */ | |
| 1020 | const ACTION_STATE_GRAPHQL_QUERY = ` | |
| 1021 | query PullRequestActionState($owner: String!, $name: String!, $number: Int!, $headRef: String!) { | |
| 1022 | repository(owner: $owner, name: $name) { | |
| 1023 | pullRequest(number: $number) { | |
| 1024 | id | |
| 1025 | headRefOid | |
| 1026 | isMergeQueueEnabled | |
| 1027 | mergeStateStatus | |
| 1028 | baseRef { compare(headRef: $headRef) { behindBy } } | |
| 1029 | } | |
| 1030 | } | |
| 1031 | }`; | |
| 1032 | ||
| 1033 | const decodeActionStateResponse = Schema.decodeUnknownResult( | |
| 1034 | Schema.fromJsonString( | |
| 1035 | Schema.Struct({ | |
| 1036 | data: Schema.Struct({ | |
| 1037 | repository: Schema.Struct({ | |
| 1038 | pullRequest: Schema.Struct({ | |
| 1039 | id: Schema.String, | |
| 1040 | headRefOid: Schema.String, | |
| 1041 | isMergeQueueEnabled: Schema.optional(Schema.Boolean), | |
| 1042 | mergeStateStatus: Schema.optional(Schema.NullOr(Schema.String)), | |
| 1043 | baseRef: Schema.optional( | |
| 1044 | Schema.NullOr( | |
| 1045 | Schema.Struct({ compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })) }), | |
| 1046 | ), | |
| 1047 | ), | |
| 1048 | }), | |
| 1049 | }), | |
| 1050 | }), | |
| 1051 | }), | |
| 1052 | ), | |
| 1053 | ); | |
| 1054 | const decodeActionState = (raw: string) => | |
| 1055 | Result.map(decodeActionStateResponse(raw), (response) => response.data.repository.pullRequest); | |
| 1056 | ||
| 1057 | const MERGE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($input: MergePullRequestInput!) { | |
| 1058 | mergePullRequest(input: $input) { clientMutationId } | |
| 1059 | }`; | |
| 1060 | const ENABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($input: EnablePullRequestAutoMergeInput!) { | |
| 1061 | enablePullRequestAutoMerge(input: $input) { clientMutationId } | |
| 1062 | }`; | |
| 1063 | const DISABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1064 | disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1065 | }`; | |
| 1066 | const UPDATE_BRANCH_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!, $updateMethod: PullRequestBranchUpdateMethod!) { | |
| 1067 | updatePullRequestBranch(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid, updateMethod: $updateMethod }) { clientMutationId } | |
| 1068 | }`; | |
| 1069 | const READY_FOR_REVIEW_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1070 | markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1071 | }`; | |
| 1072 | const CONVERT_TO_DRAFT_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1073 | convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1074 | }`; | |
| 1075 | const CLOSE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1076 | closePullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1077 | }`; | |
| 1078 | const REOPEN_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1079 | reopenPullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1080 | }`; | |
| 1081 | const ADD_COMMENT_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $body: String!) { | |
| 1082 | addComment(input: { subjectId: $subjectId, body: $body }) { clientMutationId } | |
| 1083 | }`; | |
| 1084 | ||
| 1085 | const GRAPHQL_MERGE_METHODS = { | |
| 1086 | merge: "MERGE", | |
| 1087 | squash: "SQUASH", | |
| 1088 | rebase: "REBASE", | |
| 1089 | } as const satisfies Record<PullRequestMergeMethod, string>; | |
| 1090 | ||
| 1091 | /** States in which `gh pr merge --auto` merges at once instead of arming auto-merge. */ | |
| 1092 | const IMMEDIATELY_MERGEABLE = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]); | |
| 1093 | ||
| 1094 | /** The mutations that only need the pull request's node id. */ | |
| 1095 | const SIMPLE_ACTION_MUTATIONS = { | |
| 1096 | "disable-auto-merge": DISABLE_AUTO_MERGE_GRAPHQL_MUTATION, | |
| 1097 | ready: READY_FOR_REVIEW_GRAPHQL_MUTATION, | |
| 1098 | draft: CONVERT_TO_DRAFT_GRAPHQL_MUTATION, | |
| 1099 | close: CLOSE_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 1100 | reopen: REOPEN_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 1101 | } as const satisfies Partial<Record<PullRequestAction, string>>; | |
| 1102 | ||
| 1103 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 1104 | export const make = Effect.gen(function* () { | |
| 1105 | const api = yield* GitHubApi.GitHubApi; | |
| 1106 | const vcsProcess = yield* VcsProcess.VcsProcess; | |
| 1107 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1108 | const revalidateChecks = yield* makeChecksRevalidator; | |
| 1109 | const routingIdentities = new Map< | |
| 1110 | string, | |
| 1111 | { | |
| 1112 | at: number; | |
| 1113 | value: { accountId: string; viewer: string }; | |
| 1114 | } | |
| 1115 | >(); | |
| 1116 | const identityLocks = new Map<string, { gate: Semaphore.Semaphore; users: number }>(); | |
| 1117 | const decodeRoutingIdentity = Schema.decodeUnknownEffect( | |
| 1118 | Schema.fromJsonString( | |
| 1119 | Schema.Struct({ | |
| 1120 | id: PositiveInt, | |
| 1121 | login: TrimmedNonEmptyString, | |
| 1122 | }), | |
| 1123 | ), | |
| 1124 | ); | |
| 1125 | const captureVerifiedCredential = Effect.fn("GitHubPullRequestApi.captureVerifiedCredential")( | |
| 1126 | function* (input: { readonly cwd: string; readonly host: string }) { | |
| 1127 | const unavailable = () => new GitHubViewerLoginUnavailableError({ cwd: input.cwd }); | |
| 1128 | const host = input.host.toLowerCase(); | |
| 1129 | // A missing or signed-out credential keeps its own error, so the page can say which. | |
| 1130 | const { token, fingerprint: key } = yield* api.credential(host); | |
| 1131 | const credential = { host, token, credentialFingerprint: key }; | |
| 1132 | // A cold page may ask several times. Wait per credential and check again after the | |
| 1133 | // first verification; cancellation releases the next waiter without losing its request. | |
| 1134 | return yield* Effect.acquireUseRelease( | |
| 1135 | Effect.sync(() => { | |
| 1136 | const lock = identityLocks.get(key) ?? { gate: Semaphore.makeUnsafe(1), users: 0 }; | |
| 1137 | lock.users++; | |
| 1138 | identityLocks.set(key, lock); | |
| 1139 | return lock; | |
| 1140 | }), | |
| 1141 | (lock) => | |
| 1142 | lock.gate.withPermit( | |
| 1143 | Effect.gen(function* () { | |
| 1144 | const now = yield* Clock.currentTimeMillis; | |
| 1145 | const cached = routingIdentities.get(key); | |
| 1146 | if (cached !== undefined && now - cached.at < 10 * 60_000) | |
| 1147 | return { ...credential, ...cached.value }; | |
| 1148 | // Pin this read so an auth switch cannot poison its cache entry. | |
| 1149 | const response = yield* api | |
| 1150 | .rest({ host, operation: "getRoutingIdentity", path: "user", allowReserve: true }) | |
| 1151 | .pipe( | |
| 1152 | Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), | |
| 1153 | Effect.provideService(SourceControlRateLimit.CredentialScope, key), | |
| 1154 | ); | |
| 1155 | const identity = yield* decodeRoutingIdentity(response.body).pipe( | |
| 1156 | Effect.mapError(unavailable), | |
| 1157 | ); | |
| 1158 | const value = { accountId: String(identity.id), viewer: identity.login }; | |
| 1159 | if (routingIdentities.size >= 128) | |
| 1160 | routingIdentities.delete(routingIdentities.keys().next().value!); | |
| 1161 | routingIdentities.set(key, { at: now, value }); | |
| 1162 | return { ...credential, ...value }; | |
| 1163 | }), | |
| 1164 | ), | |
| 1165 | (lock) => | |
| 1166 | Effect.sync(() => { | |
| 1167 | lock.users--; | |
| 1168 | if (lock.users === 0) identityLocks.delete(key); | |
| 1169 | }), | |
| 1170 | ); | |
| 1171 | }, | |
| 1172 | ); | |
| 1173 | const withVerifiedCredential: GitHubPullRequestApi["Service"]["withVerifiedCredential"] = ( | |
| 1174 | input, | |
| 1175 | use, | |
| 1176 | ) => | |
| 1177 | captureVerifiedCredential(input).pipe( | |
| 1178 | Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) => | |
| 1179 | use({ accountId, viewer, credentialFingerprint }).pipe( | |
| 1180 | Effect.provideService(SourceControlRateLimit.CredentialScope, credentialFingerprint), | |
| 1181 | Effect.provideService(GitHubApi.PinnedGitHubCredential, { | |
| 1182 | host, | |
| 1183 | token, | |
| 1184 | credentialFingerprint, | |
| 1185 | }), | |
| 1186 | ), | |
| 1187 | ), | |
| 1188 | ); | |
| 1189 | const getRoutingIdentity: GitHubPullRequestApi["Service"]["getRoutingIdentity"] = (input) => | |
| 1190 | captureVerifiedCredential(input).pipe( | |
| 1191 | Effect.map(({ accountId, viewer }) => ({ accountId, viewer })), | |
| 1192 | ); | |
| 1193 | ||
| 1194 | /** | |
| 1195 | * The pull request's own node id, which is what a mutation against the pull request itself is | |
| 1196 | * addressed by: a reaction on its description, or a rewrite of its words. | |
| 1197 | * | |
| 1198 | * A pull request keeps its node id for life, so it is remembered rather than re-read: a reader | |
| 1199 | * ticking files viewed would otherwise pay a GraphQL round trip per press. | |
| 1200 | */ | |
| 1201 | const nodeIds = new Map<string, string>(); | |
| 1202 | ||
| 1203 | const pullRequestNodeId = (input: { | |
| 1204 | readonly cwd: string; | |
| 1205 | readonly repository: string; | |
| 1206 | readonly host: string; | |
| 1207 | readonly number: number; | |
| 1208 | readonly operation: string; | |
| 1209 | }): Effect.Effect<string, GitHubPullRequestApiError> => { | |
| 1210 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1211 | const key = `${input.host} ${owner}/${name} ${input.number}`; | |
| 1212 | const held = nodeIds.get(key); | |
| 1213 | if (held !== undefined) { | |
| 1214 | // Put back at the end on every hit, so what falls out is the pull request nobody has looked | |
| 1215 | // at rather than the one being ticked through: a run of cold reads would otherwise evict the | |
| 1216 | // open review and make it pay a round trip per press. | |
| 1217 | nodeIds.delete(key); | |
| 1218 | nodeIds.set(key, held); | |
| 1219 | return Effect.succeed(held); | |
| 1220 | } | |
| 1221 | return graphqlRead({ | |
| 1222 | cwd: input.cwd, | |
| 1223 | host: input.host, | |
| 1224 | operation: input.operation, | |
| 1225 | allowReserve: true, | |
| 1226 | variables: { owner, name, number: input.number }, | |
| 1227 | query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, | |
| 1228 | decode: decodePullRequestNodeIdJson, | |
| 1229 | }).pipe( | |
| 1230 | Effect.tap((nodeId) => | |
| 1231 | Effect.sync(() => { | |
| 1232 | if (nodeIds.size >= NODE_ID_CACHE_CAPACITY) { | |
| 1233 | const oldest = nodeIds.keys().next().value; | |
| 1234 | if (oldest !== undefined) nodeIds.delete(oldest); | |
| 1235 | } | |
| 1236 | nodeIds.set(key, nodeId); | |
| 1237 | }), | |
| 1238 | ), | |
| 1239 | ); | |
| 1240 | }; | |
| 1241 | ||
| 1242 | /** | |
| 1243 | * Whether a client-given subject actually belongs to the pull request the request names. A | |
| 1244 | * subject id is trusted to be whatever node it names, and that node can hang off any pull | |
| 1245 | * request on the host — so the mutation itself would write wherever the id actually belongs, | |
| 1246 | * not wherever the request says it does, unless this confirms the two agree first. | |
| 1247 | */ | |
| 1248 | const subjectBelongsToPullRequest = (input: { | |
| 1249 | readonly cwd: string; | |
| 1250 | readonly repository: string; | |
| 1251 | readonly host: string; | |
| 1252 | readonly number: number; | |
| 1253 | readonly subjectId: string; | |
| 1254 | readonly operation: string; | |
| 1255 | }) => { | |
| 1256 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1257 | return graphqlRead({ | |
| 1258 | cwd: input.cwd, | |
| 1259 | host: input.host, | |
| 1260 | operation: input.operation, | |
| 1261 | allowReserve: true, | |
| 1262 | variables: { owner, name, number: input.number, subjectId: input.subjectId }, | |
| 1263 | query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, | |
| 1264 | decode: decodeReactionSubjectScopeJson, | |
| 1265 | }); | |
| 1266 | }; | |
| 1267 | ||
| 1268 | /** A GraphQL mutation whose answer is not read back; a GraphQL error fails it. */ | |
| 1269 | const graphql = (input: { | |
| 1270 | readonly host: string; | |
| 1271 | readonly operation: string; | |
| 1272 | readonly query: string; | |
| 1273 | readonly variables: Readonly<Record<string, unknown>>; | |
| 1274 | }) => api.graphql(input).pipe(Effect.asVoid); | |
| 1275 | ||
| 1276 | const readError = (cwd: string, operation: string, cause: unknown) => | |
| 1277 | new GitHubPullRequestReadError({ cwd, operation, cause }); | |
| 1278 | ||
| 1279 | /** Decodes an answer, reporting a failure against the read that made it. */ | |
| 1280 | const decodeWith = <A>( | |
| 1281 | cwd: string, | |
| 1282 | operation: string, | |
| 1283 | decode: (raw: string) => Result.Result<A, unknown>, | |
| 1284 | raw: string, | |
| 1285 | ): Effect.Effect<A, GitHubPullRequestReadError> => { | |
| 1286 | const decoded = decode(raw.trim()); | |
| 1287 | return Result.isSuccess(decoded) | |
| 1288 | ? Effect.succeed(decoded.success) | |
| 1289 | : Effect.fail(readError(cwd, operation, decoded.failure)); | |
| 1290 | }; | |
| 1291 | ||
| 1292 | /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */ | |
| 1293 | const graphqlRead = <A>(input: { | |
| 1294 | readonly cwd: string; | |
| 1295 | readonly host: string; | |
| 1296 | readonly operation: string; | |
| 1297 | readonly allowReserve?: boolean | undefined; | |
| 1298 | readonly variables?: Readonly<Record<string, unknown>>; | |
| 1299 | readonly query: string; | |
| 1300 | readonly decode: (raw: string) => Result.Result<A, unknown>; | |
| 1301 | }): Effect.Effect<A, GitHubPullRequestApiError> => | |
| 1302 | api | |
| 1303 | .graphql({ | |
| 1304 | host: input.host, | |
| 1305 | operation: input.operation, | |
| 1306 | query: input.query, | |
| 1307 | ...(input.variables === undefined ? {} : { variables: input.variables }), | |
| 1308 | ...(input.allowReserve === true ? { allowReserve: true } : {}), | |
| 1309 | }) | |
| 1310 | .pipe(Effect.flatMap((raw) => decodeWith(input.cwd, input.operation, input.decode, raw))); | |
| 1311 | ||
| 1312 | /** A REST read whose JSON answer is decoded the same way. */ | |
| 1313 | const restRead = <A>(input: { | |
| 1314 | readonly cwd: string; | |
| 1315 | readonly host: string; | |
| 1316 | readonly operation: string; | |
| 1317 | readonly path: string; | |
| 1318 | readonly decode: (raw: string) => Result.Result<A, unknown>; | |
| 1319 | }): Effect.Effect<A, GitHubPullRequestApiError> => | |
| 1320 | api | |
| 1321 | .rest({ host: input.host, operation: input.operation, path: input.path }) | |
| 1322 | .pipe( | |
| 1323 | Effect.flatMap((response) => | |
| 1324 | decodeWith(input.cwd, input.operation, input.decode, response.body), | |
| 1325 | ), | |
| 1326 | ); | |
| 1327 | ||
| 1328 | /** | |
| 1329 | * One page of the patch, read from the files API. GitHub refuses a whole diff past 300 changed | |
| 1330 | * files, and still serves those files' hunks here. | |
| 1331 | * | |
| 1332 | * A page is a whole number of files, so each one parses on its own; the caller carries on from | |
| 1333 | * `nextCursor` for as long as GitHub keeps handing pages back. | |
| 1334 | * | |
| 1335 | * A named commit is read from the commit endpoint, which lists the same file entries and pages | |
| 1336 | * them the same way, only wrapped in an object. | |
| 1337 | */ | |
| 1338 | const diffFilesPage = (input: { | |
| 1339 | readonly cwd: string; | |
| 1340 | readonly repository: string; | |
| 1341 | readonly host: string; | |
| 1342 | readonly number: number; | |
| 1343 | readonly page: number; | |
| 1344 | readonly commit?: string | undefined; | |
| 1345 | }): Effect.Effect<GitHubPullRequestDiffSlice, GitHubPullRequestApiError> => { | |
| 1346 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1347 | const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`; | |
| 1348 | return api | |
| 1349 | .rest({ | |
| 1350 | host: input.host, | |
| 1351 | operation: "getPullRequestDiff", | |
| 1352 | path: | |
| 1353 | input.commit === undefined | |
| 1354 | ? `repos/${owner}/${name}/pulls/${input.number}/files?${paging}` | |
| 1355 | : `repos/${owner}/${name}/commits/${input.commit}?${paging}`, | |
| 1356 | maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, | |
| 1357 | timeout: DIFF_TIMEOUT, | |
| 1358 | }) | |
| 1359 | .pipe( | |
| 1360 | Effect.flatMap((response) => { | |
| 1361 | // Checked before decoding: a byte-truncated response is a JSON prefix, which would | |
| 1362 | // fail to parse. Nothing of this page can be shown, and an empty patch would render | |
| 1363 | // as a change with no files rather than as the failure it is; slices already handed | |
| 1364 | // over stay with the reader either way. | |
| 1365 | if (response.truncated) { | |
| 1366 | return Effect.fail( | |
| 1367 | readError( | |
| 1368 | input.cwd, | |
| 1369 | "getPullRequestDiff", | |
| 1370 | new Error(`Page ${input.page} of the changed files was too large to read.`), | |
| 1371 | ), | |
| 1372 | ); | |
| 1373 | } | |
| 1374 | return decodeWith( | |
| 1375 | input.cwd, | |
| 1376 | "getPullRequestDiff", | |
| 1377 | input.commit === undefined ? decodePullRequestFilesJson : decodeCommitFilesJson, | |
| 1378 | response.body, | |
| 1379 | ); | |
| 1380 | }), | |
| 1381 | Effect.map((files) => { | |
| 1382 | // Counted before decoding, so a page whose files all failed to decode still moves on | |
| 1383 | // rather than pointing the reader back at the page it just read. | |
| 1384 | const morePages = files.rawCount >= DIFF_FILES_PAGE_SIZE; | |
| 1385 | return { | |
| 1386 | patch: files.patch, | |
| 1387 | truncated: files.truncated, | |
| 1388 | nextCursor: morePages ? String(input.page + 1) : null, | |
| 1389 | ...(files.omittedFileStats.length === 0 | |
| 1390 | ? {} | |
| 1391 | : { omittedFileStats: files.omittedFileStats }), | |
| 1392 | }; | |
| 1393 | }), | |
| 1394 | ); | |
| 1395 | }; | |
| 1396 | ||
| 1397 | const getPullRequestDiffFileContents: GitHubPullRequestApi["Service"]["getPullRequestDiffFileContents"] = | |
| 1398 | (input) => | |
| 1399 | Effect.gen(function* () { | |
| 1400 | if (input.commit !== undefined && !isCommitSha(input.commit)) { | |
| 1401 | return yield* new GitHubDiffCommitError({ cwd: input.cwd }); | |
| 1402 | } | |
| 1403 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1404 | const refsResponse = yield* api.rest({ | |
| 1405 | host: input.host, | |
| 1406 | operation: "getPullRequestDiffFileContents", | |
| 1407 | path: | |
| 1408 | input.commit === undefined | |
| 1409 | ? `repos/${owner}/${name}/pulls/${input.number}` | |
| 1410 | : `repos/${owner}/${name}/commits/${input.commit}`, | |
| 1411 | timeout: DIFF_TIMEOUT, | |
| 1412 | }); | |
| 1413 | const refs = decodeRevisionRefs(refsResponse.body); | |
| 1414 | // A root commit has no parent, which is an absent old revision. Every file in it is new, | |
| 1415 | // so that is a usable answer whenever the caller does not need the old side. | |
| 1416 | const baseRef = Option.isSome(refs) | |
| 1417 | ? input.commit === undefined | |
| 1418 | ? refs.value.base?.sha | |
| 1419 | : (refs.value.parents?.[0]?.sha ?? "") | |
| 1420 | : undefined; | |
| 1421 | const headRef = Option.isSome(refs) | |
| 1422 | ? input.commit === undefined | |
| 1423 | ? refs.value.head?.sha | |
| 1424 | : refs.value.sha | |
| 1425 | : undefined; | |
| 1426 | const rootCommitNewFile = | |
| 1427 | input.commit !== undefined && input.changeType === "new" && baseRef === ""; | |
| 1428 | if ( | |
| 1429 | headRef === undefined || | |
| 1430 | (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) || | |
| 1431 | !isCommitSha(headRef) | |
| 1432 | ) { | |
| 1433 | return yield* new GitHubDiffRevisionsUnavailableError({ | |
| 1434 | cwd: input.cwd, | |
| 1435 | number: input.number, | |
| 1436 | ...(input.commit === undefined ? {} : { commit: input.commit }), | |
| 1437 | }); | |
| 1438 | } | |
| 1439 | ||
| 1440 | const readFile = (revision: string, filePath: string) => | |
| 1441 | api | |
| 1442 | .rest({ | |
| 1443 | host: input.host, | |
| 1444 | operation: "getPullRequestDiffFileContents", | |
| 1445 | accept: "application/vnd.github.raw+json", | |
| 1446 | path: `repos/${owner}/${name}/contents/${filePath | |
| 1447 | .split("/") | |
| 1448 | .map(encodeURIComponent) | |
| 1449 | .join("/")}?ref=${encodeURIComponent(revision)}`, | |
| 1450 | maxResponseBytes: DIFF_FILE_MAX_OUTPUT_BYTES, | |
| 1451 | timeout: DIFF_TIMEOUT, | |
| 1452 | }) | |
| 1453 | .pipe( | |
| 1454 | Effect.flatMap((response) => | |
| 1455 | response.truncated || response.body.includes("\0") || response.invalidUtf8 | |
| 1456 | ? Effect.fail( | |
| 1457 | new GitHubDiffFileContentsUnavailableError({ | |
| 1458 | cwd: input.cwd, | |
| 1459 | path: filePath, | |
| 1460 | reason: response.truncated ? "oversized" : "binary", | |
| 1461 | }), | |
| 1462 | ) | |
| 1463 | : Effect.succeed(response.body), | |
| 1464 | ), | |
| 1465 | ); | |
| 1466 | ||
| 1467 | const [oldContents, newContents] = yield* Effect.all( | |
| 1468 | [ | |
| 1469 | input.changeType === "new" | |
| 1470 | ? Effect.succeed("") | |
| 1471 | : readFile(baseRef ?? "", input.oldPath), | |
| 1472 | input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath), | |
| 1473 | ], | |
| 1474 | { concurrency: 2 }, | |
| 1475 | ); | |
| 1476 | return { oldContents, newContents }; | |
| 1477 | }); | |
| 1478 | ||
| 1479 | /** | |
| 1480 | * Every check context of the head commit, a page at a time, for a rollup the detail read cut at | |
| 1481 | * its first hundred. Each page names the head it read, so a push mid-walk fails rather than | |
| 1482 | * mixing two revisions' checks. | |
| 1483 | */ | |
| 1484 | const readAllCheckContexts = ( | |
| 1485 | input: Parameters<GitHubPullRequestApi["Service"]["getPullRequestDetail"]>[0], | |
| 1486 | allowReserve: boolean, | |
| 1487 | ) => | |
| 1488 | Effect.gen(function* () { | |
| 1489 | 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 }; | |
| 1520 | }); | |
| 1521 | ||
| 1522 | const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { | |
| 1523 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1524 | return AllowGitHubReserve.pipe( | |
| 1525 | Effect.flatMap((allowReserve) => | |
| 1526 | graphqlRead({ | |
| 1527 | allowReserve, | |
| 1528 | cwd: input.cwd, | |
| 1529 | host: input.host, | |
| 1530 | operation: "getPullRequestDetail", | |
| 1531 | variables: { | |
| 1532 | owner, | |
| 1533 | name, | |
| 1534 | number: input.number, | |
| 1535 | headRef: `refs/pull/${input.number}/head`, | |
| 1536 | }, | |
| 1537 | query: pullRequestCoreGraphQlQuery(input.host), | |
| 1538 | decode: decodePullRequestCoreJson, | |
| 1539 | }).pipe( | |
| 1540 | // Past a hundred checks the first page would let the rest imply success, so the whole | |
| 1541 | // rollup is walked instead. | |
| 1542 | Effect.filterOrElse( | |
| 1543 | (core) => !core.checksTruncated, | |
| 1544 | (core) => | |
| 1545 | readAllCheckContexts(input, allowReserve).pipe( | |
| 1546 | Effect.filterOrFail( | |
| 1547 | (all) => all.headSha === core.headSha, | |
| 1548 | () => | |
| 1549 | readError( | |
| 1550 | input.cwd, | |
| 1551 | "getPullRequestDetail", | |
| 1552 | new Error("Pull request head changed while reading checks."), | |
| 1553 | ), | |
| 1554 | ), | |
| 1555 | Effect.map((all) => ({ | |
| 1556 | ...core, | |
| 1557 | ...pullRequestChecksFromContexts(all.contexts), | |
| 1558 | checksTruncated: all.truncated, | |
| 1559 | })), | |
| 1560 | ), | |
| 1561 | ), | |
| 1562 | ), | |
| 1563 | ), | |
| 1564 | ); | |
| 1565 | }; | |
| 1566 | ||
| 1567 | const workflowApprovalLimit = 1_000; | |
| 1568 | const workflowApprovalReadError = (cwd: string, cause: unknown) => | |
| 1569 | readError(cwd, "listWorkflowRunsRequiringApproval", cause); | |
| 1570 | ||
| 1571 | /** Every open pull request whose head branch carries this name, up to one past the limit. */ | |
| 1572 | const listHeadsByBranch = (input: { | |
| 1573 | readonly cwd: string; | |
| 1574 | readonly repository: string; | |
| 1575 | readonly host: string; | |
| 1576 | readonly headBranch: string; | |
| 1577 | }) => | |
| 1578 | Effect.gen(function* () { | |
| 1579 | 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; | |
| 1599 | }); | |
| 1600 | ||
| 1601 | /** | |
| 1602 | * The runs waiting on a maintainer for this exact head, up to one past the limit. `head_sha` is | |
| 1603 | * what scopes them; `head_branch` is checked as well, the way `gh run list --branch` did. | |
| 1604 | */ | |
| 1605 | const listActionRequiredRuns = (input: { | |
| 1606 | readonly cwd: string; | |
| 1607 | readonly repository: string; | |
| 1608 | readonly host: string; | |
| 1609 | readonly headSha: string; | |
| 1610 | readonly headBranch: string; | |
| 1611 | }) => | |
| 1612 | Effect.gen(function* () { | |
| 1613 | // The checks revalidator may have just confirmed every run of this head as current. | |
| 1614 | const known = yield* KnownWorkflowRuns; | |
| 1615 | if (known !== null && known.headSha === input.headSha) { | |
| 1616 | return known.runs.flatMap((run) => | |
| 1617 | // A run waiting on a maintainer reports `completed` with an `action_required` | |
| 1618 | // conclusion; the `status=action_required` query matches either. | |
| 1619 | (run.conclusion === "action_required" || run.status === "action_required") && | |
| 1620 | run.head_branch === input.headBranch | |
| 1621 | ? [ | |
| 1622 | { | |
| 1623 | id: run.id, | |
| 1624 | name: run.name?.trim() || `Workflow run ${run.id}`, | |
| 1625 | url: run.html_url?.trim() || null, | |
| 1626 | }, | |
| 1627 | ] | |
| 1628 | : [], | |
| 1629 | ); | |
| 1630 | } | |
| 1631 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1632 | const runs: GitHubWorkflowRunApproval[] = []; | |
| 1633 | for (let page = 1; runs.length <= workflowApprovalLimit; page++) { | |
| 1634 | const read: GitHubWorkflowRunPage = yield* restRead({ | |
| 1635 | cwd: input.cwd, | |
| 1636 | host: input.host, | |
| 1637 | operation: "listWorkflowRunsRequiringApproval", | |
| 1638 | path: `repos/${owner}/${name}/actions/runs?head_sha=${encodeURIComponent(input.headSha)}&branch=${encodeURIComponent(input.headBranch)}&event=pull_request&status=action_required&per_page=100&page=${page}`, | |
| 1639 | decode: decodeWorkflowRunsJson, | |
| 1640 | }).pipe(Effect.mapError((error) => workflowApprovalReadError(input.cwd, error))); | |
| 1641 | runs.push(...read.runs); | |
| 1642 | if (read.rawCount < 100) break; | |
| 1643 | } | |
| 1644 | return runs; | |
| 1645 | }); | |
| 1646 | ||
| 1647 | const listWorkflowRunsRequiringApproval: GitHubPullRequestApi["Service"]["listWorkflowRunsRequiringApproval"] = | |
| 1648 | (input) => | |
| 1649 | Effect.all( | |
| 1650 | [ | |
| 1651 | listHeadsByBranch(input).pipe( | |
| 1652 | Effect.flatMap( | |
| 1653 | ( | |
| 1654 | heads, | |
| 1655 | ): Effect.Effect< | |
| 1656 | GitHubPullRequestHead, | |
| 1657 | GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError | |
| 1658 | > => { | |
| 1659 | const exactHeads = heads.filter( | |
| 1660 | (pullRequest) => | |
| 1661 | pullRequest.headSha === input.headSha && | |
| 1662 | pullRequest.isCrossRepository === true && | |
| 1663 | pullRequest.headRepositoryOwner?.toLowerCase() === | |
| 1664 | input.headRepositoryOwner.toLowerCase(), | |
| 1665 | ); | |
| 1666 | if (heads.length > workflowApprovalLimit) { | |
| 1667 | return Effect.fail( | |
| 1668 | new GitHubWorkflowApprovalRefusedError({ | |
| 1669 | cwd: input.cwd, | |
| 1670 | number: input.number, | |
| 1671 | reason: "head-list-truncated", | |
| 1672 | observedCount: heads.length, | |
| 1673 | limit: workflowApprovalLimit, | |
| 1674 | }), | |
| 1675 | ); | |
| 1676 | } | |
| 1677 | if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { | |
| 1678 | return Effect.fail( | |
| 1679 | new GitHubWorkflowApprovalRefusedError({ | |
| 1680 | cwd: input.cwd, | |
| 1681 | number: input.number, | |
| 1682 | reason: "head-not-unique", | |
| 1683 | observedCount: exactHeads.length, | |
| 1684 | limit: workflowApprovalLimit, | |
| 1685 | }), | |
| 1686 | ); | |
| 1687 | } | |
| 1688 | return Effect.succeed(exactHeads[0]); | |
| 1689 | }, | |
| 1690 | ), | |
| 1691 | ), | |
| 1692 | listActionRequiredRuns(input).pipe( | |
| 1693 | Effect.flatMap((runs) => | |
| 1694 | runs.length > workflowApprovalLimit | |
| 1695 | ? Effect.fail( | |
| 1696 | new GitHubWorkflowApprovalRefusedError({ | |
| 1697 | cwd: input.cwd, | |
| 1698 | number: input.number, | |
| 1699 | reason: "run-list-truncated", | |
| 1700 | observedCount: runs.length, | |
| 1701 | limit: workflowApprovalLimit, | |
| 1702 | }), | |
| 1703 | ) | |
| 1704 | : Effect.succeed(runs), | |
| 1705 | ), | |
| 1706 | ), | |
| 1707 | ], | |
| 1708 | { concurrency: 2 }, | |
| 1709 | ).pipe(Effect.map(([, runs]) => runs)); | |
| 1710 | ||
| 1711 | /** One pull request's summary, through the same aliased query the batch uses. */ | |
| 1712 | const viewPullRequestSummary = (input: PullRequestSummaryRead) => { | |
| 1713 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1714 | return graphqlRead({ | |
| 1715 | cwd: input.cwd, | |
| 1716 | host: input.host, | |
| 1717 | operation: "getPullRequestSummary", | |
| 1718 | variables: { owner, name, number: input.number }, | |
| 1719 | query: pullRequestSummaryGraphQlQuery(input.host === "github.com"), | |
| 1720 | decode: decodePullRequestSummariesJson, | |
| 1721 | }).pipe( | |
| 1722 | Effect.flatMap((summaries) => { | |
| 1723 | const summary = summaries.get(0); | |
| 1724 | return summary === undefined | |
| 1725 | ? Effect.fail( | |
| 1726 | readError( | |
| 1727 | input.cwd, | |
| 1728 | "getPullRequestSummary", | |
| 1729 | new Error(`GitHub answered nothing for ${input.repository}#${input.number}.`), | |
| 1730 | ), | |
| 1731 | ) | |
| 1732 | : Effect.succeed(summary); | |
| 1733 | }), | |
| 1734 | ); | |
| 1735 | }; | |
| 1736 | ||
| 1737 | /** | |
| 1738 | * Summaries asked for together, on one host under one credential, share aliased GraphQL reads | |
| 1739 | * of twenty-five: the background sync reads every linked pull request each minute, and one | |
| 1740 | * `gh pr view` apiece is most of what it spends. Whatever the batch cannot answer — a selector | |
| 1741 | * GraphQL cannot address, a pull request GitHub returned nothing for — is read on its own. | |
| 1742 | */ | |
| 1743 | const summaryResolver = RequestResolver.makeGrouped<PullRequestSummaryRead, string>({ | |
| 1744 | key: ({ request, context }) => | |
| 1745 | JSON.stringify([ | |
| 1746 | request.host.toLowerCase(), | |
| 1747 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) | |
| 1748 | ?.credentialFingerprint ?? null, | |
| 1749 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), | |
| 1750 | ]), | |
| 1751 | resolver: (entries) => { | |
| 1752 | const [first] = entries; | |
| 1753 | const batchable = entries.filter( | |
| 1754 | (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null, | |
| 1755 | ); | |
| 1756 | // Stack membership rides along where GitHub serves stacks, so the background sync can skip | |
| 1757 | // the REST stack read for pull requests that are in none. | |
| 1758 | const query = buildPullRequestSummariesGraphQlQuery( | |
| 1759 | batchable.map((entry) => entry.request), | |
| 1760 | first.request.host === "github.com", | |
| 1761 | ); | |
| 1762 | const batched = | |
| 1763 | query === null | |
| 1764 | ? Effect.succeed(new Map<number, GitHubPullRequestSummary>()) | |
| 1765 | : graphqlRead({ | |
| 1766 | cwd: first.request.cwd, | |
| 1767 | host: first.request.host, | |
| 1768 | operation: "getPullRequestSummary", | |
| 1769 | query, | |
| 1770 | decode: decodePullRequestSummariesJson, | |
| 1771 | }); | |
| 1772 | return batched.pipe( | |
| 1773 | // A GraphQL error anywhere fails the whole document — one repository gone or out of | |
| 1774 | // reach — so a batch that could not be read leaves every entry to its own read. A paused | |
| 1775 | // budget is the exception: reading one at a time would only spend what is being saved. | |
| 1776 | Effect.catchCauseIf( | |
| 1777 | (cause) => | |
| 1778 | !Cause.hasInterruptsOnly(cause) && | |
| 1779 | !Cause.findErrorOption(cause).pipe( | |
| 1780 | Option.exists((error) => error._tag === "SourceControlRateLimitPausedError"), | |
| 1781 | ), | |
| 1782 | (cause) => | |
| 1783 | Effect.logDebug("batched pull request summary read failed", { cause }).pipe( | |
| 1784 | Effect.as(new Map<number, GitHubPullRequestSummary>()), | |
| 1785 | ), | |
| 1786 | ), | |
| 1787 | Effect.flatMap((summaries) => { | |
| 1788 | const unanswered = entries.filter((entry) => { | |
| 1789 | const summary = summaries.get(batchable.indexOf(entry)); | |
| 1790 | if (summary === undefined) return true; | |
| 1791 | entry.completeUnsafe(Exit.succeed(summary)); | |
| 1792 | return false; | |
| 1793 | }); | |
| 1794 | return Effect.forEach( | |
| 1795 | unanswered, | |
| 1796 | (entry) => | |
| 1797 | viewPullRequestSummary(entry.request).pipe( | |
| 1798 | Effect.exit, | |
| 1799 | Effect.map((exit) => entry.completeUnsafe(exit)), | |
| 1800 | ), | |
| 1801 | { concurrency: STAT_REQUEST_CONCURRENCY, discard: true }, | |
| 1802 | ); | |
| 1803 | }), | |
| 1804 | Effect.catchCause((cause) => | |
| 1805 | Effect.sync(() => { | |
| 1806 | for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); | |
| 1807 | }), | |
| 1808 | ), | |
| 1809 | ); | |
| 1810 | }, | |
| 1811 | }).pipe( | |
| 1812 | RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), | |
| 1813 | RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), | |
| 1814 | ); | |
| 1815 | const getPullRequestSummary: GitHubPullRequestApi["Service"]["getPullRequestSummary"] = (input) => | |
| 1816 | Effect.request(new PullRequestSummaryRead(input), summaryResolver); | |
| 1817 | ||
| 1818 | // Every watched pull request on a host in one read per pass. A pull request the batch has no | |
| 1819 | // answer for gets null, and its watch reads it in full; a failed batch fails every entry. | |
| 1820 | const watchFingerprintResolver = RequestResolver.makeGrouped< | |
| 1821 | PullRequestWatchFingerprintRead, | |
| 1822 | string | |
| 1823 | >({ | |
| 1824 | key: ({ request, context }) => | |
| 1825 | JSON.stringify([ | |
| 1826 | request.host.toLowerCase(), | |
| 1827 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) | |
| 1828 | ?.credentialFingerprint ?? null, | |
| 1829 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), | |
| 1830 | ]), | |
| 1831 | resolver: (entries) => { | |
| 1832 | const [first] = entries; | |
| 1833 | const batchable = entries.filter( | |
| 1834 | (entry) => buildPullRequestWatchFingerprintsGraphQlQuery([entry.request]) !== null, | |
| 1835 | ); | |
| 1836 | const query = buildPullRequestWatchFingerprintsGraphQlQuery( | |
| 1837 | batchable.map((entry) => entry.request), | |
| 1838 | ); | |
| 1839 | const read = | |
| 1840 | query === null | |
| 1841 | ? Effect.succeed(new Map<number, GitHubPullRequestWatchFingerprint>()) | |
| 1842 | : graphqlRead({ | |
| 1843 | cwd: first.request.cwd, | |
| 1844 | host: first.request.host, | |
| 1845 | operation: "getPullRequestWatchFingerprint", | |
| 1846 | query, | |
| 1847 | decode: decodePullRequestWatchFingerprintsJson, | |
| 1848 | }); | |
| 1849 | return read.pipe( | |
| 1850 | Effect.map((fingerprints) => { | |
| 1851 | for (const entry of entries) { | |
| 1852 | const index = batchable.indexOf(entry); | |
| 1853 | entry.completeUnsafe( | |
| 1854 | Exit.succeed(index === -1 ? null : (fingerprints.get(index) ?? null)), | |
| 1855 | ); | |
| 1856 | } | |
| 1857 | }), | |
| 1858 | Effect.catchCause((cause) => | |
| 1859 | Effect.sync(() => { | |
| 1860 | for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); | |
| 1861 | }), | |
| 1862 | ), | |
| 1863 | ); | |
| 1864 | }, | |
| 1865 | }).pipe( | |
| 1866 | RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), | |
| 1867 | RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), | |
| 1868 | ); | |
| 1869 | const getPullRequestWatchFingerprint: GitHubPullRequestApi["Service"]["getPullRequestWatchFingerprint"] = | |
| 1870 | (input) => Effect.request(new PullRequestWatchFingerprintRead(input), watchFingerprintResolver); | |
| 1871 | ||
| 1872 | return GitHubPullRequestApi.of({ | |
| 1873 | withVerifiedCredential, | |
| 1874 | revalidateChecks, | |
| 1875 | getRoutingIdentity, | |
| 1876 | getViewerLogin: (input) => | |
| 1877 | getRoutingIdentity(input).pipe(Effect.map((identity) => identity.viewer)), | |
| 1878 | ||
| 1879 | listPullRequests: (input) => { | |
| 1880 | const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS); | |
| 1881 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1882 | const query = searchQuery({ ...input, repositories: [input.repository] }); | |
| 1883 | if (query === null) { | |
| 1884 | return Effect.fail( | |
| 1885 | new GitHubRepositorySelectorError({ | |
| 1886 | cwd: input.cwd, | |
| 1887 | operation: "listPullRequests", | |
| 1888 | }), | |
| 1889 | ); | |
| 1890 | } | |
| 1891 | /** Pages of up to a hundred until `rows` have arrived or GitHub has no more. */ | |
| 1892 | const collect = ( | |
| 1893 | rows: number, | |
| 1894 | page: ( | |
| 1895 | after: string | null, | |
| 1896 | rows: number, | |
| 1897 | ) => Effect.Effect<GitHubPullRequestListPage, GitHubPullRequestApiError>, | |
| 1898 | ) => | |
| 1899 | 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 }; | |
| 1910 | }); | |
| 1911 | const read = ( | |
| 1912 | continues: boolean, | |
| 1913 | requestedRows = input.limit + 1, | |
| 1914 | ): Effect.Effect<GitHubPullRequestListBatch, GitHubPullRequestApiError> => | |
| 1915 | collect(requestedRows, (after, rows) => | |
| 1916 | continues | |
| 1917 | ? graphqlRead({ | |
| 1918 | cwd: input.cwd, | |
| 1919 | host: input.host, | |
| 1920 | operation: "listPullRequests", | |
| 1921 | // The reader's own words are in the query. | |
| 1922 | variables: { q: query, after }, | |
| 1923 | query: pullRequestSearchGraphQlQuery(rows, false, true), | |
| 1924 | decode: decodePullRequestSearchJson, | |
| 1925 | }) | |
| 1926 | : graphqlRead({ | |
| 1927 | cwd: input.cwd, | |
| 1928 | host: input.host, | |
| 1929 | operation: "listPullRequests", | |
| 1930 | variables: { owner, name, states: LIST_STATES[input.state], after }, | |
| 1931 | query: pullRequestListGraphQlQuery(rows), | |
| 1932 | decode: decodePullRequestListJson, | |
| 1933 | }), | |
| 1934 | ).pipe( | |
| 1935 | Effect.flatMap(({ items: rawItems, rawCount }) => { | |
| 1936 | const items = continues | |
| 1937 | ? rawItems | |
| 1938 | : rawItems.filter((item) => matchesUnsortedListing(item, input)); | |
| 1939 | if ( | |
| 1940 | !continues && | |
| 1941 | items.length < input.limit && | |
| 1942 | rawCount >= requestedRows && | |
| 1943 | requestedRows < fallbackMaxRows | |
| 1944 | ) { | |
| 1945 | const nextRows = Math.min(requestedRows * 2, fallbackMaxRows); | |
| 1946 | if (nextRows > requestedRows) return read(false, nextRows); | |
| 1947 | } | |
| 1948 | return Effect.succeed({ | |
| 1949 | items: items.slice(0, input.limit), | |
| 1950 | // One row over the page size is the probe for a next page, and it is | |
| 1951 | // 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, | |
| 1955 | continues, | |
| 1956 | }); | |
| 1957 | }), | |
| 1958 | ); | |
| 1959 | // GitHub does not index every repository for search, and one it will not search answers | |
| 1960 | // with no rows rather than with an error — so an empty listing is read again from the | |
| 1961 | // repository's own list. Those rows come back newest-created first, an order no `updated:` | |
| 1962 | // qualifier can carry on from, so that page says it cannot be continued and the reader | |
| 1963 | // reaches the rest of it by asking for a larger page, as every listing used to. | |
| 1964 | // | |
| 1965 | // Only ever the first slice: a repository that answered the search once will answer it | |
| 1966 | // again, so an empty slice under a cursor is a repository that has run out. | |
| 1967 | // A text search that finds nothing has found nothing: falling back would answer it with the | |
| 1968 | // repository's whole list, which is every row the reader did not search for. The fallback | |
| 1969 | // is for a repository the index does not cover, and a listing with no text to match is the | |
| 1970 | // only place an empty answer can mean that. | |
| 1971 | // Every filter is a qualifier `matchesFilters` can judge over the fallback's own rows just | |
| 1972 | // as well as search judges them over its own, so carrying them into the fallback answers | |
| 1973 | // the same read rather than a wider one. Free text is the one thing the fallback cannot | |
| 1974 | // judge locally — it lists rows, it does not search their text — so a query still rules | |
| 1975 | // the fallback out: an empty answer under one is already the answer. | |
| 1976 | const hasQuery = (input.query?.trim().length ?? 0) > 0; | |
| 1977 | return read(true).pipe( | |
| 1978 | Effect.filterOrElse( | |
| 1979 | (batch) => batch.items.length > 0 || input.cursor !== undefined || hasQuery, | |
| 1980 | () => read(false), | |
| 1981 | ), | |
| 1982 | Effect.flatMap((batch) => { | |
| 1983 | // Match the search query's host support, and enrich only rows that survived paging. | |
| 1984 | if (input.host !== "github.com" || batch.items.length === 0) return Effect.succeed(batch); | |
| 1985 | const chunks: Array<ReadonlyArray<GitHubPullRequestListItem>> = []; | |
| 1986 | for (let start = 0; start < batch.items.length; start += STAT_ALIASES_PER_REQUEST) { | |
| 1987 | chunks.push(batch.items.slice(start, start + STAT_ALIASES_PER_REQUEST)); | |
| 1988 | } | |
| 1989 | return Effect.forEach( | |
| 1990 | chunks, | |
| 1991 | (chunk) => { | |
| 1992 | const query = buildPullRequestStackMembershipsGraphQlQuery( | |
| 1993 | input.repository, | |
| 1994 | chunk.map((item) => item.number), | |
| 1995 | ); | |
| 1996 | if (query === null) return Effect.succeed(chunk); | |
| 1997 | return graphqlRead({ | |
| 1998 | cwd: input.cwd, | |
| 1999 | host: input.host, | |
| 2000 | operation: "listPullRequestStackMemberships", | |
| 2001 | query, | |
| 2002 | decode: decodePullRequestStackMembershipsJson, | |
| 2003 | }).pipe( | |
| 2004 | Effect.map((memberships) => | |
| 2005 | chunk.map((item, index) => { | |
| 2006 | const stack = memberships.get(index); | |
| 2007 | return stack === undefined ? item : { ...item, stack }; | |
| 2008 | }), | |
| 2009 | ), | |
| 2010 | // Optional badges must not take down a listing that already read successfully. | |
| 2011 | Effect.catch(() => | |
| 2012 | Effect.logWarning("Pull request stack membership enrichment failed", { | |
| 2013 | operation: "listPullRequestStackMemberships", | |
| 2014 | host: input.host, | |
| 2015 | rows: chunk.length, | |
| 2016 | }).pipe(Effect.as(chunk)), | |
| 2017 | ), | |
| 2018 | ); | |
| 2019 | }, | |
| 2020 | { concurrency: STAT_REQUEST_CONCURRENCY }, | |
| 2021 | ).pipe(Effect.map((chunks) => ({ ...batch, items: chunks.flat() }))); | |
| 2022 | }), | |
| 2023 | ); | |
| 2024 | }, | |
| 2025 | ||
| 2026 | searchPullRequests: (input) => { | |
| 2027 | const query = searchQuery(input); | |
| 2028 | if (query === null) { | |
| 2029 | return Effect.fail( | |
| 2030 | new GitHubRepositorySelectorError({ | |
| 2031 | cwd: input.cwd, | |
| 2032 | operation: "searchPullRequests", | |
| 2033 | }), | |
| 2034 | ); | |
| 2035 | } | |
| 2036 | // One extra row reveals that the host has more than the slice shows, the way the | |
| 2037 | // per-repository read does — up to GitHub's own ceiling on a search page, past which | |
| 2038 | // `hasNextPage` is what says there is more. | |
| 2039 | const rows = Math.min(input.limit + 1, PULL_REQUEST_SEARCH_MAX_ROWS); | |
| 2040 | return graphqlRead({ | |
| 2041 | cwd: input.cwd, | |
| 2042 | host: input.host, | |
| 2043 | operation: "searchPullRequests", | |
| 2044 | variables: { q: query, after: null }, | |
| 2045 | query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"), | |
| 2046 | decode: decodePullRequestSearchJson, | |
| 2047 | }).pipe( | |
| 2048 | Effect.map((batch) => ({ | |
| 2049 | items: batch.items.slice(0, input.limit), | |
| 2050 | truncated: batch.rawCount > input.limit || batch.hasNextPage, | |
| 2051 | })), | |
| 2052 | ); | |
| 2053 | }, | |
| 2054 | ||
| 2055 | listPullRequestStats: (input) => { | |
| 2056 | const chunks: Array<ReadonlyArray<{ readonly repository: string; readonly number: number }>> = | |
| 2057 | []; | |
| 2058 | for (let start = 0; start < input.changeRequests.length; start += STAT_ALIASES_PER_REQUEST) { | |
| 2059 | chunks.push(input.changeRequests.slice(start, start + STAT_ALIASES_PER_REQUEST)); | |
| 2060 | } | |
| 2061 | return Effect.forEach( | |
| 2062 | chunks, | |
| 2063 | (chunk) => { | |
| 2064 | const query = buildPullRequestStatsGraphQlQuery(chunk); | |
| 2065 | if (query === null) { | |
| 2066 | return Effect.fail( | |
| 2067 | new GitHubRepositorySelectorError({ | |
| 2068 | cwd: input.cwd, | |
| 2069 | operation: "listPullRequestStats", | |
| 2070 | }), | |
| 2071 | ); | |
| 2072 | } | |
| 2073 | return graphqlRead({ | |
| 2074 | cwd: input.cwd, | |
| 2075 | host: input.host, | |
| 2076 | operation: "listPullRequestStats", | |
| 2077 | query, | |
| 2078 | decode: decodePullRequestStatsJson, | |
| 2079 | }).pipe( | |
| 2080 | Effect.map((stats) => | |
| 2081 | chunk.flatMap((changeRequest, index) => { | |
| 2082 | const stat = stats.get(index); | |
| 2083 | return stat === undefined ? [] : [{ ...changeRequest, ...stat }]; | |
| 2084 | }), | |
| 2085 | ), | |
| 2086 | ); | |
| 2087 | }, | |
| 2088 | { concurrency: STAT_REQUEST_CONCURRENCY }, | |
| 2089 | ).pipe(Effect.map((results) => results.flat())); | |
| 2090 | }, | |
| 2091 | ||
| 2092 | getPullRequestSummary, | |
| 2093 | getPullRequestWatchFingerprint, | |
| 2094 | ||
| 2095 | getPullRequestDetail, | |
| 2096 | getPullRequestPreview: (input) => { | |
| 2097 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2098 | return graphqlRead({ | |
| 2099 | cwd: input.cwd, | |
| 2100 | host: input.host, | |
| 2101 | operation: "getPullRequestPreview", | |
| 2102 | variables: { owner, name, number: input.number }, | |
| 2103 | query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, | |
| 2104 | decode: decodePullRequestPreviewJson, | |
| 2105 | }); | |
| 2106 | }, | |
| 2107 | listWorkflowRunsRequiringApproval, | |
| 2108 | ||
| 2109 | getPullRequestStack: (input) => { | |
| 2110 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2111 | return restRead({ | |
| 2112 | cwd: input.cwd, | |
| 2113 | host: input.host, | |
| 2114 | operation: "getPullRequestStack", | |
| 2115 | path: `repos/${owner}/${name}/stacks?pull_request=${input.number}`, | |
| 2116 | decode: decodePullRequestStacksJson, | |
| 2117 | }).pipe( | |
| 2118 | // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine. | |
| 2119 | Effect.flatMap((stack) => { | |
| 2120 | if (!input.includeDetails || stack === null) return Effect.succeed(stack); | |
| 2121 | return restRead({ | |
| 2122 | cwd: input.cwd, | |
| 2123 | host: input.host, | |
| 2124 | operation: "getPullRequestStack", | |
| 2125 | path: `repos/${owner}/${name}/stacks/${stack.number}`, | |
| 2126 | decode: (raw) => decodePullRequestStacksJson(`[${raw}]`), | |
| 2127 | }); | |
| 2128 | }), | |
| 2129 | // Hosts without the stacks preview return 404. Other failures must preserve the | |
| 2130 | // previously synced stack and let the caller retry. | |
| 2131 | Effect.catchTags({ | |
| 2132 | GitHubApiNotFoundError: () => Effect.succeed(null), | |
| 2133 | }), | |
| 2134 | ); | |
| 2135 | }, | |
| 2136 | ||
| 2137 | getPullRequestActivity: (input) => | |
| 2138 | Effect.gen(function* () { | |
| 2139 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2140 | let author: PullRequestActor | null = null; | |
| 2141 | let commits: ReadonlyArray<PullRequestCommit> = []; | |
| 2142 | const remarks: PullRequestComment[] = []; | |
| 2143 | let commentsAfter: string | null = null; | |
| 2144 | let reviewsAfter: string | null = null; | |
| 2145 | let withComments = true; | |
| 2146 | let withReviews = true; | |
| 2147 | // Both remark lists page on their own; a page asks only for the ones with more to give. | |
| 2148 | for (let page = 0; page < REVIEW_THREAD_PAGES && (withComments || withReviews); page++) { | |
| 2149 | const read: GitHubPullRequestActivityPage = yield* graphqlRead({ | |
| 2150 | cwd: input.cwd, | |
| 2151 | host: input.host, | |
| 2152 | operation: "getPullRequestActivity", | |
| 2153 | variables: { | |
| 2154 | owner, | |
| 2155 | name, | |
| 2156 | number: input.number, | |
| 2157 | head: page === 0, | |
| 2158 | withComments, | |
| 2159 | commentsAfter, | |
| 2160 | withReviews, | |
| 2161 | reviewsAfter, | |
| 2162 | }, | |
| 2163 | query: PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, | |
| 2164 | decode: decodePullRequestActivityJson, | |
| 2165 | }); | |
| 2166 | if (page === 0) { | |
| 2167 | author = read.author ?? null; | |
| 2168 | commits = read.commits ?? []; | |
| 2169 | } | |
| 2170 | remarks.push(...read.remarks); | |
| 2171 | commentsAfter = read.nextCommentsCursor; | |
| 2172 | reviewsAfter = read.nextReviewsCursor; | |
| 2173 | withComments = withComments && commentsAfter !== null; | |
| 2174 | withReviews = withReviews && reviewsAfter !== null; | |
| 2175 | } | |
| 2176 | return { | |
| 2177 | author, | |
| 2178 | comments: remarks.toSorted((left, right) => | |
| 2179 | left.createdAt.localeCompare(right.createdAt), | |
| 2180 | ), | |
| 2181 | commits, | |
| 2182 | } satisfies GitHubPullRequestActivity; | |
| 2183 | }), | |
| 2184 | ||
| 2185 | getPullRequestDiff: (input) => { | |
| 2186 | const filesPage = (page: number) => | |
| 2187 | diffFilesPage({ | |
| 2188 | cwd: input.cwd, | |
| 2189 | repository: input.repository, | |
| 2190 | host: input.host, | |
| 2191 | number: input.number, | |
| 2192 | page, | |
| 2193 | ...(input.commit === undefined ? {} : { commit: input.commit }), | |
| 2194 | }); | |
| 2195 | if (input.commit !== undefined && !isCommitSha(input.commit)) { | |
| 2196 | return Effect.fail(new GitHubDiffCommitError({ cwd: input.cwd })); | |
| 2197 | } | |
| 2198 | // A cursor only ever comes from the files walk, so a reader carrying one is already past | |
| 2199 | // the point where `gh pr diff` had anything to say. | |
| 2200 | if (input.cursor !== undefined) { | |
| 2201 | const page = diffCursorPage(input.cursor); | |
| 2202 | return page === null | |
| 2203 | ? Effect.fail(new GitHubDiffCursorError({ cwd: input.cwd })) | |
| 2204 | : filesPage(page); | |
| 2205 | } | |
| 2206 | // `gh pr diff` speaks for the whole pull request and has no way to name one commit of it. | |
| 2207 | if (input.commit !== undefined) { | |
| 2208 | return filesPage(1); | |
| 2209 | } | |
| 2210 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2211 | return api | |
| 2212 | .rest({ | |
| 2213 | host: input.host, | |
| 2214 | operation: "getPullRequestDiff", | |
| 2215 | path: `repos/${owner}/${name}/pulls/${input.number}`, | |
| 2216 | accept: "application/vnd.github.diff", | |
| 2217 | maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, | |
| 2218 | timeout: DIFF_TIMEOUT, | |
| 2219 | }) | |
| 2220 | .pipe( | |
| 2221 | Effect.flatMap((response) => | |
| 2222 | // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor | |
| 2223 | // something the reader can carry on from. The files API can serve the same change a | |
| 2224 | // whole number of files at a time, so an oversized patch takes that road as well. | |
| 2225 | response.truncated | |
| 2226 | ? filesPage(1) | |
| 2227 | : // One read served the whole patch, so there is no next slice to ask for. | |
| 2228 | Effect.succeed({ patch: response.body, truncated: false, nextCursor: null }), | |
| 2229 | ), | |
| 2230 | // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read | |
| 2231 | // from the files API instead, a page per call. Only once the direct read has failed: a | |
| 2232 | // pull request GitHub will serve a diff for must not pay for a second request. A | |
| 2233 | // fallback that fails too reports the original refusal, which is the one that explains | |
| 2234 | // the page. Narrowed to a request GitHub answered and refused: a missing credential or | |
| 2235 | // a rate limit fails the same way for every request. | |
| 2236 | Effect.catchTags({ | |
| 2237 | GitHubApiResponseError: (error) => filesPage(1).pipe(Effect.mapError(() => error)), | |
| 2238 | }), | |
| 2239 | ); | |
| 2240 | }, | |
| 2241 | ||
| 2242 | getPullRequestDiffFileContents, | |
| 2243 | ||
| 2244 | getReviewThreadComments: (input) => { | |
| 2245 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2246 | return graphqlRead({ | |
| 2247 | cwd: input.cwd, | |
| 2248 | host: input.host, | |
| 2249 | operation: "getReviewThreadComments", | |
| 2250 | variables: { | |
| 2251 | owner, | |
| 2252 | name, | |
| 2253 | number: input.number, | |
| 2254 | threadId: input.threadId, | |
| 2255 | cursor: input.cursor, | |
| 2256 | }, | |
| 2257 | query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, | |
| 2258 | decode: decodeReviewThreadCommentsJson, | |
| 2259 | }).pipe( | |
| 2260 | Effect.flatMap(({ belongsToPullRequest, comments, nextCursor }) => | |
| 2261 | belongsToPullRequest | |
| 2262 | ? Effect.succeed({ comments, nextCursor }) | |
| 2263 | : Effect.fail( | |
| 2264 | new GitHubSubjectScopeError({ | |
| 2265 | cwd: input.cwd, | |
| 2266 | operation: "getReviewThreadComments", | |
| 2267 | }), | |
| 2268 | ), | |
| 2269 | ), | |
| 2270 | ); | |
| 2271 | }, | |
| 2272 | ||
| 2273 | listReviewThreadComments: (input) => | |
| 2274 | Effect.gen(function* () { | |
| 2275 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2276 | const threadPage = ( | |
| 2277 | cursor: string | null, | |
| 2278 | ): Effect.Effect<GitHubReviewThreadPage, GitHubPullRequestApiError> => | |
| 2279 | graphqlRead({ | |
| 2280 | cwd: input.cwd, | |
| 2281 | host: input.host, | |
| 2282 | operation: "listReviewThreadComments", | |
| 2283 | variables: { owner, name, number: input.number, cursor }, | |
| 2284 | query: REVIEW_THREADS_GRAPHQL_QUERY, | |
| 2285 | decode: decodeReviewThreadsJson, | |
| 2286 | }); | |
| 2287 | const entries: GitHubReviewThreadEntry[] = []; | |
| 2288 | const avatarsByLogin = new Map<string, string>(); | |
| 2289 | const botLogins = new Set<string>(); | |
| 2290 | const commitStats = new Map< | |
| 2291 | string, | |
| 2292 | { readonly additions: number; readonly deletions: number } | |
| 2293 | >(); | |
| 2294 | let reviewers: ReadonlyArray<PullRequestActor> = []; | |
| 2295 | let reactions: GitHubReviewThreadPage["reactions"] = []; | |
| 2296 | const reactionsById = new Map<string, ReadonlyArray<PullRequestReaction>>(); | |
| 2297 | const editedAtById = new Map<string, string>(); | |
| 2298 | let commits: GitHubReviewThreadPage["commits"] = []; | |
| 2299 | let viewer: GitHubReviewThreadPage["viewer"] = { canUpdate: true, didAuthor: false }; | |
| 2300 | const dismissalsByReviewId = new Map<string, string>(); | |
| 2301 | let dismissalCursor: string | null = null; | |
| 2302 | let cursor: string | null = null; | |
| 2303 | let page = 0; | |
| 2304 | do { | |
| 2305 | const read: GitHubReviewThreadPage = yield* threadPage(cursor); | |
| 2306 | entries.push(...read.threads); | |
| 2307 | for (const login of read.botLogins) botLogins.add(login); | |
| 2308 | for (const [login, avatarUrl] of read.avatarsByLogin) | |
| 2309 | avatarsByLogin.set(login, avatarUrl); | |
| 2310 | // The roster, the commits and the viewer's standing travel with every page, and the | |
| 2311 | // first one already carries all of them. | |
| 2312 | if (page === 0) { | |
| 2313 | reviewers = read.reviewers; | |
| 2314 | reactions = read.reactions; | |
| 2315 | for (const [id, entry] of read.reactionsById) reactionsById.set(id, entry); | |
| 2316 | for (const [id, editedAt] of read.editedAtById) editedAtById.set(id, editedAt); | |
| 2317 | commits = read.commits; | |
| 2318 | viewer = read.viewer; | |
| 2319 | for (const [id, message] of read.dismissalsByReviewId) | |
| 2320 | dismissalsByReviewId.set(id, message); | |
| 2321 | dismissalCursor = read.nextDismissalCursor; | |
| 2322 | for (const [oid, stat] of read.commitStats) commitStats.set(oid, stat); | |
| 2323 | } | |
| 2324 | cursor = read.nextCursor; | |
| 2325 | page += 1; | |
| 2326 | } while (cursor !== null && page < REVIEW_THREAD_PAGES); | |
| 2327 | ||
| 2328 | // Almost never entered: the embedded page already holds every dismissal a pull request | |
| 2329 | // ordinarily accrues. Followed so a review whose event fell past that page still finds | |
| 2330 | // 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; | |
| 2348 | } | |
| 2349 | ||
| 2350 | const reviewThreads = entries.map((entry) => ({ | |
| 2351 | ...entry.thread, | |
| 2352 | commentCount: entry.commentCount, | |
| 2353 | ...(entry.nextCommentCursor === null | |
| 2354 | ? {} | |
| 2355 | : { nextCommentsCursor: entry.nextCommentCursor }), | |
| 2356 | })); | |
| 2357 | return { | |
| 2358 | comments: reviewThreadConversation(reviewThreads), | |
| 2359 | dismissalsByReviewId, | |
| 2360 | reviewThreads, | |
| 2361 | // GitHub's own count of each thread, so the number the page shows is the host's even | |
| 2362 | // where a bound kept some of the words on GitHub. | |
| 2363 | commentCount: entries.reduce((total, entry) => total + entry.commentCount, 0), | |
| 2364 | truncated: cursor !== null || entries.some((entry) => entry.nextCommentCursor !== null), | |
| 2365 | reviewThreadsTruncated: cursor !== null, | |
| 2366 | reactions, | |
| 2367 | reactionsById, | |
| 2368 | editedAtById, | |
| 2369 | reviewers, | |
| 2370 | avatarsByLogin, | |
| 2371 | botLogins, | |
| 2372 | commitStats, | |
| 2373 | commits, | |
| 2374 | viewer, | |
| 2375 | }; | |
| 2376 | }), | |
| 2377 | ||
| 2378 | listActorAvatars: (input) => { | |
| 2379 | if (input.ids.length === 0) { | |
| 2380 | return Effect.succeed(new Map<string, string>()); | |
| 2381 | } | |
| 2382 | return graphqlRead({ | |
| 2383 | cwd: input.cwd, | |
| 2384 | host: input.host, | |
| 2385 | operation: "listActorAvatars", | |
| 2386 | variables: { ids: input.ids }, | |
| 2387 | query: ACTOR_AVATARS_GRAPHQL_QUERY, | |
| 2388 | decode: decodeActorAvatarsJson, | |
| 2389 | }); | |
| 2390 | }, | |
| 2391 | ||
| 2392 | getViewerAccess: (input) => { | |
| 2393 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2394 | return graphqlRead({ | |
| 2395 | cwd: input.cwd, | |
| 2396 | host: input.host, | |
| 2397 | operation: "getViewerAccess", | |
| 2398 | ...(input.allowReserve === true ? { allowReserve: true } : {}), | |
| 2399 | variables: { owner, name, number: input.number }, | |
| 2400 | query: VIEWER_PERMISSIONS_GRAPHQL_QUERY, | |
| 2401 | decode: decodeViewerPermissionsJson, | |
| 2402 | }); | |
| 2403 | }, | |
| 2404 | ||
| 2405 | listReviewerCandidates: (input) => { | |
| 2406 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2407 | return graphqlRead({ | |
| 2408 | cwd: input.cwd, | |
| 2409 | host: input.host, | |
| 2410 | operation: "listReviewerCandidates", | |
| 2411 | allowReserve: true, | |
| 2412 | variables: { owner, name, number: input.number }, | |
| 2413 | query: REVIEWER_CANDIDATES_GRAPHQL_QUERY, | |
| 2414 | decode: decodeReviewerCandidatesJson, | |
| 2415 | }); | |
| 2416 | }, | |
| 2417 | ||
| 2418 | setReviewerRequest: (input) => { | |
| 2419 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2420 | // Posting to a login GitHub has already been asked about is what a re-request is, so | |
| 2421 | // there is nothing to say here about somebody who has reviewed once already. | |
| 2422 | return api | |
| 2423 | .rest({ | |
| 2424 | host: input.host, | |
| 2425 | operation: "setReviewerRequest", | |
| 2426 | method: input.requested ? "POST" : "DELETE", | |
| 2427 | path: `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`, | |
| 2428 | body: buildReviewerRequest(input.reviewers), | |
| 2429 | }) | |
| 2430 | .pipe(Effect.asVoid); | |
| 2431 | }, | |
| 2432 | ||
| 2433 | listLabelCandidates: (input) => { | |
| 2434 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2435 | return graphqlRead({ | |
| 2436 | cwd: input.cwd, | |
| 2437 | host: input.host, | |
| 2438 | operation: "listLabelCandidates", | |
| 2439 | allowReserve: true, | |
| 2440 | variables: { owner, name, number: input.number }, | |
| 2441 | query: LABEL_CANDIDATES_GRAPHQL_QUERY, | |
| 2442 | decode: decodeLabelCandidatesJson, | |
| 2443 | }); | |
| 2444 | }, | |
| 2445 | ||
| 2446 | setLabels: (input) => { | |
| 2447 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2448 | // A pull request is an issue to the labels API. Adding posts a list and leaves what was | |
| 2449 | // already there; taking off is one delete per label, since the endpoint names one in its | |
| 2450 | // path. The name goes into the path encoded, because a label may carry a space or a slash. | |
| 2451 | const issue = `repos/${owner}/${name}/issues/${input.number}/labels`; | |
| 2452 | if (input.applied) { | |
| 2453 | return api | |
| 2454 | .rest({ | |
| 2455 | host: input.host, | |
| 2456 | operation: "setLabels", | |
| 2457 | method: "POST", | |
| 2458 | path: issue, | |
| 2459 | body: buildLabelRequest(input.labels), | |
| 2460 | }) | |
| 2461 | .pipe(Effect.asVoid); | |
| 2462 | } | |
| 2463 | return Effect.forEach( | |
| 2464 | input.labels, | |
| 2465 | (label) => | |
| 2466 | api.rest({ | |
| 2467 | host: input.host, | |
| 2468 | operation: "setLabels", | |
| 2469 | method: "DELETE", | |
| 2470 | path: `${issue}/${encodeURIComponent(label)}`, | |
| 2471 | }), | |
| 2472 | { concurrency: 1, discard: true }, | |
| 2473 | ); | |
| 2474 | }, | |
| 2475 | ||
| 2476 | runPullRequestAction: (input) => { | |
| 2477 | if (input.stackNumber !== undefined) | |
| 2478 | return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( | |
| 2479 | Effect.provideService(GitHubApi.GitHubApi, api), | |
| 2480 | Effect.provideService(VcsProcess.VcsProcess, vcsProcess), | |
| 2481 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 2482 | ); | |
| 2483 | if (input.action === "revert") { | |
| 2484 | return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( | |
| 2485 | Effect.flatMap((pullRequestId) => | |
| 2486 | graphql({ | |
| 2487 | host: input.host, | |
| 2488 | operation: "revertPullRequest", | |
| 2489 | query: REVERT_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 2490 | variables: { pullRequestId }, | |
| 2491 | }), | |
| 2492 | ), | |
| 2493 | ); | |
| 2494 | } | |
| 2495 | if (input.action === "approve-workflows") { | |
| 2496 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2497 | return getPullRequestDetail(input).pipe( | |
| 2498 | Effect.flatMap((detail) => { | |
| 2499 | if (detail.isCrossRepository !== true) return Effect.void; | |
| 2500 | if (detail.headSha == null || detail.headRepositoryOwner == null) { | |
| 2501 | return Effect.fail( | |
| 2502 | new GitHubWorkflowApprovalHeadUnavailableError({ | |
| 2503 | cwd: input.cwd, | |
| 2504 | number: input.number, | |
| 2505 | }), | |
| 2506 | ); | |
| 2507 | } | |
| 2508 | const expectedHeadSha = detail.headSha; | |
| 2509 | const expectedHeadBranch = detail.headBranch; | |
| 2510 | const expectedHeadRepositoryOwner = detail.headRepositoryOwner; | |
| 2511 | return listWorkflowRunsRequiringApproval({ | |
| 2512 | ...input, | |
| 2513 | headSha: expectedHeadSha, | |
| 2514 | headBranch: expectedHeadBranch, | |
| 2515 | headRepositoryOwner: expectedHeadRepositoryOwner, | |
| 2516 | isCrossRepository: true, | |
| 2517 | }).pipe( | |
| 2518 | Effect.flatMap((runs) => | |
| 2519 | Effect.forEach( | |
| 2520 | runs, | |
| 2521 | (run) => | |
| 2522 | getPullRequestDetail(input).pipe( | |
| 2523 | Effect.flatMap((current) => { | |
| 2524 | if (current.headSha == null || current.headRepositoryOwner == null) { | |
| 2525 | return Effect.fail( | |
| 2526 | new GitHubWorkflowApprovalHeadUnavailableError({ | |
| 2527 | cwd: input.cwd, | |
| 2528 | number: input.number, | |
| 2529 | }), | |
| 2530 | ); | |
| 2531 | } | |
| 2532 | if ( | |
| 2533 | current.isCrossRepository !== true || | |
| 2534 | current.headSha !== expectedHeadSha || | |
| 2535 | current.headBranch !== expectedHeadBranch || | |
| 2536 | current.headRepositoryOwner.toLowerCase() !== | |
| 2537 | expectedHeadRepositoryOwner.toLowerCase() | |
| 2538 | ) { | |
| 2539 | return Effect.fail( | |
| 2540 | new GitHubWorkflowApprovalHeadChangedError({ | |
| 2541 | cwd: input.cwd, | |
| 2542 | number: input.number, | |
| 2543 | }), | |
| 2544 | ); | |
| 2545 | } | |
| 2546 | return listWorkflowRunsRequiringApproval({ | |
| 2547 | ...input, | |
| 2548 | headSha: current.headSha, | |
| 2549 | headBranch: current.headBranch, | |
| 2550 | headRepositoryOwner: current.headRepositoryOwner, | |
| 2551 | isCrossRepository: true, | |
| 2552 | }); | |
| 2553 | }), | |
| 2554 | Effect.flatMap((currentRuns) => | |
| 2555 | currentRuns.some((current) => current.id === run.id) | |
| 2556 | ? api | |
| 2557 | .rest({ | |
| 2558 | host: input.host, | |
| 2559 | operation: "approveWorkflowRun", | |
| 2560 | method: "POST", | |
| 2561 | path: `repos/${owner}/${name}/actions/runs/${run.id}/approve`, | |
| 2562 | }) | |
| 2563 | .pipe(Effect.asVoid) | |
| 2564 | : Effect.void, | |
| 2565 | ), | |
| 2566 | ), | |
| 2567 | { concurrency: 1, discard: true }, | |
| 2568 | ), | |
| 2569 | ), | |
| 2570 | ); | |
| 2571 | }), | |
| 2572 | ); | |
| 2573 | } | |
| 2574 | const action = input.action; | |
| 2575 | if (action in SIMPLE_ACTION_MUTATIONS) { | |
| 2576 | return pullRequestNodeId({ ...input, operation: "runPullRequestAction" }).pipe( | |
| 2577 | Effect.flatMap((pullRequestId) => | |
| 2578 | graphql({ | |
| 2579 | host: input.host, | |
| 2580 | operation: "runPullRequestAction", | |
| 2581 | query: SIMPLE_ACTION_MUTATIONS[action as keyof typeof SIMPLE_ACTION_MUTATIONS], | |
| 2582 | variables: { pullRequestId }, | |
| 2583 | }), | |
| 2584 | ), | |
| 2585 | ); | |
| 2586 | } | |
| 2587 | return Effect.gen(function* () { | |
| 2588 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2589 | // Read fresh rather than from the node id cache: merging and updating act on the head | |
| 2590 | // as it stands now, and the merge queue decides which mutation a merge is. | |
| 2591 | const state = yield* graphqlRead({ | |
| 2592 | cwd: input.cwd, | |
| 2593 | host: input.host, | |
| 2594 | operation: "runPullRequestAction", | |
| 2595 | allowReserve: true, | |
| 2596 | query: ACTION_STATE_GRAPHQL_QUERY, | |
| 2597 | variables: { | |
| 2598 | owner, | |
| 2599 | name, | |
| 2600 | number: input.number, | |
| 2601 | headRef: `refs/pull/${input.number}/head`, | |
| 2602 | }, | |
| 2603 | decode: decodeActionState, | |
| 2604 | }); | |
| 2605 | if (action === "update-branch") { | |
| 2606 | // Already current is done, the way `gh pr update-branch` reports it. | |
| 2607 | if (state.baseRef?.compare?.behindBy === 0) return; | |
| 2608 | // GitHub updates with a merge commit unless asked to rebase, which is its own default. | |
| 2609 | return yield* graphql({ | |
| 2610 | host: input.host, | |
| 2611 | operation: "runPullRequestAction", | |
| 2612 | query: UPDATE_BRANCH_GRAPHQL_MUTATION, | |
| 2613 | variables: { | |
| 2614 | pullRequestId: state.id, | |
| 2615 | expectedHeadOid: state.headRefOid, | |
| 2616 | updateMethod: input.updateMethod === "rebase" ? "REBASE" : "MERGE", | |
| 2617 | }, | |
| 2618 | }); | |
| 2619 | } | |
| 2620 | let body: string | undefined; | |
| 2621 | let expectedHead: string | undefined; | |
| 2622 | if (input.removeAgentCreditsOnMerge === true && input.mergeMethod !== "rebase") { | |
| 2623 | const message = yield* graphqlRead({ | |
| 2624 | cwd: input.cwd, | |
| 2625 | host: input.host, | |
| 2626 | operation: "runPullRequestAction", | |
| 2627 | allowReserve: true, | |
| 2628 | query: MERGE_MESSAGE_GRAPHQL_QUERY, | |
| 2629 | variables: { | |
| 2630 | owner, | |
| 2631 | name, | |
| 2632 | number: input.number, | |
| 2633 | method: input.mergeMethod === "squash" ? "SQUASH" : "MERGE", | |
| 2634 | }, | |
| 2635 | decode: decodeMergeMessage, | |
| 2636 | }); | |
| 2637 | // GitHub's merge queue chooses its own message and ignores custom text. | |
| 2638 | if (!message.isMergeQueueEnabled) { | |
| 2639 | const cleaned = removeAgentCredits(message.viewerMergeBodyText); | |
| 2640 | if (cleaned !== message.viewerMergeBodyText) { | |
| 2641 | body = cleaned; | |
| 2642 | expectedHead = message.headRefOid; | |
| 2643 | } | |
| 2644 | } | |
| 2645 | } | |
| 2646 | // A merge queue takes a pull request through auto-merge rather than a direct merge, and | |
| 2647 | // `--auto` on a pull request that is mergeable right now simply merges it, as `gh` does. | |
| 2648 | // GitHub stores the strategy with a standing instruction rather than choosing one at | |
| 2649 | // merge time, so arming still names it. | |
| 2650 | const auto = | |
| 2651 | state.isMergeQueueEnabled === true || | |
| 2652 | (action === "enable-auto-merge" && | |
| 2653 | !IMMEDIATELY_MERGEABLE.has(state.mergeStateStatus?.toUpperCase() ?? "")); | |
| 2654 | yield* graphql({ | |
| 2655 | host: input.host, | |
| 2656 | operation: "runPullRequestAction", | |
| 2657 | query: auto ? ENABLE_AUTO_MERGE_GRAPHQL_MUTATION : MERGE_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 2658 | variables: { | |
| 2659 | input: { | |
| 2660 | pullRequestId: state.id, | |
| 2661 | mergeMethod: GRAPHQL_MERGE_METHODS[input.mergeMethod ?? "merge"], | |
| 2662 | ...(expectedHead === undefined ? {} : { expectedHeadOid: expectedHead }), | |
| 2663 | ...(body === undefined ? {} : { commitBody: body }), | |
| 2664 | }, | |
| 2665 | }, | |
| 2666 | }); | |
| 2667 | }); | |
| 2668 | }, | |
| 2669 | ||
| 2670 | commentOnPullRequest: (input) => | |
| 2671 | pullRequestNodeId({ ...input, operation: "commentOnPullRequest" }).pipe( | |
| 2672 | Effect.flatMap((subjectId) => | |
| 2673 | graphql({ | |
| 2674 | host: input.host, | |
| 2675 | operation: "commentOnPullRequest", | |
| 2676 | query: ADD_COMMENT_GRAPHQL_MUTATION, | |
| 2677 | variables: { subjectId, body: input.body }, | |
| 2678 | }), | |
| 2679 | ), | |
| 2680 | ), | |
| 2681 | ||
| 2682 | submitReview: (input) => { | |
| 2683 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2684 | // The whole review is one request, so nothing is visible to anyone else until the verdict | |
| 2685 | // is sent. | |
| 2686 | return api | |
| 2687 | .rest({ | |
| 2688 | host: input.host, | |
| 2689 | operation: "submitReview", | |
| 2690 | method: "POST", | |
| 2691 | path: `repos/${owner}/${name}/pulls/${input.number}/reviews`, | |
| 2692 | body: buildReviewSubmission({ | |
| 2693 | verdict: input.verdict, | |
| 2694 | body: input.body, | |
| 2695 | comments: input.comments, | |
| 2696 | }), | |
| 2697 | }) | |
| 2698 | .pipe(Effect.asVoid); | |
| 2699 | }, | |
| 2700 | ||
| 2701 | replyToReviewThread: (input) => | |
| 2702 | graphql({ | |
| 2703 | host: input.host, | |
| 2704 | operation: "replyToReviewThread", | |
| 2705 | query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, | |
| 2706 | variables: { threadId: input.threadId, body: input.body }, | |
| 2707 | }), | |
| 2708 | ||
| 2709 | getPullRequestFilesViewed: (input) => { | |
| 2710 | 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); | |
| 2732 | }), | |
| 2733 | ); | |
| 2734 | return read(null, [], FILES_VIEWED_MAX_PAGES); | |
| 2735 | }, | |
| 2736 | ||
| 2737 | setPullRequestFilesViewed: (input) => { | |
| 2738 | const mutation = buildSetFilesViewedGraphQlMutation(input.files); | |
| 2739 | if (mutation === null) return Effect.void; | |
| 2740 | 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 | ), | |
| 2749 | ); | |
| 2750 | }, | |
| 2751 | ||
| 2752 | setReviewThreadResolution: (input) => | |
| 2753 | graphql({ | |
| 2754 | host: input.host, | |
| 2755 | operation: "setReviewThreadResolution", | |
| 2756 | query: input.resolved | |
| 2757 | ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION | |
| 2758 | : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, | |
| 2759 | variables: { threadId: input.threadId }, | |
| 2760 | }), | |
| 2761 | ||
| 2762 | setReaction: (input) => { | |
| 2763 | const givenSubjectId = input.subjectId; | |
| 2764 | const subjectId = | |
| 2765 | givenSubjectId === undefined | |
| 2766 | ? pullRequestNodeId({ ...input, operation: "setReaction" }) | |
| 2767 | : subjectBelongsToPullRequest({ | |
| 2768 | ...input, | |
| 2769 | subjectId: givenSubjectId, | |
| 2770 | operation: "setReaction", | |
| 2771 | }).pipe( | |
| 2772 | Effect.flatMap((belongs) => | |
| 2773 | belongs | |
| 2774 | ? Effect.succeed(givenSubjectId) | |
| 2775 | : Effect.fail( | |
| 2776 | new GitHubSubjectScopeError({ | |
| 2777 | cwd: input.cwd, | |
| 2778 | operation: "setReaction", | |
| 2779 | }), | |
| 2780 | ), | |
| 2781 | ), | |
| 2782 | ); | |
| 2783 | return subjectId.pipe( | |
| 2784 | Effect.flatMap((subjectId) => | |
| 2785 | graphql({ | |
| 2786 | host: input.host, | |
| 2787 | operation: "setReaction", | |
| 2788 | query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION, | |
| 2789 | variables: { subjectId, content: gitHubReactionContent(input.content) }, | |
| 2790 | }), | |
| 2791 | ), | |
| 2792 | ); | |
| 2793 | }, | |
| 2794 | ||
| 2795 | updatePullRequest: (input) => | |
| 2796 | pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe( | |
| 2797 | Effect.flatMap((pullRequestId) => | |
| 2798 | graphql({ | |
| 2799 | host: input.host, | |
| 2800 | operation: "updatePullRequest", | |
| 2801 | query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 2802 | // A field the caller did not name is left out of the request entirely, so GitHub | |
| 2803 | // keeps the words that are there rather than being asked for an empty one. | |
| 2804 | variables: { | |
| 2805 | pullRequestId, | |
| 2806 | ...(input.title === undefined ? {} : { title: input.title }), | |
| 2807 | ...(input.body === undefined ? {} : { body: input.body }), | |
| 2808 | }, | |
| 2809 | }), | |
| 2810 | ), | |
| 2811 | ), | |
| 2812 | ||
| 2813 | updateComment: (input) => | |
| 2814 | subjectBelongsToPullRequest({ | |
| 2815 | cwd: input.cwd, | |
| 2816 | repository: input.repository, | |
| 2817 | host: input.host, | |
| 2818 | number: input.number, | |
| 2819 | subjectId: input.commentId, | |
| 2820 | operation: "updateComment", | |
| 2821 | }).pipe( | |
| 2822 | Effect.flatMap((belongs) => | |
| 2823 | belongs | |
| 2824 | ? Effect.succeed(input.commentId) | |
| 2825 | : Effect.fail( | |
| 2826 | new GitHubSubjectScopeError({ | |
| 2827 | cwd: input.cwd, | |
| 2828 | operation: "updateComment", | |
| 2829 | }), | |
| 2830 | ), | |
| 2831 | ), | |
| 2832 | Effect.flatMap((commentId) => | |
| 2833 | graphql({ | |
| 2834 | host: input.host, | |
| 2835 | operation: "updateComment", | |
| 2836 | query: | |
| 2837 | input.kind === "issue-comment" | |
| 2838 | ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION | |
| 2839 | : UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, | |
| 2840 | variables: { commentId, body: input.body }, | |
| 2841 | }), | |
| 2842 | ), | |
| 2843 | ), | |
| 2844 | }); | |
| 2845 | }); | |
| 2846 | ||
| 2847 | export const layer = Layer.effect(GitHubPullRequestApi, make); |
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts deleted-4545| ... | ... | @@ -1,4545 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; | |
| 3 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Clock from "effect/Clock"; | |
| 6 | import * as Deferred from "effect/Deferred"; | |
| 7 | import * as Fiber from "effect/Fiber"; | |
| 8 | import * as Layer from "effect/Layer"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | import * as TestClock from "effect/testing/TestClock"; | |
| 11 | import * as Redacted from "effect/Redacted"; | |
| 12 | ||
| 13 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 14 | ||
| 15 | import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; | |
| 16 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 17 | import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; | |
| 18 | import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; | |
| 19 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; | |
| 20 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 21 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; | |
| 22 | import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; | |
| 23 | ||
| 24 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 25 | ||
| 26 | const coreResponse = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({ | |
| 27 | data: { | |
| 28 | repository: { | |
| 29 | mergeCommitAllowed: true, | |
| 30 | squashMergeAllowed: false, | |
| 31 | rebaseMergeAllowed: true, | |
| 32 | viewerPermission: "WRITE", | |
| 33 | pullRequest: { | |
| 34 | number: 7, | |
| 35 | title: "Pull request 7", | |
| 36 | url: "https://github.com/acme/web/pull/7", | |
| 37 | headRefName: "feature", | |
| 38 | baseRefName: "main", | |
| 39 | headRefOid: "abc123", | |
| 40 | state: "OPEN", | |
| 41 | createdAt: "2026-07-01T00:00:00Z", | |
| 42 | updatedAt: "2026-07-02T00:00:00Z", | |
| 43 | viewerCanUpdate: true, | |
| 44 | viewerDidAuthor: false, | |
| 45 | viewerCanUpdateBranch: true, | |
| 46 | baseRef: { compare: { behindBy: 2 } }, | |
| 47 | reviewRequests: { nodes: [] }, | |
| 48 | labels: { nodes: [] }, | |
| 49 | commits: { nodes: [] }, | |
| 50 | ...pullRequest, | |
| 51 | }, | |
| 52 | }, | |
| 53 | }, | |
| 54 | }); | |
| 55 | ||
| 56 | /** One request the CLI made, as the transport received it. */ | |
| 57 | type ApiCall = | |
| 58 | | ({ readonly kind: "graphql" } & GitHubApi.GitHubGraphQlInput) | |
| 59 | | ({ readonly kind: "rest" } & GitHubApi.GitHubRestInput); | |
| 60 | ||
| 61 | type ApiAnswer = Effect.Effect<GitHubApi.GitHubRestResponse, GitHubApi.GitHubApiError>; | |
| 62 | ||
| 63 | const mockedExecute = vi.fn<(call: ApiCall) => ApiAnswer>(); | |
| 64 | const defaultCredential = (host: string) => | |
| 65 | Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:token` }); | |
| 66 | const mockedCredential = vi.fn(defaultCredential); | |
| 67 | const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() => | |
| 68 | Effect.succeed(output('{"data":{}}')), | |
| 69 | ); | |
| 70 | ||
| 71 | /** | |
| 72 | * A transport that answers from the mocks above but spends the real GraphQL budget, the way | |
| 73 | * GitHubApi does, so the reserve and pause behaviour is still the module's to prove. | |
| 74 | */ | |
| 75 | const mockApi = Layer.effect( | |
| 76 | GitHubApi.GitHubApi, | |
| 77 | Effect.gen(function* () { | |
| 78 | const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; | |
| 79 | return GitHubApi.GitHubApi.of({ | |
| 80 | graphql: (input) => | |
| 81 | budget | |
| 82 | .query(input.host, input.query, input.allowReserve ? { allowReserve: true } : undefined) | |
| 83 | .pipe( | |
| 84 | Effect.flatMap((query) => | |
| 85 | (input.query.includes("query PullRequestStackMemberships") | |
| 86 | ? mockedStackMemberships | |
| 87 | : mockedExecute)({ kind: "graphql", ...input, query }), | |
| 88 | ), | |
| 89 | Effect.map((answer) => answer.body), | |
| 90 | Effect.tap((body) => budget.observe(input.host, body)), | |
| 91 | ), | |
| 92 | rest: (input) => mockedExecute({ kind: "rest", ...input }), | |
| 93 | credential: (host) => mockedCredential(host), | |
| 94 | }); | |
| 95 | }), | |
| 96 | ); | |
| 97 | ||
| 98 | const layer = it.layer( | |
| 99 | GitHubPullRequestCli.layer.pipe( | |
| 100 | Layer.provideMerge(mockApi), | |
| 101 | Layer.provideMerge(GitHubGraphQlBudget.layer), | |
| 102 | Layer.provide(NodeCrypto.layer), | |
| 103 | Layer.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))), | |
| 104 | ), | |
| 105 | ); | |
| 106 | ||
| 107 | /** A successful answer; GraphQL reads take its body. */ | |
| 108 | function output( | |
| 109 | body: string, | |
| 110 | truncated = false, | |
| 111 | invalidUtf8 = false, | |
| 112 | ): GitHubApi.GitHubRestResponse { | |
| 113 | return { status: 200, headers: {}, body, truncated, invalidUtf8 }; | |
| 114 | } | |
| 115 | ||
| 116 | /** | |
| 117 | * Listing rows as GraphQL answers them. Overrides may use the flat shapes a test reads most | |
| 118 | * easily: `reviewRequests` as `[{ login }]` or `[{ slug }]`, and `checks` as the rollup state. | |
| 119 | */ | |
| 120 | function rows( | |
| 121 | count: number, | |
| 122 | firstNumber: number, | |
| 123 | overrides: (number: number) => Readonly<Record<string, unknown>> = () => ({}), | |
| 124 | ): ReadonlyArray<Record<string, unknown>> { | |
| 125 | return Array.from({ length: count }, (_, index) => { | |
| 126 | const number = firstNumber + index; | |
| 127 | const { reviewRequests, checks, ...rest } = overrides(number) as { | |
| 128 | reviewRequests?: ReadonlyArray<Record<string, string>>; | |
| 129 | checks?: string; | |
| 130 | } & Record<string, unknown>; | |
| 131 | return { | |
| 132 | number, | |
| 133 | title: `Pull request ${number}`, | |
| 134 | url: `https://github.com/acme/web/pull/${number}`, | |
| 135 | headRefName: "feat/page", | |
| 136 | baseRefName: "main", | |
| 137 | createdAt: "2026-07-01T00:00:00Z", | |
| 138 | updatedAt: "2026-07-02T00:00:00Z", | |
| 139 | repository: { nameWithOwner: "acme/web" }, | |
| 140 | ...(reviewRequests === undefined | |
| 141 | ? {} | |
| 142 | : { | |
| 143 | reviewRequests: { | |
| 144 | nodes: reviewRequests.map((requestedReviewer) => ({ requestedReviewer })), | |
| 145 | }, | |
| 146 | }), | |
| 147 | ...(checks === undefined | |
| 148 | ? {} | |
| 149 | : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }), | |
| 150 | ...rest, | |
| 151 | }; | |
| 152 | }); | |
| 153 | } | |
| 154 | ||
| 155 | /** A page of the searched listing. */ | |
| 156 | function pullRequests( | |
| 157 | count: number, | |
| 158 | firstNumber: number, | |
| 159 | overrides?: (number: number) => Readonly<Record<string, unknown>>, | |
| 160 | ): string { | |
| 161 | return encodeJson({ | |
| 162 | data: { | |
| 163 | search: { pageInfo: { hasNextPage: false }, nodes: rows(count, firstNumber, overrides) }, | |
| 164 | }, | |
| 165 | }); | |
| 166 | } | |
| 167 | ||
| 168 | /** A page of a repository's own list, which is what the search-free fallback reads. */ | |
| 169 | function listedPullRequests( | |
| 170 | count: number, | |
| 171 | firstNumber: number, | |
| 172 | overrides?: (number: number) => Readonly<Record<string, unknown>>, | |
| 173 | ): string { | |
| 174 | return encodeJson({ | |
| 175 | data: { | |
| 176 | repository: { | |
| 177 | pullRequests: { | |
| 178 | pageInfo: { hasNextPage: false }, | |
| 179 | nodes: rows(count, firstNumber, overrides), | |
| 180 | }, | |
| 181 | }, | |
| 182 | }, | |
| 183 | }); | |
| 184 | } | |
| 185 | ||
| 186 | /** A search that found nothing, which is also what GitHub says for a repository it does not index. */ | |
| 187 | const emptySearch = () => output(pullRequests(0, 1)); | |
| 188 | const emptyList = () => output(listedPullRequests(0, 1)); | |
| 189 | ||
| 190 | function pullRequestFiles(count: number, firstIndex: number): string { | |
| 191 | return encodeJson( | |
| 192 | Array.from({ length: count }, (_, index) => ({ | |
| 193 | filename: `src/file${firstIndex + index}.ts`, | |
| 194 | status: "modified", | |
| 195 | patch: "@@ -1 +1 @@\n-old\n+new", | |
| 196 | })), | |
| 197 | ); | |
| 198 | } | |
| 199 | ||
| 200 | /** One thread's comments as the GraphQL read returns them, cursor and all. */ | |
| 201 | function threadComments( | |
| 202 | ids: ReadonlyArray<string>, | |
| 203 | endCursor: string | null, | |
| 204 | totalCount = ids.length, | |
| 205 | ) { | |
| 206 | return { | |
| 207 | totalCount, | |
| 208 | pageInfo: { hasNextPage: endCursor !== null, endCursor }, | |
| 209 | nodes: ids.map((id) => ({ id, body: id, createdAt: "2026-07-01T00:00:00Z" })), | |
| 210 | }; | |
| 211 | } | |
| 212 | ||
| 213 | function thread(id: string, ...commentIds: ReadonlyArray<string>) { | |
| 214 | return { | |
| 215 | id, | |
| 216 | path: "src/a.ts", | |
| 217 | line: 1, | |
| 218 | diffSide: "RIGHT", | |
| 219 | isResolved: false, | |
| 220 | isOutdated: false, | |
| 221 | comments: threadComments(commentIds, null), | |
| 222 | }; | |
| 223 | } | |
| 224 | ||
| 225 | function reviewThreadsPage( | |
| 226 | nodes: ReadonlyArray<Record<string, unknown>>, | |
| 227 | endCursor: string | null, | |
| 228 | ): string { | |
| 229 | return encodeJson({ | |
| 230 | data: { | |
| 231 | repository: { | |
| 232 | pullRequest: { | |
| 233 | reviewThreads: { | |
| 234 | totalCount: nodes.length, | |
| 235 | pageInfo: { hasNextPage: endCursor !== null, endCursor }, | |
| 236 | nodes, | |
| 237 | }, | |
| 238 | }, | |
| 239 | }, | |
| 240 | }, | |
| 241 | }); | |
| 242 | } | |
| 243 | ||
| 244 | function threadCommentsPage( | |
| 245 | ids: ReadonlyArray<string>, | |
| 246 | endCursor: string | null, | |
| 247 | totalCount: number, | |
| 248 | pullRequestId = "PR_7", | |
| 249 | ): string { | |
| 250 | return encodeJson({ | |
| 251 | data: { | |
| 252 | repository: { pullRequest: { id: "PR_7" } }, | |
| 253 | node: { | |
| 254 | pullRequest: { id: pullRequestId }, | |
| 255 | comments: threadComments(ids, endCursor, totalCount), | |
| 256 | }, | |
| 257 | }, | |
| 258 | }); | |
| 259 | } | |
| 260 | ||
| 261 | /** What GitHub answers for a pull request it will not serve a whole diff for. */ | |
| 262 | const diffRefused = new GitHubApi.GitHubApiResponseError({ | |
| 263 | host: "github.com", | |
| 264 | operation: "getPullRequestDiff", | |
| 265 | status: 406, | |
| 266 | }); | |
| 267 | ||
| 268 | /** The nth request the CLI made. */ | |
| 269 | function callAt(index: number): ApiCall { | |
| 270 | const call = mockedExecute.mock.calls[index]; | |
| 271 | assert.isDefined(call); | |
| 272 | return call[0]; | |
| 273 | } | |
| 274 | ||
| 275 | /** The GraphQL variables of the nth request. */ | |
| 276 | function varsAt(index: number): Readonly<Record<string, unknown>> { | |
| 277 | const call = callAt(index); | |
| 278 | assert.strictEqual(call.kind, "graphql"); | |
| 279 | return call.kind === "graphql" ? (call.variables ?? {}) : {}; | |
| 280 | } | |
| 281 | ||
| 282 | /** The GraphQL document of the nth request. */ | |
| 283 | function queryAt(index: number): string { | |
| 284 | const call = callAt(index); | |
| 285 | return call.kind === "graphql" ? call.query : ""; | |
| 286 | } | |
| 287 | ||
| 288 | /** The REST path of the nth request. */ | |
| 289 | function pathAt(index: number): string { | |
| 290 | const call = callAt(index); | |
| 291 | return call.kind === "rest" ? call.path : ""; | |
| 292 | } | |
| 293 | ||
| 294 | /** The search the nth request carried, or undefined for a read that did not search. */ | |
| 295 | function searchOfCall(index: number): string | undefined { | |
| 296 | const q = callAt(index).kind === "graphql" ? varsAt(index)["q"] : undefined; | |
| 297 | return typeof q === "string" ? q : undefined; | |
| 298 | } | |
| 299 | ||
| 300 | /** One row as a search answers it, which is the listing's row one connection deeper. */ | |
| 301 | function searchItem(number: number, repository: string, updatedAt: string) { | |
| 302 | return { | |
| 303 | number, | |
| 304 | title: `Pull request ${number}`, | |
| 305 | url: `https://github.com/${repository}/pull/${number}`, | |
| 306 | author: { login: "octocat", avatarUrl: "https://avatars/octocat" }, | |
| 307 | headRefName: "feat/page", | |
| 308 | baseRefName: "main", | |
| 309 | state: "OPEN", | |
| 310 | isDraft: false, | |
| 311 | mergeable: "MERGEABLE", | |
| 312 | createdAt: "2026-07-01T00:00:00Z", | |
| 313 | updatedAt, | |
| 314 | repository: { nameWithOwner: repository }, | |
| 315 | reviewRequests: { nodes: [{ requestedReviewer: { login: "hubot" } }] }, | |
| 316 | labels: { nodes: [{ name: "bug", color: "ff0000" }] }, | |
| 317 | }; | |
| 318 | } | |
| 319 | ||
| 320 | function searchPage(nodes: ReadonlyArray<unknown>, hasNextPage = false) { | |
| 321 | return output(encodeJson({ data: { search: { pageInfo: { hasNextPage }, nodes } } })); | |
| 322 | } | |
| 323 | ||
| 324 | const searchQueryOfCall = searchOfCall; | |
| 325 | ||
| 326 | /** | |
| 327 | * Answers every request with the body of the first route whose needle its GraphQL document or | |
| 328 | * REST path contains, and anything unrouted with an empty object, which a mutation never reads. | |
| 329 | */ | |
| 330 | function route(...routes: ReadonlyArray<readonly [needle: string, body: unknown]>): void { | |
| 331 | mockedExecute.mockImplementation((call) => { | |
| 332 | const target = call.kind === "graphql" ? call.query : call.path; | |
| 333 | const match = routes.find(([needle]) => target.includes(needle)); | |
| 334 | return Effect.succeed(output(match === undefined ? "{}" : encodeJson(match[1]))); | |
| 335 | }); | |
| 336 | } | |
| 337 | ||
| 338 | /** The variables of every GraphQL request whose document contains `needle`, in order. */ | |
| 339 | function variablesOf(needle: string): ReadonlyArray<Readonly<Record<string, unknown>>> { | |
| 340 | return mockedExecute.mock.calls.flatMap(([call]) => | |
| 341 | call.kind === "graphql" && call.query.includes(needle) ? [call.variables ?? {}] : [], | |
| 342 | ); | |
| 343 | } | |
| 344 | ||
| 345 | /** Every REST request whose path contains `needle`, in order. */ | |
| 346 | function restCallsTo(needle: string): ReadonlyArray<GitHubApi.GitHubRestInput> { | |
| 347 | return mockedExecute.mock.calls.flatMap(([call]) => | |
| 348 | call.kind === "rest" && call.path.includes(needle) ? [call] : [], | |
| 349 | ); | |
| 350 | } | |
| 351 | ||
| 352 | /** | |
| 353 | * The check that a client-given node hangs off the pull request it names. Routed before the node | |
| 354 | * id lookup, whose document it contains. | |
| 355 | */ | |
| 356 | const SUBJECT_SCOPE_QUERY = "node(id: $subjectId)"; | |
| 357 | const subjectScope = (subjectId: string, pullRequestId: string) => ({ | |
| 358 | data: { | |
| 359 | repository: { pullRequest: { id: pullRequestId } }, | |
| 360 | node: { id: subjectId, pullRequest: { id: pullRequestId } }, | |
| 361 | }, | |
| 362 | }); | |
| 363 | ||
| 364 | /** One page of a head commit's check contexts, the read a rollup past one page is walked by. */ | |
| 365 | const checkContextsPage = ( | |
| 366 | nodes: ReadonlyArray<Record<string, unknown>>, | |
| 367 | endCursor: string | null, | |
| 368 | headRefOid = "abc123", | |
| 369 | ) => | |
| 370 | encodeJson({ | |
| 371 | data: { | |
| 372 | repository: { | |
| 373 | pullRequest: { | |
| 374 | headRefOid, | |
| 375 | commits: { | |
| 376 | nodes: [ | |
| 377 | { | |
| 378 | commit: { | |
| 379 | statusCheckRollup: { | |
| 380 | contexts: { nodes, pageInfo: { hasNextPage: endCursor !== null, endCursor } }, | |
| 381 | }, | |
| 382 | }, | |
| 383 | }, | |
| 384 | ], | |
| 385 | }, | |
| 386 | }, | |
| 387 | }, | |
| 388 | }, | |
| 389 | }); | |
| 390 | ||
| 391 | /** A pull request's base and head, as the REST read a file expansion starts from answers them. */ | |
| 392 | const pullRequestRefs = encodeJson({ base: { sha: "a1b2c3d" }, head: { sha: "b1c2d3e" } }); | |
| 393 | ||
| 394 | /** A cross-repository pull request whose head waits on a maintainer to run its workflows. */ | |
| 395 | const crossRepositoryDetail = (headRefOid = "abc123") => | |
| 396 | coreResponse({ | |
| 397 | headRefName: "feat/page", | |
| 398 | headRefOid, | |
| 399 | isCrossRepository: true, | |
| 400 | headRepositoryOwner: { login: "octocat" }, | |
| 401 | }); | |
| 402 | ||
| 403 | /** Open pull requests on one head branch, every one of them from the same fork head. */ | |
| 404 | const heads = (numbers: ReadonlyArray<number>) => ({ | |
| 405 | data: { | |
| 406 | repository: { | |
| 407 | pullRequests: { | |
| 408 | pageInfo: { hasNextPage: false, endCursor: null }, | |
| 409 | nodes: numbers.map((number) => ({ | |
| 410 | number, | |
| 411 | headRefOid: "abc123", | |
| 412 | isCrossRepository: true, | |
| 413 | headRepositoryOwner: { login: "octocat" }, | |
| 414 | })), | |
| 415 | }, | |
| 416 | }, | |
| 417 | }, | |
| 418 | }); | |
| 419 | ||
| 420 | const workflowRuns = (ids: ReadonlyArray<number>) => ({ | |
| 421 | workflow_runs: ids.map((id) => ({ | |
| 422 | id, | |
| 423 | name: id === 10 ? "build" : `run ${id}`, | |
| 424 | html_url: `https://example.com/${id}`, | |
| 425 | })), | |
| 426 | }); | |
| 427 | ||
| 428 | /** Answers the reads an approval makes: the detail, the heads, the runs; approvals return nothing. */ | |
| 429 | function workflowApprovalRoutes( | |
| 430 | detail: () => unknown, | |
| 431 | headsAnswer: unknown, | |
| 432 | runsAnswer: unknown, | |
| 433 | ): void { | |
| 434 | mockedExecute.mockImplementation((call) => | |
| 435 | Effect.sync(() => | |
| 436 | output( | |
| 437 | call.kind === "rest" | |
| 438 | ? call.path.endsWith("/approve") | |
| 439 | ? "" | |
| 440 | : encodeJson(runsAnswer) | |
| 441 | : call.query.includes("headRefName: $head") | |
| 442 | ? encodeJson(headsAnswer) | |
| 443 | : encodeJson(detail()), | |
| 444 | ), | |
| 445 | ), | |
| 446 | ); | |
| 447 | } | |
| 448 | ||
| 449 | /** The pull request node id lookup, which the layer caches for every test after the first. */ | |
| 450 | const NODE_ID_QUERY = "pullRequest(number: $number) { id }"; | |
| 451 | const nodeIdAnswer = (id: string) => ({ data: { repository: { pullRequest: { id } } } }); | |
| 452 | ||
| 453 | afterEach(() => { | |
| 454 | mockedExecute.mockReset(); | |
| 455 | mockedStackMemberships.mockReset(); | |
| 456 | mockedCredential.mockReset(); | |
| 457 | mockedCredential.mockImplementation(defaultCredential); | |
| 458 | }); | |
| 459 | ||
| 460 | it.effect( | |
| 461 | "keeps a verified credential through an auth switch and separates token fingerprints", | |
| 462 | () => | |
| 463 | Effect.gen(function* () { | |
| 464 | let activeToken = "broad-credential"; | |
| 465 | const requests: Array<{ readonly url: string; readonly authorization: string | undefined }> = | |
| 466 | []; | |
| 467 | const credentials = Layer.succeed( | |
| 468 | GitHubCredentials.GitHubCredentials, | |
| 469 | GitHubCredentials.GitHubCredentials.of({ | |
| 470 | get: (host) => | |
| 471 | Effect.sync(() => ({ | |
| 472 | host, | |
| 473 | token: Redacted.make(activeToken), | |
| 474 | source: "gh" as const, | |
| 475 | fingerprint: `${host}:${activeToken.length}${activeToken.at(-1)}`, | |
| 476 | })), | |
| 477 | invalidate: () => Effect.void, | |
| 478 | }), | |
| 479 | ); | |
| 480 | const http = Layer.succeed( | |
| 481 | HttpClient.HttpClient, | |
| 482 | HttpClient.make((request) => | |
| 483 | Effect.sync(() => { | |
| 484 | requests.push({ url: request.url, authorization: request.headers["authorization"] }); | |
| 485 | const body = request.url.endsWith("/user") | |
| 486 | ? { id: 123, login: "same-account" } | |
| 487 | : { data: { repository: { pullRequest: { id: "PR_1" } }, addComment: {} } }; | |
| 488 | return HttpClientResponse.fromWeb(request, new Response(encodeJson(body))); | |
| 489 | }), | |
| 490 | ), | |
| 491 | ); | |
| 492 | const cli = yield* GitHubPullRequestCli.make.pipe( | |
| 493 | Effect.provide( | |
| 494 | GitHubApi.layer.pipe( | |
| 495 | Layer.provide(Layer.mergeAll(credentials, http)), | |
| 496 | Layer.provide(GitHubGraphQlBudget.layer), | |
| 497 | Layer.provide(SourceControlRateLimit.layer), | |
| 498 | Layer.merge(VcsProcess.layer), | |
| 499 | Layer.provideMerge(NodeServices.layer), | |
| 500 | ), | |
| 501 | ), | |
| 502 | ); | |
| 503 | const input = { cwd: "/repo", host: "github.com" }; | |
| 504 | const first = yield* cli.withVerifiedCredential(input, (identity) => | |
| 505 | Effect.gen(function* () { | |
| 506 | activeToken = "restricted-credential"; | |
| 507 | expect(yield* cli.getViewerLogin(input)).toBe("same-account"); | |
| 508 | yield* cli.commentOnPullRequest({ | |
| 509 | ...input, | |
| 510 | repository: "owner/repo", | |
| 511 | number: 1, | |
| 512 | body: "comment", | |
| 513 | }); | |
| 514 | return identity; | |
| 515 | }), | |
| 516 | ); | |
| 517 | const second = yield* cli.withVerifiedCredential(input, Effect.succeed); | |
| 518 | expect(first.accountId).toBe(second.accountId); | |
| 519 | expect(first.credentialFingerprint).not.toBe(second.credentialFingerprint); | |
| 520 | expect(encodeJson([first, second])).not.toContain("broad-credential"); | |
| 521 | expect(encodeJson([first, second])).not.toContain("restricted-credential"); | |
| 522 | // The comment was written under the credential the page verified, not the switched one. | |
| 523 | expect( | |
| 524 | requests | |
| 525 | .filter((request) => request.url.endsWith("/graphql")) | |
| 526 | .map((request) => request.authorization), | |
| 527 | ).toEqual(["Bearer broad-credential", "Bearer broad-credential"]); | |
| 528 | expect( | |
| 529 | requests | |
| 530 | .filter((request) => request.url.endsWith("/user")) | |
| 531 | .map((request) => request.authorization), | |
| 532 | ).toEqual(["Bearer broad-credential", "Bearer restricted-credential"]); | |
| 533 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | |
| 534 | accountId: "123", | |
| 535 | viewer: "same-account", | |
| 536 | }); | |
| 537 | }), | |
| 538 | ); | |
| 539 | ||
| 540 | layer("GitHubPullRequestCli.layer", (it) => { | |
| 541 | it.effect("admits only one concurrent preview above the reserve and resumes after reset", () => | |
| 542 | Effect.gen(function* () { | |
| 543 | const now = yield* Clock.currentTimeMillis; | |
| 544 | const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; | |
| 545 | const resetAt = "2099-08-13T14:00:00Z"; | |
| 546 | yield* budget.observe( | |
| 547 | "preview-budget.example", | |
| 548 | encodeJson({ data: { rateLimit: { cost: 1, limit: 5_000, remaining: 501, resetAt } } }), | |
| 549 | ); | |
| 550 | mockedExecute.mockReturnValue( | |
| 551 | Effect.succeed( | |
| 552 | output( | |
| 553 | encodeJson({ | |
| 554 | data: { | |
| 555 | repository: { | |
| 556 | pullRequest: { | |
| 557 | number: 7, | |
| 558 | title: "Fast previews", | |
| 559 | url: "https://preview-budget.example/acme/web/pull/7", | |
| 560 | state: "OPEN", | |
| 561 | isDraft: false, | |
| 562 | createdAt: "2026-07-01T00:00:00Z", | |
| 563 | author: null, | |
| 564 | }, | |
| 565 | }, | |
| 566 | rateLimit: { cost: 1, limit: 5_000, remaining: 500, resetAt }, | |
| 567 | }, | |
| 568 | }), | |
| 569 | ), | |
| 570 | ), | |
| 571 | ); | |
| 572 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 573 | const input = { | |
| 574 | cwd: "/w", | |
| 575 | repository: "acme/web", | |
| 576 | host: "preview-budget.example", | |
| 577 | number: 7, | |
| 578 | }; | |
| 579 | const results = yield* Effect.all( | |
| 580 | Array.from({ length: 20 }, (_, index) => | |
| 581 | cli.getPullRequestPreview({ ...input, number: index + 1 }).pipe(Effect.result), | |
| 582 | ), | |
| 583 | { concurrency: "unbounded" }, | |
| 584 | ); | |
| 585 | expect(results.filter((result) => result._tag === "Success")).toHaveLength(1); | |
| 586 | for (const result of results) { | |
| 587 | if (result._tag === "Failure") { | |
| 588 | expect(result.failure._tag).toBe("SourceControlRateLimitPausedError"); | |
| 589 | } | |
| 590 | } | |
| 591 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 592 | yield* TestClock.setTime(Date.parse(resetAt)); | |
| 593 | yield* cli.getPullRequestPreview(input); | |
| 594 | expect(mockedExecute).toHaveBeenCalledTimes(2); | |
| 595 | yield* TestClock.setTime(now); | |
| 596 | }), | |
| 597 | ); | |
| 598 | ||
| 599 | it.effect("loads the complete hover card with one GraphQL request", () => | |
| 600 | Effect.gen(function* () { | |
| 601 | mockedExecute.mockReturnValueOnce( | |
| 602 | Effect.succeed( | |
| 603 | output( | |
| 604 | encodeJson({ | |
| 605 | data: { | |
| 606 | repository: { | |
| 607 | pullRequest: { | |
| 608 | number: 7, | |
| 609 | title: "Fast previews", | |
| 610 | url: "https://github.example/acme/web/pull/7", | |
| 611 | state: "MERGED", | |
| 612 | isDraft: false, | |
| 613 | createdAt: "2026-07-01T00:00:00Z", | |
| 614 | author: { | |
| 615 | login: "octocat", | |
| 616 | name: "Octo Cat", | |
| 617 | avatarUrl: "https://github.example/avatar.png", | |
| 618 | }, | |
| 619 | }, | |
| 620 | }, | |
| 621 | }, | |
| 622 | }), | |
| 623 | ), | |
| 624 | ), | |
| 625 | ); | |
| 626 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 627 | const preview = yield* cli.getPullRequestPreview({ | |
| 628 | cwd: "/w", | |
| 629 | repository: "acme/web", | |
| 630 | host: "github.example", | |
| 631 | number: 7, | |
| 632 | }); | |
| 633 | expect(preview).toEqual({ | |
| 634 | number: 7, | |
| 635 | title: "Fast previews", | |
| 636 | url: "https://github.example/acme/web/pull/7", | |
| 637 | state: "merged", | |
| 638 | isDraft: false, | |
| 639 | createdAt: "2026-07-01T00:00:00Z", | |
| 640 | author: { | |
| 641 | login: "octocat", | |
| 642 | name: "Octo Cat", | |
| 643 | avatarUrl: "https://github.example/avatar.png", | |
| 644 | }, | |
| 645 | }); | |
| 646 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 647 | expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.example" }); | |
| 648 | }), | |
| 649 | ); | |
| 650 | ||
| 651 | it.effect("coalesces concurrent identity verification for the same host and credential", () => | |
| 652 | Effect.gen(function* () { | |
| 653 | mockedExecute.mockImplementation(() => | |
| 654 | Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))), | |
| 655 | ); | |
| 656 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 657 | const results = yield* Effect.all( | |
| 658 | Array.from({ length: 4 }, () => | |
| 659 | cli.getRoutingIdentity({ cwd: "/w", host: "github.identity-flight.test" }), | |
| 660 | ), | |
| 661 | { concurrency: 4 }, | |
| 662 | ); | |
| 663 | expect(results).toEqual( | |
| 664 | Array.from({ length: 4 }, () => ({ accountId: "123", viewer: "viewer" })), | |
| 665 | ); | |
| 666 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 667 | expect(pathAt(0)).toBe("user"); | |
| 668 | }), | |
| 669 | ); | |
| 670 | ||
| 671 | it.effect( | |
| 672 | "lets another identity reader continue when the first verification is interrupted", | |
| 673 | () => | |
| 674 | Effect.gen(function* () { | |
| 675 | const firstStarted = yield* Deferred.make<void>(); | |
| 676 | let verifications = 0; | |
| 677 | mockedExecute.mockImplementation(() => | |
| 678 | Effect.gen(function* () { | |
| 679 | if (++verifications === 1) { | |
| 680 | yield* Deferred.succeed(firstStarted, undefined); | |
| 681 | return yield* Effect.never; | |
| 682 | } | |
| 683 | return output('{"id":123,"login":"viewer"}'); | |
| 684 | }), | |
| 685 | ); | |
| 686 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 687 | const input = { cwd: "/w", host: "github.identity-cancel.test" }; | |
| 688 | const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); | |
| 689 | yield* Deferred.await(firstStarted); | |
| 690 | // The second reader is queued on the same credential's lock behind the first. | |
| 691 | const second = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); | |
| 692 | yield* Effect.yieldNow; | |
| 693 | yield* Fiber.interrupt(first); | |
| 694 | expect(yield* Fiber.join(second)).toEqual({ accountId: "123", viewer: "viewer" }); | |
| 695 | expect(verifications).toBe(2); | |
| 696 | }), | |
| 697 | ); | |
| 698 | ||
| 699 | it.effect("reads linked pull requests on one host together, filed back by position", () => | |
| 700 | Effect.gen(function* () { | |
| 701 | const node = (number: number) => ({ | |
| 702 | number, | |
| 703 | title: `Pull request ${number}`, | |
| 704 | url: `https://github.com/acme/web/pull/${number}`, | |
| 705 | author: { __typename: "User", login: "octocat", name: "Octo Cat", avatarUrl: null }, | |
| 706 | baseRefName: "main", | |
| 707 | headRefName: `feat/${number}`, | |
| 708 | state: "OPEN", | |
| 709 | isDraft: false, | |
| 710 | mergeable: "MERGEABLE", | |
| 711 | reviewDecision: null, | |
| 712 | latestReviews: { nodes: [{ state: "APPROVED", author: { login: "reviewer" } }] }, | |
| 713 | additions: 12, | |
| 714 | deletions: 3, | |
| 715 | changedFiles: 2, | |
| 716 | updatedAt: "2026-08-24T12:34:56.000Z", | |
| 717 | mergedAt: null, | |
| 718 | closedAt: null, | |
| 719 | commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, | |
| 720 | }); | |
| 721 | mockedExecute.mockReturnValueOnce( | |
| 722 | Effect.succeed( | |
| 723 | output( | |
| 724 | encodeJson({ | |
| 725 | data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } }, | |
| 726 | }), | |
| 727 | ), | |
| 728 | ), | |
| 729 | ); | |
| 730 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 731 | ||
| 732 | const reads = yield* Effect.forEach( | |
| 733 | [7, 8], | |
| 734 | (number) => | |
| 735 | cli.getPullRequestSummary({ | |
| 736 | cwd: "/w", | |
| 737 | repository: "acme/web", | |
| 738 | host: "github.com", | |
| 739 | number, | |
| 740 | }), | |
| 741 | { concurrency: "unbounded" }, | |
| 742 | ).pipe(Effect.forkChild); | |
| 743 | yield* TestClock.adjust("10 millis"); | |
| 744 | const [seven, eight] = yield* Fiber.join(reads); | |
| 745 | ||
| 746 | assert.deepStrictEqual( | |
| 747 | { | |
| 748 | number: seven?.number, | |
| 749 | state: seven?.state, | |
| 750 | headBranch: seven?.headBranch, | |
| 751 | author: seven?.author?.login, | |
| 752 | changedFiles: seven?.changedFiles, | |
| 753 | reviewDecision: seven?.reviewDecision, | |
| 754 | checksState: seven?.checksState, | |
| 755 | mergeability: seven?.mergeability, | |
| 756 | }, | |
| 757 | { | |
| 758 | number: 7, | |
| 759 | state: "open", | |
| 760 | headBranch: "feat/7", | |
| 761 | author: "octocat", | |
| 762 | changedFiles: 2, | |
| 763 | reviewDecision: "approved", | |
| 764 | checksState: "passing", | |
| 765 | mergeability: "mergeable", | |
| 766 | }, | |
| 767 | ); | |
| 768 | assert.strictEqual(eight?.headBranch, "feat/8"); | |
| 769 | expect(mockedExecute).toHaveBeenCalledOnce(); | |
| 770 | const document = queryAt(0); | |
| 771 | expect(document).toContain( | |
| 772 | 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)', | |
| 773 | ); | |
| 774 | expect(document).toContain("pullRequest(number: 8)"); | |
| 775 | }), | |
| 776 | ); | |
| 777 | ||
| 778 | it.effect("fingerprints watched pull requests on one host in one read", () => | |
| 779 | Effect.gen(function* () { | |
| 780 | const node = (comments: number) => ({ | |
| 781 | state: "OPEN", | |
| 782 | mergeable: "MERGEABLE", | |
| 783 | headRefOid: "abc123", | |
| 784 | comments: { totalCount: comments, nodes: [] }, | |
| 785 | reviews: { totalCount: 0, nodes: [] }, | |
| 786 | reviewThreads: { totalCount: 0 }, | |
| 787 | commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, | |
| 788 | }); | |
| 789 | mockedExecute.mockReturnValueOnce( | |
| 790 | Effect.succeed( | |
| 791 | output( | |
| 792 | JSON.stringify({ | |
| 793 | data: { w0: { pullRequest: node(1) }, w1: { pullRequest: null } }, | |
| 794 | }), | |
| 795 | ), | |
| 796 | ), | |
| 797 | ); | |
| 798 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 799 | ||
| 800 | const reads = yield* Effect.forEach( | |
| 801 | [7, 8], | |
| 802 | (number) => | |
| 803 | cli.getPullRequestWatchFingerprint({ | |
| 804 | cwd: "/w", | |
| 805 | repository: "acme/web", | |
| 806 | host: "github.com", | |
| 807 | number, | |
| 808 | }), | |
| 809 | { concurrency: "unbounded" }, | |
| 810 | ).pipe(Effect.forkChild); | |
| 811 | yield* TestClock.adjust("10 millis"); | |
| 812 | const [seven, eight] = yield* Fiber.join(reads); | |
| 813 | ||
| 814 | expect(seven?.remarks.startsWith("1 ")).toBe(true); | |
| 815 | // GitHub had no answer for #8, so its watch reads it in full. | |
| 816 | expect(eight).toBeNull(); | |
| 817 | 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)', | |
| 821 | ); | |
| 822 | }), | |
| 823 | ); | |
| 824 | ||
| 825 | it.effect("reads a pull request the batch said nothing about on its own", () => | |
| 826 | Effect.gen(function* () { | |
| 827 | mockedExecute | |
| 828 | .mockReturnValueOnce(Effect.succeed(output('{"data":{"s0":{"pullRequest":null}}}'))) | |
| 829 | .mockReturnValueOnce( | |
| 830 | Effect.succeed( | |
| 831 | output( | |
| 832 | encodeJson({ | |
| 833 | data: { | |
| 834 | s0: { | |
| 835 | pullRequest: { | |
| 836 | number: 7, | |
| 837 | title: "Reuse the summary", | |
| 838 | url: "https://github.com/acme/web/pull/7", | |
| 839 | author: { login: "octocat", name: "Octo Cat" }, | |
| 840 | baseRefName: "main", | |
| 841 | headRefName: "feat/summary", | |
| 842 | state: "OPEN", | |
| 843 | isDraft: false, | |
| 844 | mergeable: "MERGEABLE", | |
| 845 | reviewDecision: "APPROVED", | |
| 846 | additions: 12, | |
| 847 | deletions: 3, | |
| 848 | changedFiles: 2, | |
| 849 | updatedAt: "2026-08-24T12:34:56.000Z", | |
| 850 | commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, | |
| 851 | }, | |
| 852 | }, | |
| 853 | }, | |
| 854 | }), | |
| 855 | ), | |
| 856 | ), | |
| 857 | ); | |
| 858 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 859 | ||
| 860 | const read = yield* cli | |
| 861 | .getPullRequestSummary({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }) | |
| 862 | .pipe(Effect.forkChild); | |
| 863 | yield* TestClock.adjust("10 millis"); | |
| 864 | const summary = yield* Fiber.join(read); | |
| 865 | ||
| 866 | assert.strictEqual(summary.headBranch, "feat/summary"); | |
| 867 | assert.strictEqual(summary.checksState, "passing"); | |
| 868 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 869 | expect(varsAt(1)).toEqual({ owner: "acme", name: "web", number: 7 }); | |
| 870 | }), | |
| 871 | ); | |
| 872 | ||
| 873 | it.effect("reads the stack a pull request is in through the stacks preview, on its host", () => | |
| 874 | Effect.gen(function* () { | |
| 875 | mockedExecute.mockReturnValueOnce( | |
| 876 | Effect.succeed( | |
| 877 | output( | |
| 878 | encodeJson([ | |
| 879 | { | |
| 880 | id: 42, | |
| 881 | number: 3, | |
| 882 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 883 | base: { ref: "main" }, | |
| 884 | pull_requests: [ | |
| 885 | { | |
| 886 | number: 6, | |
| 887 | head: { ref: "feat/one" }, | |
| 888 | state: "closed", | |
| 889 | merged_at: "2026-09-02", | |
| 890 | }, | |
| 891 | { number: 7, head: { ref: "feat/two" }, state: "open", merged_at: null }, | |
| 892 | ], | |
| 893 | }, | |
| 894 | ]), | |
| 895 | ), | |
| 896 | ), | |
| 897 | ); | |
| 898 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 899 | ||
| 900 | const stack = yield* cli.getPullRequestStack({ | |
| 901 | cwd: "/w", | |
| 902 | repository: "acme/web", | |
| 903 | host: "ghe.example.com", | |
| 904 | number: 7, | |
| 905 | }); | |
| 906 | ||
| 907 | assert.deepStrictEqual(stack, { | |
| 908 | id: "42", | |
| 909 | number: 3, | |
| 910 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 911 | base: "main", | |
| 912 | layers: [ | |
| 913 | { number: 6, headBranch: "feat/one", state: "merged" }, | |
| 914 | { number: 7, headBranch: "feat/two", state: "open" }, | |
| 915 | ], | |
| 916 | }); | |
| 917 | expect(callAt(0)).toMatchObject({ | |
| 918 | kind: "rest", | |
| 919 | host: "ghe.example.com", | |
| 920 | path: "repos/acme/web/stacks?pull_request=7", | |
| 921 | }); | |
| 922 | }), | |
| 923 | ); | |
| 924 | ||
| 925 | it.effect("fetches layer titles only when the caller asks for stack details", () => | |
| 926 | Effect.gen(function* () { | |
| 927 | const minimal = { | |
| 928 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 929 | number: 3, | |
| 930 | base: { ref: "main" }, | |
| 931 | pull_requests: [ | |
| 932 | { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null }, | |
| 933 | ], | |
| 934 | }; | |
| 935 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson([minimal])))); | |
| 936 | mockedExecute.mockReturnValueOnce( | |
| 937 | Effect.succeed( | |
| 938 | output( | |
| 939 | encodeJson({ | |
| 940 | ...minimal, | |
| 941 | pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }], | |
| 942 | }), | |
| 943 | ), | |
| 944 | ), | |
| 945 | ); | |
| 946 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 947 | const stack = yield* cli.getPullRequestStack({ | |
| 948 | cwd: "/w", | |
| 949 | repository: "acme/web", | |
| 950 | host: "github.com", | |
| 951 | number: 7, | |
| 952 | includeDetails: true, | |
| 953 | }); | |
| 954 | expect(stack?.layers[0]).toMatchObject({ | |
| 955 | title: "Second layer", | |
| 956 | headSha: "abc123", | |
| 957 | isDraft: false, | |
| 958 | }); | |
| 959 | expect(pathAt(1)).toBe("repos/acme/web/stacks/3"); | |
| 960 | }), | |
| 961 | ); | |
| 962 | ||
| 963 | it.effect("reads an empty stacks listing as not stacked", () => | |
| 964 | Effect.gen(function* () { | |
| 965 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 966 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 967 | ||
| 968 | const stack = yield* cli.getPullRequestStack({ | |
| 969 | cwd: "/w", | |
| 970 | repository: "acme/web", | |
| 971 | host: "github.com", | |
| 972 | number: 7, | |
| 973 | }); | |
| 974 | ||
| 975 | assert.isNull(stack); | |
| 976 | }), | |
| 977 | ); | |
| 978 | ||
| 979 | it.effect("reads a host that refuses the stacks preview as not stacked", () => | |
| 980 | Effect.gen(function* () { | |
| 981 | mockedExecute.mockReturnValueOnce( | |
| 982 | Effect.fail( | |
| 983 | new GitHubApi.GitHubApiNotFoundError({ | |
| 984 | host: "github.com", | |
| 985 | operation: "getPullRequestStack", | |
| 986 | }), | |
| 987 | ), | |
| 988 | ); | |
| 989 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 990 | ||
| 991 | const stack = yield* cli.getPullRequestStack({ | |
| 992 | cwd: "/w", | |
| 993 | repository: "acme/web", | |
| 994 | host: "github.com", | |
| 995 | number: 7, | |
| 996 | }); | |
| 997 | ||
| 998 | assert.isNull(stack); | |
| 999 | }), | |
| 1000 | ); | |
| 1001 | ||
| 1002 | it.effect("does not read a refused credential as an unstacked pull request", () => | |
| 1003 | Effect.gen(function* () { | |
| 1004 | mockedExecute.mockReturnValueOnce( | |
| 1005 | Effect.fail( | |
| 1006 | new GitHubApi.GitHubApiAuthenticationError({ | |
| 1007 | host: "github.com", | |
| 1008 | operation: "getPullRequestStack", | |
| 1009 | }), | |
| 1010 | ), | |
| 1011 | ); | |
| 1012 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1013 | ||
| 1014 | const error = yield* Effect.flip( | |
| 1015 | cli.getPullRequestStack({ | |
| 1016 | cwd: "/w", | |
| 1017 | repository: "acme/web", | |
| 1018 | host: "github.com", | |
| 1019 | number: 7, | |
| 1020 | }), | |
| 1021 | ); | |
| 1022 | ||
| 1023 | assert.strictEqual(error._tag, "GitHubApiAuthenticationError"); | |
| 1024 | }), | |
| 1025 | ); | |
| 1026 | ||
| 1027 | it.effect("preserves transient stack failures instead of reporting no stack", () => | |
| 1028 | Effect.gen(function* () { | |
| 1029 | const failure = new GitHubApi.GitHubApiResponseError({ | |
| 1030 | host: "github.com", | |
| 1031 | operation: "getPullRequestStack", | |
| 1032 | status: 503, | |
| 1033 | }); | |
| 1034 | mockedExecute.mockReturnValueOnce(Effect.fail(failure)); | |
| 1035 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1036 | const error = yield* Effect.flip( | |
| 1037 | cli.getPullRequestStack({ | |
| 1038 | cwd: "/w", | |
| 1039 | repository: "acme/web", | |
| 1040 | host: "github.com", | |
| 1041 | number: 7, | |
| 1042 | }), | |
| 1043 | ); | |
| 1044 | assert.strictEqual(error, failure); | |
| 1045 | }), | |
| 1046 | ); | |
| 1047 | ||
| 1048 | it.effect("reports a stacks answer it cannot read against the stack read", () => | |
| 1049 | Effect.gen(function* () { | |
| 1050 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]'))); | |
| 1051 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1052 | ||
| 1053 | const error = yield* Effect.flip( | |
| 1054 | cli.getPullRequestStack({ | |
| 1055 | cwd: "/w", | |
| 1056 | repository: "acme/web", | |
| 1057 | host: "github.com", | |
| 1058 | number: 7, | |
| 1059 | }), | |
| 1060 | ); | |
| 1061 | ||
| 1062 | assert.strictEqual(error._tag, "GitHubPullRequestReadError"); | |
| 1063 | if (error._tag !== "GitHubPullRequestReadError") return; | |
| 1064 | assert.strictEqual(error.operation, "getPullRequestStack"); | |
| 1065 | }), | |
| 1066 | ); | |
| 1067 | ||
| 1068 | it.effect("asks for one row more than the page, to probe for a next page", () => | |
| 1069 | Effect.gen(function* () { | |
| 1070 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); | |
| 1071 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1072 | ||
| 1073 | const batch = yield* cli.listPullRequests({ | |
| 1074 | cwd: "/w", | |
| 1075 | repository: "acme/web", | |
| 1076 | host: "github.com", | |
| 1077 | state: "open", | |
| 1078 | involvement: "all", | |
| 1079 | viewer: "bilal", | |
| 1080 | limit: 10, | |
| 1081 | }); | |
| 1082 | ||
| 1083 | assert.strictEqual(batch.items.length, 3); | |
| 1084 | assert.isFalse(batch.truncated); | |
| 1085 | expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.com" }); | |
| 1086 | expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); | |
| 1087 | expect(queryAt(0)).toContain("first: 11"); | |
| 1088 | }), | |
| 1089 | ); | |
| 1090 | ||
| 1091 | it.effect("reports truncation from the extra row, counted before decoding", () => | |
| 1092 | Effect.gen(function* () { | |
| 1093 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1)))); | |
| 1094 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1095 | ||
| 1096 | const batch = yield* cli.listPullRequests({ | |
| 1097 | cwd: "/w", | |
| 1098 | repository: "acme/web", | |
| 1099 | host: "github.com", | |
| 1100 | state: "open", | |
| 1101 | involvement: "all", | |
| 1102 | viewer: "bilal", | |
| 1103 | limit: 10, | |
| 1104 | }); | |
| 1105 | ||
| 1106 | assert.strictEqual(batch.items.length, 10); | |
| 1107 | assert.isTrue(batch.truncated); | |
| 1108 | }), | |
| 1109 | ); | |
| 1110 | ||
| 1111 | it.effect("excludes merged pull requests from the Closed tab", () => | |
| 1112 | Effect.gen(function* () { | |
| 1113 | mockedExecute.mockImplementation((call) => | |
| 1114 | Effect.succeed( | |
| 1115 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1116 | ? emptyList() | |
| 1117 | : emptySearch(), | |
| 1118 | ), | |
| 1119 | ); | |
| 1120 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1121 | ||
| 1122 | yield* cli.listPullRequests({ | |
| 1123 | cwd: "/w", | |
| 1124 | repository: "acme/web", | |
| 1125 | host: "github.com", | |
| 1126 | state: "closed", | |
| 1127 | involvement: "all", | |
| 1128 | viewer: "bilal", | |
| 1129 | limit: 10, | |
| 1130 | }); | |
| 1131 | ||
| 1132 | // A closed pull request may also be a merged one, so the tab narrows through search. | |
| 1133 | expect(searchOfCall(0)).toBe("is:pr is:closed is:unmerged sort:updated-desc repo:acme/web"); | |
| 1134 | }), | |
| 1135 | ); | |
| 1136 | ||
| 1137 | it.effect("narrows to the author on the authored tab", () => | |
| 1138 | Effect.gen(function* () { | |
| 1139 | mockedExecute.mockImplementation((call) => | |
| 1140 | Effect.succeed( | |
| 1141 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1142 | ? emptyList() | |
| 1143 | : emptySearch(), | |
| 1144 | ), | |
| 1145 | ); | |
| 1146 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1147 | ||
| 1148 | yield* cli.listPullRequests({ | |
| 1149 | cwd: "/w", | |
| 1150 | repository: "acme/web", | |
| 1151 | host: "github.com", | |
| 1152 | state: "open", | |
| 1153 | involvement: "authored", | |
| 1154 | viewer: "bilal", | |
| 1155 | limit: 10, | |
| 1156 | }); | |
| 1157 | ||
| 1158 | expect(searchOfCall(0)).toBe("is:pr is:open author:bilal sort:updated-desc repo:acme/web"); | |
| 1159 | }), | |
| 1160 | ); | |
| 1161 | ||
| 1162 | it.effect("narrows through search on the reviewing tab", () => | |
| 1163 | Effect.gen(function* () { | |
| 1164 | mockedExecute.mockImplementation((call) => | |
| 1165 | Effect.succeed( | |
| 1166 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1167 | ? emptyList() | |
| 1168 | : emptySearch(), | |
| 1169 | ), | |
| 1170 | ); | |
| 1171 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1172 | ||
| 1173 | yield* cli.listPullRequests({ | |
| 1174 | cwd: "/w", | |
| 1175 | repository: "acme/web", | |
| 1176 | host: "github.com", | |
| 1177 | state: "open", | |
| 1178 | involvement: "reviewing", | |
| 1179 | viewer: "bilal", | |
| 1180 | limit: 10, | |
| 1181 | }); | |
| 1182 | ||
| 1183 | expect(searchOfCall(0)).toBe( | |
| 1184 | "is:pr is:open review-requested:bilal sort:updated-desc repo:acme/web", | |
| 1185 | ); | |
| 1186 | }), | |
| 1187 | ); | |
| 1188 | ||
| 1189 | it.effect("carries every repository and every qualifier into one search", () => | |
| 1190 | Effect.gen(function* () { | |
| 1191 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); | |
| 1192 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1193 | ||
| 1194 | yield* cli.searchPullRequests({ | |
| 1195 | cwd: "/w", | |
| 1196 | host: "github.com", | |
| 1197 | repositories: ["acme/web", "pingdotgg/t3code"], | |
| 1198 | state: "closed", | |
| 1199 | involvement: "reviewing", | |
| 1200 | viewer: "bilal", | |
| 1201 | limit: 10, | |
| 1202 | query: "pull requests page", | |
| 1203 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, | |
| 1204 | }); | |
| 1205 | ||
| 1206 | // One request for both repositories, carrying everything the per-repository read expresses | |
| 1207 | // as a flag: the tab, the involvement, the reader's words, where to carry on from, and the | |
| 1208 | // order the page reads in. | |
| 1209 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 1210 | assert.strictEqual( | |
| 1211 | searchQueryOfCall(0), | |
| 1212 | 'is:pr is:closed is:unmerged review-requested:bilal "pull requests page" ' + | |
| 1213 | "updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web repo:pingdotgg/t3code", | |
| 1214 | ); | |
| 1215 | }), | |
| 1216 | ); | |
| 1217 | ||
| 1218 | it.effect("narrows a search to the author, and to merged on the merged tab", () => | |
| 1219 | Effect.gen(function* () { | |
| 1220 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); | |
| 1221 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1222 | ||
| 1223 | yield* cli.searchPullRequests({ | |
| 1224 | cwd: "/w", | |
| 1225 | host: "github.com", | |
| 1226 | repositories: ["acme/web"], | |
| 1227 | state: "merged", | |
| 1228 | involvement: "authored", | |
| 1229 | viewer: "bilal", | |
| 1230 | limit: 10, | |
| 1231 | }); | |
| 1232 | ||
| 1233 | assert.strictEqual( | |
| 1234 | searchQueryOfCall(0), | |
| 1235 | "is:pr is:merged author:bilal sort:updated-desc repo:acme/web", | |
| 1236 | ); | |
| 1237 | }), | |
| 1238 | ); | |
| 1239 | ||
| 1240 | it.effect("keeps a searched-for qualifier inside the phrase", () => | |
| 1241 | Effect.gen(function* () { | |
| 1242 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); | |
| 1243 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1244 | ||
| 1245 | yield* cli.searchPullRequests({ | |
| 1246 | cwd: "/w", | |
| 1247 | host: "github.com", | |
| 1248 | repositories: ["acme/web"], | |
| 1249 | state: "open", | |
| 1250 | involvement: "all", | |
| 1251 | viewer: "bilal", | |
| 1252 | limit: 10, | |
| 1253 | query: 'x" is:merged repo:evil/repo', | |
| 1254 | }); | |
| 1255 | ||
| 1256 | // Quoted and escaped, so the words a reader typed narrow the listing rather than widening it. | |
| 1257 | assert.strictEqual( | |
| 1258 | searchQueryOfCall(0), | |
| 1259 | 'is:pr is:open "x\\" is:merged repo:evil/repo" sort:updated-desc repo:acme/web', | |
| 1260 | ); | |
| 1261 | }), | |
| 1262 | ); | |
| 1263 | ||
| 1264 | it.effect("refuses to search for a repository GitHub cannot address", () => | |
| 1265 | Effect.gen(function* () { | |
| 1266 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1267 | ||
| 1268 | const failure = yield* Effect.flip( | |
| 1269 | cli.searchPullRequests({ | |
| 1270 | cwd: "/w", | |
| 1271 | host: "github.com", | |
| 1272 | repositories: ["acme/web", "acme/web is:merged"], | |
| 1273 | state: "open", | |
| 1274 | involvement: "all", | |
| 1275 | viewer: "bilal", | |
| 1276 | limit: 10, | |
| 1277 | }), | |
| 1278 | ); | |
| 1279 | ||
| 1280 | // Nothing is sent: a name that could end its own qualifier is refused rather than escaped. | |
| 1281 | assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); | |
| 1282 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 1283 | }), | |
| 1284 | ); | |
| 1285 | ||
| 1286 | it.effect("files each searched row under the repository it came from", () => | |
| 1287 | Effect.gen(function* () { | |
| 1288 | mockedExecute.mockReturnValue( | |
| 1289 | Effect.succeed( | |
| 1290 | searchPage([ | |
| 1291 | searchItem(7, "acme/web", "2026-07-03T00:00:00Z"), | |
| 1292 | searchItem(9, "pingdotgg/t3code", "2026-07-02T00:00:00Z"), | |
| 1293 | // Not a pull request, which `is:pr` excludes and a decode skips rather than fails on. | |
| 1294 | {}, | |
| 1295 | ]), | |
| 1296 | ), | |
| 1297 | ); | |
| 1298 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1299 | ||
| 1300 | const batch = yield* cli.searchPullRequests({ | |
| 1301 | cwd: "/w", | |
| 1302 | host: "github.com", | |
| 1303 | repositories: ["acme/web", "pingdotgg/t3code"], | |
| 1304 | state: "open", | |
| 1305 | involvement: "all", | |
| 1306 | viewer: "bilal", | |
| 1307 | limit: 10, | |
| 1308 | }); | |
| 1309 | ||
| 1310 | assert.deepStrictEqual( | |
| 1311 | batch.items.map((item) => [item.repository, item.number, item.author?.avatarUrl]), | |
| 1312 | [ | |
| 1313 | ["acme/web", 7, "https://avatars/octocat"], | |
| 1314 | ["pingdotgg/t3code", 9, "https://avatars/octocat"], | |
| 1315 | ], | |
| 1316 | ); | |
| 1317 | // The listing leaves the line counts to a read of their own. | |
| 1318 | assert.deepStrictEqual( | |
| 1319 | batch.items.map((item) => [item.additions, item.deletions]), | |
| 1320 | [ | |
| 1321 | [0, 0], | |
| 1322 | [0, 0], | |
| 1323 | ], | |
| 1324 | ); | |
| 1325 | assert.isFalse(batch.truncated); | |
| 1326 | }), | |
| 1327 | ); | |
| 1328 | ||
| 1329 | it.effect("reports truncation from the extra row, and from a page GitHub says has more", () => | |
| 1330 | Effect.gen(function* () { | |
| 1331 | mockedExecute | |
| 1332 | .mockReturnValueOnce( | |
| 1333 | Effect.succeed( | |
| 1334 | searchPage([ | |
| 1335 | searchItem(1, "acme/web", "2026-07-03T00:00:00Z"), | |
| 1336 | searchItem(2, "acme/web", "2026-07-02T00:00:00Z"), | |
| 1337 | searchItem(3, "acme/web", "2026-07-01T00:00:00Z"), | |
| 1338 | ]), | |
| 1339 | ), | |
| 1340 | ) | |
| 1341 | .mockReturnValueOnce( | |
| 1342 | Effect.succeed(searchPage([searchItem(1, "acme/web", "2026-07-03T00:00:00Z")], true)), | |
| 1343 | ); | |
| 1344 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1345 | const read = () => | |
| 1346 | cli.searchPullRequests({ | |
| 1347 | cwd: "/w", | |
| 1348 | host: "github.com", | |
| 1349 | repositories: ["acme/web"], | |
| 1350 | state: "open", | |
| 1351 | involvement: "all", | |
| 1352 | viewer: "bilal", | |
| 1353 | limit: 2, | |
| 1354 | }); | |
| 1355 | ||
| 1356 | const overflowing = yield* read(); | |
| 1357 | const capped = yield* read(); | |
| 1358 | ||
| 1359 | // The extra row is the probe, and it is not handed on. | |
| 1360 | assert.strictEqual(overflowing.items.length, 2); | |
| 1361 | assert.isTrue(overflowing.truncated); | |
| 1362 | // A slice at GitHub's own ceiling has no extra row to probe with, so `hasNextPage` answers. | |
| 1363 | assert.isTrue(capped.truncated); | |
| 1364 | }), | |
| 1365 | ); | |
| 1366 | ||
| 1367 | it.effect("enriches only the visible fallback rows after filtering and widening", () => | |
| 1368 | Effect.gen(function* () { | |
| 1369 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1370 | mockedExecute.mockReturnValueOnce( | |
| 1371 | Effect.succeed(output(listedPullRequests(3, 1, () => ({ isDraft: true })))), | |
| 1372 | ); | |
| 1373 | mockedExecute.mockReturnValueOnce( | |
| 1374 | Effect.succeed(output(listedPullRequests(6, 1, (number) => ({ isDraft: number < 4 })))), | |
| 1375 | ); | |
| 1376 | mockedStackMemberships.mockReturnValueOnce( | |
| 1377 | Effect.succeed( | |
| 1378 | output( | |
| 1379 | encodeJson({ | |
| 1380 | data: { | |
| 1381 | s0: { | |
| 1382 | pullRequest: { | |
| 1383 | stack: { number: 3, size: 2, baseRefName: "main" }, | |
| 1384 | stackEntry: { position: 1 }, | |
| 1385 | }, | |
| 1386 | }, | |
| 1387 | s1: { pullRequest: { stack: null, stackEntry: null } }, | |
| 1388 | }, | |
| 1389 | }), | |
| 1390 | ), | |
| 1391 | ), | |
| 1392 | ); | |
| 1393 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1394 | const batch = yield* cli.listPullRequests({ | |
| 1395 | cwd: "/w", | |
| 1396 | repository: "acme/web", | |
| 1397 | host: "github.com", | |
| 1398 | state: "open", | |
| 1399 | involvement: "all", | |
| 1400 | viewer: "bilal", | |
| 1401 | limit: 2, | |
| 1402 | filters: { draft: "hide" }, | |
| 1403 | }); | |
| 1404 | expect(batch.items.map((item) => item.number)).toEqual([4, 5]); | |
| 1405 | expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); | |
| 1406 | expect(batch.items[1]?.stack).toBeUndefined(); | |
| 1407 | expect(batch.truncated).toBe(true); | |
| 1408 | expect(batch.continues).toBe(false); | |
| 1409 | expect(mockedStackMemberships).toHaveBeenCalledTimes(1); | |
| 1410 | 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)"); | |
| 1416 | }), | |
| 1417 | ); | |
| 1418 | ||
| 1419 | it.effect("batches membership reads and keeps successful rows when one chunk fails", () => | |
| 1420 | Effect.gen(function* () { | |
| 1421 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1)))); | |
| 1422 | mockedStackMemberships.mockImplementation((input) => | |
| 1423 | input.kind === "graphql" && input.query.includes("pullRequest(number: 26)") | |
| 1424 | ? Effect.fail( | |
| 1425 | new GitHubApi.GitHubApiResponseError({ | |
| 1426 | host: "github.com", | |
| 1427 | operation: "listPullRequestStackMemberships", | |
| 1428 | status: 502, | |
| 1429 | }), | |
| 1430 | ) | |
| 1431 | : Effect.succeed( | |
| 1432 | output( | |
| 1433 | encodeJson({ | |
| 1434 | data: { | |
| 1435 | s0: { | |
| 1436 | pullRequest: { | |
| 1437 | stack: { number: 3, size: 2, baseRefName: "main" }, | |
| 1438 | stackEntry: { position: 1 }, | |
| 1439 | }, | |
| 1440 | }, | |
| 1441 | }, | |
| 1442 | }), | |
| 1443 | ), | |
| 1444 | ), | |
| 1445 | ); | |
| 1446 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1447 | const batch = yield* cli.listPullRequests({ | |
| 1448 | cwd: "/w", | |
| 1449 | repository: "acme/web", | |
| 1450 | host: "github.com", | |
| 1451 | state: "open", | |
| 1452 | involvement: "all", | |
| 1453 | viewer: "bilal", | |
| 1454 | limit: 26, | |
| 1455 | }); | |
| 1456 | expect(batch.items.map((item) => item.number)).toEqual( | |
| 1457 | Array.from({ length: 26 }, (_, i) => i + 1), | |
| 1458 | ); | |
| 1459 | expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); | |
| 1460 | expect(batch.items[25]?.stack).toBeUndefined(); | |
| 1461 | expect(batch.truncated).toBe(true); | |
| 1462 | expect(batch.continues).toBe(true); | |
| 1463 | expect(mockedStackMemberships).toHaveBeenCalledTimes(2); | |
| 1464 | }), | |
| 1465 | ); | |
| 1466 | ||
| 1467 | it.effect("skips membership enrichment for empty pages and enterprise hosts", () => | |
| 1468 | Effect.gen(function* () { | |
| 1469 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1470 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptyList())); | |
| 1471 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7)))); | |
| 1472 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1473 | const input = { | |
| 1474 | cwd: "/w", | |
| 1475 | repository: "acme/web", | |
| 1476 | state: "open" as const, | |
| 1477 | involvement: "all" as const, | |
| 1478 | viewer: "bilal", | |
| 1479 | limit: 2, | |
| 1480 | }; | |
| 1481 | const empty = yield* cli.listPullRequests({ ...input, host: "github.com" }); | |
| 1482 | const enterprise = yield* cli.listPullRequests({ ...input, host: "github.acme.test" }); | |
| 1483 | expect(empty.items).toEqual([]); | |
| 1484 | expect(enterprise.items.map((item) => item.number)).toEqual([7]); | |
| 1485 | expect(mockedStackMemberships).not.toHaveBeenCalled(); | |
| 1486 | }), | |
| 1487 | ); | |
| 1488 | ||
| 1489 | it.effect("reads the line counts in chunks, and files them back by position", () => | |
| 1490 | Effect.gen(function* () { | |
| 1491 | const changeRequests = Array.from({ length: 26 }, (_, index) => ({ | |
| 1492 | repository: "acme/web", | |
| 1493 | number: index + 1, | |
| 1494 | })); | |
| 1495 | mockedExecute.mockImplementation(() => | |
| 1496 | // Every chunk answers for its first alias only, so a row GitHub said nothing about is | |
| 1497 | // dropped rather than shown as a change of no size. | |
| 1498 | Effect.succeed( | |
| 1499 | output(encodeJson({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })), | |
| 1500 | ), | |
| 1501 | ); | |
| 1502 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1503 | ||
| 1504 | const stats = yield* cli.listPullRequestStats({ | |
| 1505 | cwd: "/w", | |
| 1506 | host: "github.com", | |
| 1507 | changeRequests, | |
| 1508 | }); | |
| 1509 | ||
| 1510 | // Twenty-five aliases a request, so twenty-six rows are two requests. | |
| 1511 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 1512 | assert.deepStrictEqual(stats, [ | |
| 1513 | { repository: "acme/web", number: 1, additions: 4, deletions: 1 }, | |
| 1514 | { repository: "acme/web", number: 26, additions: 4, deletions: 1 }, | |
| 1515 | ]); | |
| 1516 | const document = queryAt(0); | |
| 1517 | expect(document).toContain('s0: repository(owner: "acme", name: "web")'); | |
| 1518 | expect(document).toContain("pullRequest(number: 25)"); | |
| 1519 | }), | |
| 1520 | ); | |
| 1521 | ||
| 1522 | it.effect("refuses to look up counts for a repository GitHub cannot address", () => | |
| 1523 | Effect.gen(function* () { | |
| 1524 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1525 | ||
| 1526 | const failure = yield* Effect.flip( | |
| 1527 | cli.listPullRequestStats({ | |
| 1528 | cwd: "/w", | |
| 1529 | host: "github.com", | |
| 1530 | changeRequests: [{ repository: 'acme/web") { x } #', number: 1 }], | |
| 1531 | }), | |
| 1532 | ); | |
| 1533 | ||
| 1534 | assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); | |
| 1535 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 1536 | }), | |
| 1537 | ); | |
| 1538 | ||
| 1539 | it.effect("hands a search to GitHub rather than to the rows already read", () => | |
| 1540 | Effect.gen(function* () { | |
| 1541 | mockedExecute.mockImplementation((call) => | |
| 1542 | Effect.succeed( | |
| 1543 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1544 | ? emptyList() | |
| 1545 | : emptySearch(), | |
| 1546 | ), | |
| 1547 | ); | |
| 1548 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1549 | ||
| 1550 | yield* cli.listPullRequests({ | |
| 1551 | cwd: "/w", | |
| 1552 | repository: "acme/web", | |
| 1553 | host: "github.com", | |
| 1554 | state: "open", | |
| 1555 | involvement: "all", | |
| 1556 | viewer: "bilal", | |
| 1557 | limit: 10, | |
| 1558 | query: "pull requests page", | |
| 1559 | }); | |
| 1560 | ||
| 1561 | // The recency qualifier rides along, because free text would otherwise reorder the page | |
| 1562 | // by relevance and truncation would drop the newest matches. | |
| 1563 | expect(searchOfCall(0)).toBe( | |
| 1564 | 'is:pr is:open "pull requests page" sort:updated-desc repo:acme/web', | |
| 1565 | ); | |
| 1566 | }), | |
| 1567 | ); | |
| 1568 | ||
| 1569 | it.effect("joins a search onto the tab's own qualifiers instead of replacing them", () => | |
| 1570 | Effect.gen(function* () { | |
| 1571 | mockedExecute.mockImplementation((call) => | |
| 1572 | Effect.succeed( | |
| 1573 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1574 | ? emptyList() | |
| 1575 | : emptySearch(), | |
| 1576 | ), | |
| 1577 | ); | |
| 1578 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1579 | ||
| 1580 | yield* cli.listPullRequests({ | |
| 1581 | cwd: "/w", | |
| 1582 | repository: "acme/web", | |
| 1583 | host: "github.com", | |
| 1584 | state: "closed", | |
| 1585 | involvement: "reviewing", | |
| 1586 | viewer: "bilal", | |
| 1587 | limit: 10, | |
| 1588 | query: "page", | |
| 1589 | }); | |
| 1590 | ||
| 1591 | expect(searchOfCall(0)).toBe( | |
| 1592 | 'is:pr is:closed is:unmerged review-requested:bilal "page" sort:updated-desc repo:acme/web', | |
| 1593 | ); | |
| 1594 | }), | |
| 1595 | ); | |
| 1596 | ||
| 1597 | it.effect("carries the further narrowings into the search as qualifiers", () => | |
| 1598 | Effect.gen(function* () { | |
| 1599 | mockedExecute.mockImplementation((call) => | |
| 1600 | Effect.succeed( | |
| 1601 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1602 | ? emptyList() | |
| 1603 | : emptySearch(), | |
| 1604 | ), | |
| 1605 | ); | |
| 1606 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1607 | ||
| 1608 | yield* cli.listPullRequests({ | |
| 1609 | cwd: "/w", | |
| 1610 | repository: "acme/web", | |
| 1611 | host: "github.com", | |
| 1612 | state: "open", | |
| 1613 | involvement: "all", | |
| 1614 | viewer: "bilal", | |
| 1615 | limit: 10, | |
| 1616 | filters: { | |
| 1617 | draft: "hide", | |
| 1618 | review: "changes-requested", | |
| 1619 | checks: "failing", | |
| 1620 | labels: [["needs design"], ['quo"te']], | |
| 1621 | excludedLabels: ["wip"], | |
| 1622 | author: "octocat", | |
| 1623 | }, | |
| 1624 | }); | |
| 1625 | ||
| 1626 | // Quotes around anything a reader typed, and the one character that could end a quoted | |
| 1627 | // value early dropped rather than escaped. | |
| 1628 | expect(searchOfCall(0)).toBe( | |
| 1629 | 'is:pr is:open label:"needs design" label:"quote" -label:"wip" author:"octocat" ' + | |
| 1630 | "draft:false review:changes_requested status:failure sort:updated-desc repo:acme/web", | |
| 1631 | ); | |
| 1632 | }), | |
| 1633 | ); | |
| 1634 | ||
| 1635 | it.effect('resolves an author filter of "me" to the viewer, not the literal word', () => | |
| 1636 | Effect.gen(function* () { | |
| 1637 | mockedExecute.mockImplementation((call) => | |
| 1638 | Effect.succeed( | |
| 1639 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1640 | ? emptyList() | |
| 1641 | : emptySearch(), | |
| 1642 | ), | |
| 1643 | ); | |
| 1644 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1645 | ||
| 1646 | yield* cli.listPullRequests({ | |
| 1647 | cwd: "/w", | |
| 1648 | repository: "acme/web", | |
| 1649 | host: "github.com", | |
| 1650 | state: "open", | |
| 1651 | involvement: "all", | |
| 1652 | viewer: "bilal", | |
| 1653 | limit: 10, | |
| 1654 | filters: { author: "me" }, | |
| 1655 | }); | |
| 1656 | ||
| 1657 | expect(searchOfCall(0)).toBe('is:pr is:open author:"bilal" sort:updated-desc repo:acme/web'); | |
| 1658 | }), | |
| 1659 | ); | |
| 1660 | ||
| 1661 | it.effect("sends one label qualifier per group, its names joined the way GitHub ors them", () => | |
| 1662 | Effect.gen(function* () { | |
| 1663 | mockedExecute.mockImplementation((call) => | |
| 1664 | Effect.succeed( | |
| 1665 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1666 | ? emptyList() | |
| 1667 | : emptySearch(), | |
| 1668 | ), | |
| 1669 | ); | |
| 1670 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1671 | ||
| 1672 | yield* cli.listPullRequests({ | |
| 1673 | cwd: "/w", | |
| 1674 | repository: "acme/web", | |
| 1675 | host: "github.com", | |
| 1676 | state: "open", | |
| 1677 | involvement: "all", | |
| 1678 | viewer: "bilal", | |
| 1679 | limit: 10, | |
| 1680 | filters: { labels: [["size:S", "size:XS"], ["bug"]] }, | |
| 1681 | }); | |
| 1682 | ||
| 1683 | // One qualifier satisfied by either size, and a second one that must hold as well. | |
| 1684 | expect(searchOfCall(0)).toBe( | |
| 1685 | 'is:pr is:open label:"size:S","size:XS" label:"bug" sort:updated-desc repo:acme/web', | |
| 1686 | ); | |
| 1687 | }), | |
| 1688 | ); | |
| 1689 | ||
| 1690 | it.effect( | |
| 1691 | "falls back for a repository the index does not cover under a checks filter, keeping only the matching rows", | |
| 1692 | () => | |
| 1693 | Effect.gen(function* () { | |
| 1694 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1695 | mockedExecute.mockReturnValueOnce( | |
| 1696 | Effect.succeed( | |
| 1697 | output( | |
| 1698 | listedPullRequests(2, 1, (number) => ({ | |
| 1699 | checks: number === 1 ? "SUCCESS" : "FAILURE", | |
| 1700 | })), | |
| 1701 | ), | |
| 1702 | ), | |
| 1703 | ); | |
| 1704 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1705 | ||
| 1706 | const batch = yield* cli.listPullRequests({ | |
| 1707 | cwd: "/w", | |
| 1708 | repository: "acme/web", | |
| 1709 | host: "github.com", | |
| 1710 | state: "open", | |
| 1711 | involvement: "all", | |
| 1712 | viewer: "bilal", | |
| 1713 | limit: 10, | |
| 1714 | filters: { checks: "passing" }, | |
| 1715 | }); | |
| 1716 | ||
| 1717 | // The fallback's rows carry `checksState` exactly as a search's rows do, so `checks` is | |
| 1718 | // now a filter the fallback judges itself, the same as `draft`: an empty search answer | |
| 1719 | // under it is still ambiguous, and the row picked out afterwards is the one whose own | |
| 1720 | // `checksState` reads "passing". | |
| 1721 | expect(searchOfCall(1)).toBeUndefined(); | |
| 1722 | assert.deepStrictEqual( | |
| 1723 | batch.items.map((item) => item.number), | |
| 1724 | [1], | |
| 1725 | ); | |
| 1726 | }), | |
| 1727 | ); | |
| 1728 | ||
| 1729 | it.effect("fails a checks filter for a row whose checks are still pending", () => | |
| 1730 | Effect.gen(function* () { | |
| 1731 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1732 | mockedExecute.mockReturnValueOnce( | |
| 1733 | Effect.succeed(output(listedPullRequests(1, 1, () => ({ checks: "PENDING" })))), | |
| 1734 | ); | |
| 1735 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1736 | ||
| 1737 | const batch = yield* cli.listPullRequests({ | |
| 1738 | cwd: "/w", | |
| 1739 | repository: "acme/web", | |
| 1740 | host: "github.com", | |
| 1741 | state: "open", | |
| 1742 | involvement: "all", | |
| 1743 | viewer: "bilal", | |
| 1744 | limit: 10, | |
| 1745 | filters: { checks: "passing" }, | |
| 1746 | }); | |
| 1747 | ||
| 1748 | // Pending equals neither "passing" nor "failing", so it satisfies neither filter value — | |
| 1749 | // the same row would also be dropped by `checks: "failing"`. | |
| 1750 | assert.deepStrictEqual(batch.items, []); | |
| 1751 | }), | |
| 1752 | ); | |
| 1753 | ||
| 1754 | it.effect( | |
| 1755 | "falls back for a repository the index does not cover even under a judgeable filter", | |
| 1756 | () => | |
| 1757 | Effect.gen(function* () { | |
| 1758 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1759 | mockedExecute.mockReturnValueOnce( | |
| 1760 | Effect.succeed(output(listedPullRequests(2, 1, (number) => ({ isDraft: number === 1 })))), | |
| 1761 | ); | |
| 1762 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1763 | ||
| 1764 | const batch = yield* cli.listPullRequests({ | |
| 1765 | cwd: "/w", | |
| 1766 | repository: "acme/web", | |
| 1767 | host: "github.com", | |
| 1768 | state: "open", | |
| 1769 | involvement: "all", | |
| 1770 | viewer: "bilal", | |
| 1771 | limit: 10, | |
| 1772 | filters: { draft: "hide" }, | |
| 1773 | }); | |
| 1774 | ||
| 1775 | // `draft` is a filter the fallback can judge over its own rows just as search judges it, | |
| 1776 | // so an empty search answer under it alone is still ambiguous between "nothing matches" | |
| 1777 | // and "this repository is not indexed" — and the fallback applies the filter itself, | |
| 1778 | // keeping only the non-draft row. | |
| 1779 | expect(searchOfCall(1)).toBeUndefined(); | |
| 1780 | expect(batch.items.map((item) => item.number)).toEqual([2]); | |
| 1781 | }), | |
| 1782 | ); | |
| 1783 | ||
| 1784 | it.effect("carries the further narrowings into a batched search", () => | |
| 1785 | Effect.gen(function* () { | |
| 1786 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); | |
| 1787 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1788 | ||
| 1789 | yield* cli.searchPullRequests({ | |
| 1790 | cwd: "/w", | |
| 1791 | host: "github.com", | |
| 1792 | repositories: ["acme/web"], | |
| 1793 | state: "open", | |
| 1794 | involvement: "all", | |
| 1795 | viewer: "bilal", | |
| 1796 | limit: 10, | |
| 1797 | filters: { draft: "only", review: "none", labels: [["bug"]] }, | |
| 1798 | }); | |
| 1799 | ||
| 1800 | assert.strictEqual( | |
| 1801 | searchQueryOfCall(0), | |
| 1802 | 'is:pr is:open label:"bug" draft:true review:none sort:updated-desc repo:acme/web', | |
| 1803 | ); | |
| 1804 | }), | |
| 1805 | ); | |
| 1806 | ||
| 1807 | it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () => | |
| 1808 | Effect.gen(function* () { | |
| 1809 | mockedExecute.mockImplementation((call) => | |
| 1810 | Effect.succeed( | |
| 1811 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1812 | ? emptyList() | |
| 1813 | : emptySearch(), | |
| 1814 | ), | |
| 1815 | ); | |
| 1816 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1817 | ||
| 1818 | yield* cli.listPullRequests({ | |
| 1819 | cwd: "/w", | |
| 1820 | repository: "acme/web", | |
| 1821 | host: "github.com", | |
| 1822 | state: "open", | |
| 1823 | involvement: "all", | |
| 1824 | viewer: "bilal", | |
| 1825 | limit: 10, | |
| 1826 | query: '-- is:merged label:secret "widen me"', | |
| 1827 | }); | |
| 1828 | ||
| 1829 | // Every word stays inside one phrase: nothing before it, nothing after it, and the | |
| 1830 | // leading dashes are text rather than the start of another argument. | |
| 1831 | expect(searchOfCall(0)).toBe( | |
| 1832 | String.raw`is:pr is:open "-- is:merged label:secret \"widen me\"" sort:updated-desc repo:acme/web`, | |
| 1833 | ); | |
| 1834 | }), | |
| 1835 | ); | |
| 1836 | ||
| 1837 | it.effect("escapes a backslash before the quote it would otherwise let out", () => | |
| 1838 | Effect.gen(function* () { | |
| 1839 | mockedExecute.mockImplementation((call) => | |
| 1840 | Effect.succeed( | |
| 1841 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1842 | ? emptyList() | |
| 1843 | : emptySearch(), | |
| 1844 | ), | |
| 1845 | ); | |
| 1846 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1847 | ||
| 1848 | yield* cli.listPullRequests({ | |
| 1849 | cwd: "/w", | |
| 1850 | repository: "acme/web", | |
| 1851 | host: "github.com", | |
| 1852 | state: "open", | |
| 1853 | involvement: "all", | |
| 1854 | viewer: "bilal", | |
| 1855 | limit: 10, | |
| 1856 | query: String.raw`a\" is:merged`, | |
| 1857 | }); | |
| 1858 | ||
| 1859 | // GitHub reads `\\` as one backslash and `\"` as one quote, so the phrase ends where | |
| 1860 | // this says it does; escaping the quote alone would have closed it early. | |
| 1861 | expect(searchOfCall(0)).toBe( | |
| 1862 | String.raw`is:pr is:open "a\\\" is:merged" sort:updated-desc repo:acme/web`, | |
| 1863 | ); | |
| 1864 | }), | |
| 1865 | ); | |
| 1866 | ||
| 1867 | it.effect("asks for nothing but the order when the reader typed only spaces", () => | |
| 1868 | Effect.gen(function* () { | |
| 1869 | mockedExecute.mockImplementation((call) => | |
| 1870 | Effect.succeed( | |
| 1871 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 1872 | ? emptyList() | |
| 1873 | : emptySearch(), | |
| 1874 | ), | |
| 1875 | ); | |
| 1876 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1877 | ||
| 1878 | yield* cli.listPullRequests({ | |
| 1879 | cwd: "/w", | |
| 1880 | repository: "acme/web", | |
| 1881 | host: "github.com", | |
| 1882 | state: "open", | |
| 1883 | involvement: "all", | |
| 1884 | viewer: "bilal", | |
| 1885 | limit: 10, | |
| 1886 | query: " ", | |
| 1887 | }); | |
| 1888 | ||
| 1889 | // An empty phrase would match nothing rather than everything, so it is left out; the | |
| 1890 | // order the page reads rows in is asked for whether or not anything was typed. | |
| 1891 | expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); | |
| 1892 | }), | |
| 1893 | ); | |
| 1894 | ||
| 1895 | it.effect("carries on from the instant the last slice ended on", () => | |
| 1896 | Effect.gen(function* () { | |
| 1897 | mockedExecute.mockReturnValue(Effect.succeed(output(pullRequests(3, 1)))); | |
| 1898 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1899 | ||
| 1900 | const batch = yield* cli.listPullRequests({ | |
| 1901 | cwd: "/w", | |
| 1902 | repository: "acme/web", | |
| 1903 | host: "github.com", | |
| 1904 | state: "open", | |
| 1905 | involvement: "all", | |
| 1906 | viewer: "bilal", | |
| 1907 | limit: 10, | |
| 1908 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, | |
| 1909 | }); | |
| 1910 | ||
| 1911 | // Inclusive, so the rows already sent at that instant come back for the caller to drop — | |
| 1912 | // which is what keeps the ones beside them from being skipped. | |
| 1913 | expect(searchOfCall(0)).toBe( | |
| 1914 | "is:pr is:open updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web", | |
| 1915 | ); | |
| 1916 | assert.isTrue(batch.continues); | |
| 1917 | }), | |
| 1918 | ); | |
| 1919 | ||
| 1920 | it.effect("answers a search that found nothing with nothing, not with the whole repository", () => | |
| 1921 | Effect.gen(function* () { | |
| 1922 | // The fallback is for a repository the index does not cover. Under a text search an empty | |
| 1923 | // answer means the text matched nothing, and listing everything instead would fill the | |
| 1924 | // page with rows the reader did not search for. | |
| 1925 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1926 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1927 | ||
| 1928 | const batch = yield* cli.listPullRequests({ | |
| 1929 | cwd: "/w", | |
| 1930 | repository: "acme/web", | |
| 1931 | host: "github.com", | |
| 1932 | state: "open", | |
| 1933 | involvement: "all", | |
| 1934 | viewer: "bilal", | |
| 1935 | limit: 10, | |
| 1936 | query: "fdsfklj", | |
| 1937 | }); | |
| 1938 | ||
| 1939 | assert.strictEqual(batch.items.length, 0); | |
| 1940 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 1941 | }), | |
| 1942 | ); | |
| 1943 | ||
| 1944 | it.effect("reads a repository GitHub will not search from its own list", () => | |
| 1945 | Effect.gen(function* () { | |
| 1946 | // GitHub answers for a repository outside its search index with no rows and no error. | |
| 1947 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1948 | mockedExecute.mockReturnValueOnce( | |
| 1949 | Effect.succeed(output(listedPullRequests(3, 1, () => ({ state: "CLOSED" })))), | |
| 1950 | ); | |
| 1951 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1952 | ||
| 1953 | const batch = yield* cli.listPullRequests({ | |
| 1954 | cwd: "/w", | |
| 1955 | repository: "acme/web", | |
| 1956 | host: "github.com", | |
| 1957 | state: "closed", | |
| 1958 | involvement: "all", | |
| 1959 | viewer: "bilal", | |
| 1960 | limit: 10, | |
| 1961 | }); | |
| 1962 | ||
| 1963 | assert.strictEqual(batch.items.length, 3); | |
| 1964 | // The fallback itself uses no search, then narrows the decoded rows locally. They arrive | |
| 1965 | // newest-created first, so nothing can carry on from them. | |
| 1966 | expect(searchOfCall(1)).toBeUndefined(); | |
| 1967 | expect(varsAt(1)).toMatchObject({ owner: "acme", name: "web", states: ["CLOSED", "MERGED"] }); | |
| 1968 | assert.isFalse(batch.continues); | |
| 1969 | }), | |
| 1970 | ); | |
| 1971 | ||
| 1972 | it.effect("keeps state and involvement filters on the search-free fallback", () => | |
| 1973 | Effect.gen(function* () { | |
| 1974 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 1975 | mockedExecute.mockReturnValueOnce( | |
| 1976 | Effect.succeed( | |
| 1977 | output( | |
| 1978 | listedPullRequests(4, 1, (number) => ({ | |
| 1979 | state: number === 4 ? "OPEN" : "CLOSED", | |
| 1980 | ...(number === 3 ? { mergedAt: "2026-07-03T00:00:00Z" } : {}), | |
| 1981 | reviewRequests: | |
| 1982 | number === 2 ? [{ slug: "platform", name: "Platform" }] : [{ login: "bilal" }], | |
| 1983 | })), | |
| 1984 | ), | |
| 1985 | ), | |
| 1986 | ); | |
| 1987 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 1988 | ||
| 1989 | const batch = yield* cli.listPullRequests({ | |
| 1990 | cwd: "/w", | |
| 1991 | repository: "acme/web", | |
| 1992 | host: "github.com", | |
| 1993 | state: "closed", | |
| 1994 | involvement: "reviewing", | |
| 1995 | viewer: "bilal", | |
| 1996 | limit: 10, | |
| 1997 | }); | |
| 1998 | ||
| 1999 | // Individual requests for this viewer and team requests survive. The fallback cannot | |
| 2000 | // resolve team membership, so dropping team-routed reviews would hide legitimate work. | |
| 2001 | expect(batch.items.map((item) => item.number)).toEqual([1, 2]); | |
| 2002 | expect(searchOfCall(1)).toBeUndefined(); | |
| 2003 | assert.isFalse(batch.continues); | |
| 2004 | }), | |
| 2005 | ); | |
| 2006 | ||
| 2007 | it.effect("grows the search-free fallback until it fills the filtered page", () => | |
| 2008 | Effect.gen(function* () { | |
| 2009 | const unrelated = () => ({ reviewRequests: [{ login: "somebody-else" }] }); | |
| 2010 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); | |
| 2011 | mockedExecute.mockReturnValueOnce( | |
| 2012 | Effect.succeed(output(listedPullRequests(3, 1, unrelated))), | |
| 2013 | ); | |
| 2014 | mockedExecute.mockReturnValueOnce( | |
| 2015 | Effect.succeed( | |
| 2016 | output( | |
| 2017 | listedPullRequests(4, 1, (number) => | |
| 2018 | number === 4 ? { reviewRequests: [{ login: "bilal" }] } : unrelated(), | |
| 2019 | ), | |
| 2020 | ), | |
| 2021 | ), | |
| 2022 | ); | |
| 2023 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2024 | ||
| 2025 | const batch = yield* cli.listPullRequests({ | |
| 2026 | cwd: "/w", | |
| 2027 | repository: "acme/web", | |
| 2028 | host: "github.com", | |
| 2029 | state: "open", | |
| 2030 | involvement: "reviewing", | |
| 2031 | viewer: "bilal", | |
| 2032 | limit: 2, | |
| 2033 | }); | |
| 2034 | ||
| 2035 | expect(batch.items.map((item) => item.number)).toEqual([4]); | |
| 2036 | expect(queryAt(1)).toContain("first: 3,"); | |
| 2037 | expect(queryAt(2)).toContain("first: 6,"); | |
| 2038 | assert.isFalse(batch.truncated); | |
| 2039 | }), | |
| 2040 | ); | |
| 2041 | ||
| 2042 | it.effect("bounds a sparse search-free fallback and reports the unread tail", () => | |
| 2043 | Effect.gen(function* () { | |
| 2044 | // A repository with far more pull requests than the bound, none of them for this reader. | |
| 2045 | let listed = 0; | |
| 2046 | mockedExecute.mockImplementation((call) => { | |
| 2047 | if (call.kind !== "graphql" || !call.query.includes("pullRequests(")) { | |
| 2048 | return Effect.succeed(emptySearch()); | |
| 2049 | } | |
| 2050 | const first = Number(/first: (\d+)/.exec(call.query)?.[1]); | |
| 2051 | const nodes = rows(first, listed + 1, () => ({ | |
| 2052 | reviewRequests: [{ login: "somebody-else" }], | |
| 2053 | })); | |
| 2054 | listed += first; | |
| 2055 | return Effect.succeed( | |
| 2056 | output( | |
| 2057 | encodeJson({ | |
| 2058 | data: { | |
| 2059 | repository: { | |
| 2060 | pullRequests: { pageInfo: { hasNextPage: true, endCursor: `c${listed}` }, nodes }, | |
| 2061 | }, | |
| 2062 | }, | |
| 2063 | }), | |
| 2064 | ), | |
| 2065 | ); | |
| 2066 | }); | |
| 2067 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2068 | ||
| 2069 | const batch = yield* cli.listPullRequests({ | |
| 2070 | cwd: "/w", | |
| 2071 | repository: "acme/web", | |
| 2072 | host: "github.com", | |
| 2073 | state: "open", | |
| 2074 | involvement: "reviewing", | |
| 2075 | viewer: "bilal", | |
| 2076 | limit: 2, | |
| 2077 | }); | |
| 2078 | ||
| 2079 | // The last widening reads a thousand rows, a page of a hundred at a time, and stops there. | |
| 2080 | const last = mockedExecute.mock.calls.length - 1; | |
| 2081 | expect(varsAt(last)["after"]).toBe(`c${listed - 100}`); | |
| 2082 | expect(listed - (3 + 6 + 12 + 24 + 48 + 96 + 192 + 384 + 768)).toBe(1000); | |
| 2083 | assert.strictEqual(batch.items.length, 0); | |
| 2084 | assert.isTrue(batch.truncated); | |
| 2085 | }), | |
| 2086 | ); | |
| 2087 | ||
| 2088 | it.effect("takes an empty slice for a repository that has run out, not one to read again", () => | |
| 2089 | Effect.gen(function* () { | |
| 2090 | mockedExecute.mockImplementation((call) => | |
| 2091 | Effect.succeed( | |
| 2092 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 2093 | ? emptyList() | |
| 2094 | : emptySearch(), | |
| 2095 | ), | |
| 2096 | ); | |
| 2097 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2098 | ||
| 2099 | yield* cli.listPullRequests({ | |
| 2100 | cwd: "/w", | |
| 2101 | repository: "acme/web", | |
| 2102 | host: "github.com", | |
| 2103 | state: "open", | |
| 2104 | involvement: "all", | |
| 2105 | viewer: "bilal", | |
| 2106 | limit: 10, | |
| 2107 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, | |
| 2108 | }); | |
| 2109 | ||
| 2110 | // A repository that answered the search once answers it again, so an empty slice under a | |
| 2111 | // cursor is the end of it rather than a repository search cannot reach. | |
| 2112 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 2113 | }), | |
| 2114 | ); | |
| 2115 | ||
| 2116 | /** The state a merge or branch update reads first: behind its base and blocked on checks. */ | |
| 2117 | const actionState = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({ | |
| 2118 | data: { | |
| 2119 | repository: { | |
| 2120 | pullRequest: { | |
| 2121 | id: "PR_7", | |
| 2122 | headRefOid: "abc123", | |
| 2123 | isMergeQueueEnabled: false, | |
| 2124 | mergeStateStatus: "BLOCKED", | |
| 2125 | baseRef: { compare: { behindBy: 2 } }, | |
| 2126 | ...pullRequest, | |
| 2127 | }, | |
| 2128 | }, | |
| 2129 | }, | |
| 2130 | }); | |
| 2131 | const mergeMessage = (body: string, isMergeQueueEnabled = false) => ({ | |
| 2132 | data: { | |
| 2133 | repository: { | |
| 2134 | pullRequest: { isMergeQueueEnabled, headRefOid: "abc123", viewerMergeBodyText: body }, | |
| 2135 | }, | |
| 2136 | }, | |
| 2137 | }); | |
| 2138 | ||
| 2139 | it.effect("updates a stale branch with a merge commit unless asked to rebase", () => | |
| 2140 | Effect.gen(function* () { | |
| 2141 | route(["query PullRequestActionState", actionState()]); | |
| 2142 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2143 | ||
| 2144 | yield* cli.runPullRequestAction({ | |
| 2145 | cwd: "/w", | |
| 2146 | repository: "acme/web", | |
| 2147 | host: "github.com", | |
| 2148 | number: 7, | |
| 2149 | action: "update-branch", | |
| 2150 | }); | |
| 2151 | yield* cli.runPullRequestAction({ | |
| 2152 | cwd: "/w", | |
| 2153 | repository: "acme/web", | |
| 2154 | host: "github.com", | |
| 2155 | number: 7, | |
| 2156 | action: "update-branch", | |
| 2157 | updateMethod: "rebase", | |
| 2158 | }); | |
| 2159 | ||
| 2160 | // GitHub's own default: a merge commit unless asked to rebase, pinned to the head it read. | |
| 2161 | expect(variablesOf("updatePullRequestBranch(")).toEqual([ | |
| 2162 | { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "MERGE" }, | |
| 2163 | { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "REBASE" }, | |
| 2164 | ]); | |
| 2165 | }), | |
| 2166 | ); | |
| 2167 | ||
| 2168 | it.effect("merges with the strategy it was asked for", () => | |
| 2169 | Effect.gen(function* () { | |
| 2170 | route(["query PullRequestActionState", actionState()]); | |
| 2171 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2172 | ||
| 2173 | yield* cli.runPullRequestAction({ | |
| 2174 | cwd: "/w", | |
| 2175 | repository: "acme/web", | |
| 2176 | host: "github.com", | |
| 2177 | number: 7, | |
| 2178 | action: "merge", | |
| 2179 | mergeMethod: "squash", | |
| 2180 | }); | |
| 2181 | ||
| 2182 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2183 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, | |
| 2184 | ]); | |
| 2185 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); | |
| 2186 | }), | |
| 2187 | ); | |
| 2188 | ||
| 2189 | it.effect.each(["merge", "enable-auto-merge"] as const)( | |
| 2190 | "removes agent credits from the proposed message for %s", | |
| 2191 | (action) => | |
| 2192 | Effect.gen(function* () { | |
| 2193 | route( | |
| 2194 | [ | |
| 2195 | "query PullRequestMergeMessage", | |
| 2196 | mergeMessage( | |
| 2197 | "Details\n\nCo-authored-by: Alice <alice@example.com>\nCo-authored-by: Claude <noreply@anthropic.com>", | |
| 2198 | ), | |
| 2199 | ], | |
| 2200 | ["query PullRequestActionState", actionState()], | |
| 2201 | ); | |
| 2202 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2203 | yield* cli.runPullRequestAction({ | |
| 2204 | cwd: "/w", | |
| 2205 | repository: "acme/web", | |
| 2206 | host: "github.com", | |
| 2207 | number: 7, | |
| 2208 | action, | |
| 2209 | mergeMethod: "squash", | |
| 2210 | removeAgentCreditsOnMerge: true, | |
| 2211 | }); | |
| 2212 | expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("SQUASH"); | |
| 2213 | expect( | |
| 2214 | variablesOf(action === "merge" ? "mergePullRequest(" : "enablePullRequestAutoMerge("), | |
| 2215 | ).toEqual([ | |
| 2216 | { | |
| 2217 | input: { | |
| 2218 | pullRequestId: "PR_7", | |
| 2219 | mergeMethod: "SQUASH", | |
| 2220 | // Pinned to the head the message was read from, so it cannot describe other commits. | |
| 2221 | expectedHeadOid: "abc123", | |
| 2222 | commitBody: "Details\n\nCo-authored-by: Alice <alice@example.com>", | |
| 2223 | }, | |
| 2224 | }, | |
| 2225 | ]); | |
| 2226 | }), | |
| 2227 | ); | |
| 2228 | ||
| 2229 | it.effect.each([ | |
| 2230 | { | |
| 2231 | description: "an unchanged message", | |
| 2232 | body: "Details\n\nCo-authored-by: Alice <alice@example.com>", | |
| 2233 | queued: false, | |
| 2234 | }, | |
| 2235 | { | |
| 2236 | description: "a merge queue", | |
| 2237 | body: "Co-authored-by: Claude <noreply@anthropic.com>", | |
| 2238 | queued: true, | |
| 2239 | }, | |
| 2240 | ] as const)("keeps GitHub's default message for $description", ({ body, queued }) => | |
| 2241 | Effect.gen(function* () { | |
| 2242 | route( | |
| 2243 | ["query PullRequestMergeMessage", mergeMessage(body, queued)], | |
| 2244 | ["query PullRequestActionState", actionState({ isMergeQueueEnabled: queued })], | |
| 2245 | ); | |
| 2246 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2247 | yield* cli.runPullRequestAction({ | |
| 2248 | cwd: "/w", | |
| 2249 | repository: "acme/web", | |
| 2250 | host: "github.com", | |
| 2251 | number: 7, | |
| 2252 | action: "merge", | |
| 2253 | removeAgentCreditsOnMerge: true, | |
| 2254 | }); | |
| 2255 | expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("MERGE"); | |
| 2256 | // A merge queue takes the pull request through auto-merge, with its own message. | |
| 2257 | expect(variablesOf(queued ? "enablePullRequestAutoMerge(" : "mergePullRequest(")).toEqual([ | |
| 2258 | { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, | |
| 2259 | ]); | |
| 2260 | }), | |
| 2261 | ); | |
| 2262 | ||
| 2263 | it.effect("passes an explicitly empty body when the proposed message only credits an agent", () => | |
| 2264 | Effect.gen(function* () { | |
| 2265 | route( | |
| 2266 | [ | |
| 2267 | "query PullRequestMergeMessage", | |
| 2268 | mergeMessage("Co-authored-by: Claude <noreply@anthropic.com>"), | |
| 2269 | ], | |
| 2270 | ["query PullRequestActionState", actionState()], | |
| 2271 | ); | |
| 2272 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2273 | yield* cli.runPullRequestAction({ | |
| 2274 | cwd: "/w", | |
| 2275 | repository: "acme/web", | |
| 2276 | host: "github.com", | |
| 2277 | number: 7, | |
| 2278 | action: "merge", | |
| 2279 | removeAgentCreditsOnMerge: true, | |
| 2280 | }); | |
| 2281 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2282 | { | |
| 2283 | input: { | |
| 2284 | pullRequestId: "PR_7", | |
| 2285 | mergeMethod: "MERGE", | |
| 2286 | expectedHeadOid: "abc123", | |
| 2287 | commitBody: "", | |
| 2288 | }, | |
| 2289 | }, | |
| 2290 | ]); | |
| 2291 | }), | |
| 2292 | ); | |
| 2293 | ||
| 2294 | it.effect("does not fetch a message for rebase merges", () => | |
| 2295 | Effect.gen(function* () { | |
| 2296 | route(["query PullRequestActionState", actionState()]); | |
| 2297 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2298 | yield* cli.runPullRequestAction({ | |
| 2299 | cwd: "/w", | |
| 2300 | repository: "acme/web", | |
| 2301 | host: "github.com", | |
| 2302 | number: 7, | |
| 2303 | action: "merge", | |
| 2304 | mergeMethod: "rebase", | |
| 2305 | removeAgentCreditsOnMerge: true, | |
| 2306 | }); | |
| 2307 | expect(variablesOf("query PullRequestMergeMessage")).toEqual([]); | |
| 2308 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2309 | { input: { pullRequestId: "PR_7", mergeMethod: "REBASE" } }, | |
| 2310 | ]); | |
| 2311 | }), | |
| 2312 | ); | |
| 2313 | ||
| 2314 | it.effect("refuses to merge when the proposed message cannot be read", () => | |
| 2315 | Effect.gen(function* () { | |
| 2316 | route( | |
| 2317 | ["query PullRequestMergeMessage", { data: { repository: { pullRequest: null } } }], | |
| 2318 | ["query PullRequestActionState", actionState()], | |
| 2319 | ); | |
| 2320 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2321 | const result = yield* Effect.result( | |
| 2322 | cli.runPullRequestAction({ | |
| 2323 | cwd: "/w", | |
| 2324 | repository: "acme/web", | |
| 2325 | host: "github.com", | |
| 2326 | number: 7, | |
| 2327 | action: "merge", | |
| 2328 | removeAgentCreditsOnMerge: true, | |
| 2329 | }), | |
| 2330 | ); | |
| 2331 | expect(result._tag).toBe("Failure"); | |
| 2332 | expect(variablesOf("mergePullRequest(")).toEqual([]); | |
| 2333 | }), | |
| 2334 | ); | |
| 2335 | ||
| 2336 | it.effect("arms auto-merge with the same strategy a merge would have used", () => | |
| 2337 | Effect.gen(function* () { | |
| 2338 | route(["query PullRequestActionState", actionState()]); | |
| 2339 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2340 | ||
| 2341 | yield* cli.runPullRequestAction({ | |
| 2342 | cwd: "/w", | |
| 2343 | repository: "acme/web", | |
| 2344 | host: "github.com", | |
| 2345 | number: 7, | |
| 2346 | action: "enable-auto-merge", | |
| 2347 | mergeMethod: "squash", | |
| 2348 | }); | |
| 2349 | // No strategy asked for is GitHub's own default, exactly as it is for a merge now. | |
| 2350 | yield* cli.runPullRequestAction({ | |
| 2351 | cwd: "/w", | |
| 2352 | repository: "acme/web", | |
| 2353 | host: "github.com", | |
| 2354 | number: 7, | |
| 2355 | action: "enable-auto-merge", | |
| 2356 | }); | |
| 2357 | ||
| 2358 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([ | |
| 2359 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, | |
| 2360 | { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, | |
| 2361 | ]); | |
| 2362 | expect(variablesOf("mergePullRequest(")).toEqual([]); | |
| 2363 | }), | |
| 2364 | ); | |
| 2365 | ||
| 2366 | it.effect("merges at once when auto-merge is asked of a pull request that is ready now", () => | |
| 2367 | Effect.gen(function* () { | |
| 2368 | route(["query PullRequestActionState", actionState({ mergeStateStatus: "CLEAN" })]); | |
| 2369 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2370 | ||
| 2371 | yield* cli.runPullRequestAction({ | |
| 2372 | cwd: "/w", | |
| 2373 | repository: "acme/web", | |
| 2374 | host: "github.com", | |
| 2375 | number: 7, | |
| 2376 | action: "enable-auto-merge", | |
| 2377 | mergeMethod: "squash", | |
| 2378 | }); | |
| 2379 | ||
| 2380 | expect(variablesOf("mergePullRequest(")).toEqual([ | |
| 2381 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, | |
| 2382 | ]); | |
| 2383 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); | |
| 2384 | }), | |
| 2385 | ); | |
| 2386 | ||
| 2387 | it.effect("takes auto-merge back off without naming a strategy", () => | |
| 2388 | Effect.gen(function* () { | |
| 2389 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2390 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2391 | ||
| 2392 | yield* cli.runPullRequestAction({ | |
| 2393 | cwd: "/w", | |
| 2394 | repository: "acme/web", | |
| 2395 | host: "github.com", | |
| 2396 | number: 7, | |
| 2397 | action: "disable-auto-merge", | |
| 2398 | mergeMethod: "squash", | |
| 2399 | }); | |
| 2400 | ||
| 2401 | expect(variablesOf("disablePullRequestAutoMerge(")).toEqual([{ pullRequestId: "PR_7" }]); | |
| 2402 | }), | |
| 2403 | ); | |
| 2404 | ||
| 2405 | it.effect("opens a pull request that reverts a merged pull request", () => | |
| 2406 | Effect.gen(function* () { | |
| 2407 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2408 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2409 | ||
| 2410 | yield* cli.runPullRequestAction({ | |
| 2411 | cwd: "/w", | |
| 2412 | repository: "acme/web", | |
| 2413 | host: "github.com", | |
| 2414 | number: 7, | |
| 2415 | action: "revert", | |
| 2416 | }); | |
| 2417 | ||
| 2418 | expect(variablesOf("revertPullRequest(")).toEqual([{ pullRequestId: "PR_7" }]); | |
| 2419 | }), | |
| 2420 | ); | |
| 2421 | ||
| 2422 | it.effect("does not approve action-required runs for a same-repository pull request", () => | |
| 2423 | Effect.gen(function* () { | |
| 2424 | mockedExecute.mockReturnValueOnce( | |
| 2425 | Effect.succeed( | |
| 2426 | output( | |
| 2427 | encodeJson( | |
| 2428 | coreResponse({ | |
| 2429 | number: 7, | |
| 2430 | title: "Pull request 7", | |
| 2431 | url: "https://github.com/acme/web/pull/7", | |
| 2432 | headRefName: "feat/page", | |
| 2433 | headRefOid: "abc123", | |
| 2434 | isCrossRepository: false, | |
| 2435 | headRepositoryOwner: { login: "acme" }, | |
| 2436 | baseRefName: "main", | |
| 2437 | createdAt: "2026-07-01T00:00:00Z", | |
| 2438 | updatedAt: "2026-07-02T00:00:00Z", | |
| 2439 | }), | |
| 2440 | ), | |
| 2441 | ), | |
| 2442 | ), | |
| 2443 | ); | |
| 2444 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2445 | ||
| 2446 | yield* cli.runPullRequestAction({ | |
| 2447 | cwd: "/w", | |
| 2448 | repository: "acme/web", | |
| 2449 | host: "github.com", | |
| 2450 | number: 7, | |
| 2451 | action: "approve-workflows", | |
| 2452 | }); | |
| 2453 | ||
| 2454 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 2455 | }), | |
| 2456 | ); | |
| 2457 | ||
| 2458 | it.effect("finds and approves every workflow waiting on a maintainer", () => | |
| 2459 | Effect.gen(function* () { | |
| 2460 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([10, 11])); | |
| 2461 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2462 | ||
| 2463 | yield* cli.runPullRequestAction({ | |
| 2464 | cwd: "/w", | |
| 2465 | repository: "acme/web", | |
| 2466 | host: "github.com", | |
| 2467 | number: 7, | |
| 2468 | action: "approve-workflows", | |
| 2469 | }); | |
| 2470 | ||
| 2471 | // Open pull requests on this head branch, and runs scoped to this exact head. | |
| 2472 | expect(variablesOf("headRefName: $head")[0]).toEqual({ | |
| 2473 | owner: "acme", | |
| 2474 | name: "web", | |
| 2475 | head: "feat/page", | |
| 2476 | after: null, | |
| 2477 | }); | |
| 2478 | expect(restCallsTo("actions/runs?")[0]?.path).toBe( | |
| 2479 | "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fpage&event=pull_request&status=action_required&per_page=100&page=1", | |
| 2480 | ); | |
| 2481 | // Each run is approved only after the head is read again and still lists it. | |
| 2482 | expect(restCallsTo("/approve").map((call) => [call.method, call.path])).toEqual([ | |
| 2483 | ["POST", "repos/acme/web/actions/runs/10/approve"], | |
| 2484 | ["POST", "repos/acme/web/actions/runs/11/approve"], | |
| 2485 | ]); | |
| 2486 | assert.strictEqual(restCallsTo("actions/runs?").length, 3); | |
| 2487 | }), | |
| 2488 | ); | |
| 2489 | ||
| 2490 | it.effect("counts revalidated runs that wait on a maintainer as GitHub reports them", () => | |
| 2491 | Effect.gen(function* () { | |
| 2492 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([])); | |
| 2493 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2494 | ||
| 2495 | const runs = yield* cli | |
| 2496 | .listWorkflowRunsRequiringApproval({ | |
| 2497 | cwd: "/w", | |
| 2498 | repository: "acme/web", | |
| 2499 | host: "github.com", | |
| 2500 | number: 7, | |
| 2501 | headSha: "abc123", | |
| 2502 | headBranch: "feat/page", | |
| 2503 | headRepositoryOwner: "octocat", | |
| 2504 | isCrossRepository: true, | |
| 2505 | }) | |
| 2506 | .pipe( | |
| 2507 | Effect.provideService(KnownWorkflowRuns, { | |
| 2508 | headSha: "abc123", | |
| 2509 | runs: [ | |
| 2510 | // Live shape: waiting on approval is completed + action_required. | |
| 2511 | { | |
| 2512 | id: 10, | |
| 2513 | status: "completed", | |
| 2514 | conclusion: "action_required", | |
| 2515 | head_branch: "feat/page", | |
| 2516 | }, | |
| 2517 | { id: 11, status: "completed", conclusion: "success", head_branch: "feat/page" }, | |
| 2518 | ], | |
| 2519 | }), | |
| 2520 | ); | |
| 2521 | ||
| 2522 | expect(runs.map((run) => run.id)).toEqual([10]); | |
| 2523 | assert.strictEqual(restCallsTo("actions/runs?").length, 0); | |
| 2524 | }), | |
| 2525 | ); | |
| 2526 | ||
| 2527 | it.effect("refuses a stale workflow approval after the pull request head changes", () => | |
| 2528 | Effect.gen(function* () { | |
| 2529 | let detailReads = 0; | |
| 2530 | workflowApprovalRoutes( | |
| 2531 | () => crossRepositoryDetail(++detailReads === 1 ? "abc123" : "def456"), | |
| 2532 | heads([7]), | |
| 2533 | workflowRuns([10]), | |
| 2534 | ); | |
| 2535 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2536 | ||
| 2537 | const error = yield* Effect.flip( | |
| 2538 | cli.runPullRequestAction({ | |
| 2539 | cwd: "/w", | |
| 2540 | repository: "acme/web", | |
| 2541 | host: "github.com", | |
| 2542 | number: 7, | |
| 2543 | action: "approve-workflows", | |
| 2544 | }), | |
| 2545 | ); | |
| 2546 | ||
| 2547 | expect(error).toMatchObject({ | |
| 2548 | _tag: "GitHubWorkflowApprovalHeadChangedError", | |
| 2549 | number: 7, | |
| 2550 | }); | |
| 2551 | assert.strictEqual(detailReads, 2); | |
| 2552 | expect(restCallsTo("/approve")).toEqual([]); | |
| 2553 | }), | |
| 2554 | ); | |
| 2555 | ||
| 2556 | it.effect("reads workflow runs and their pull request scope concurrently", () => | |
| 2557 | Effect.gen(function* () { | |
| 2558 | const headsStarted = yield* Deferred.make<void>(); | |
| 2559 | const runsStarted = yield* Deferred.make<void>(); | |
| 2560 | mockedExecute.mockImplementation((call) => | |
| 2561 | call.kind === "graphql" | |
| 2562 | ? Deferred.succeed(headsStarted, undefined).pipe( | |
| 2563 | Effect.andThen(Deferred.await(runsStarted)), | |
| 2564 | Effect.as(output(encodeJson(heads([7])))), | |
| 2565 | ) | |
| 2566 | : Deferred.succeed(runsStarted, undefined).pipe( | |
| 2567 | Effect.andThen(Deferred.await(headsStarted)), | |
| 2568 | Effect.as(output(encodeJson(workflowRuns([10])))), | |
| 2569 | ), | |
| 2570 | ); | |
| 2571 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2572 | ||
| 2573 | const runs = yield* cli.listWorkflowRunsRequiringApproval({ | |
| 2574 | cwd: "/w", | |
| 2575 | repository: "acme/web", | |
| 2576 | host: "github.com", | |
| 2577 | number: 7, | |
| 2578 | headSha: "abc123", | |
| 2579 | headBranch: "feat/page", | |
| 2580 | headRepositoryOwner: "octocat", | |
| 2581 | isCrossRepository: true, | |
| 2582 | }); | |
| 2583 | ||
| 2584 | expect(runs).toEqual([{ id: 10, name: "build", url: "https://example.com/10" }]); | |
| 2585 | }), | |
| 2586 | ); | |
| 2587 | ||
| 2588 | it.effect("refuses workflow approval when one head belongs to several pull requests", () => | |
| 2589 | Effect.gen(function* () { | |
| 2590 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7, 8]), workflowRuns([])); | |
| 2591 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2592 | ||
| 2593 | const error = yield* Effect.flip( | |
| 2594 | cli.listWorkflowRunsRequiringApproval({ | |
| 2595 | cwd: "/w", | |
| 2596 | repository: "acme/web", | |
| 2597 | host: "github.com", | |
| 2598 | number: 7, | |
| 2599 | headSha: "abc123", | |
| 2600 | headBranch: "feat/page", | |
| 2601 | headRepositoryOwner: "octocat", | |
| 2602 | isCrossRepository: true, | |
| 2603 | }), | |
| 2604 | ); | |
| 2605 | ||
| 2606 | expect(error).toMatchObject({ | |
| 2607 | _tag: "GitHubWorkflowApprovalRefusedError", | |
| 2608 | reason: "head-not-unique", | |
| 2609 | number: 7, | |
| 2610 | observedCount: 2, | |
| 2611 | limit: 1_000, | |
| 2612 | }); | |
| 2613 | expect(error.message).toContain("instead of uniquely matching #7"); | |
| 2614 | }), | |
| 2615 | ); | |
| 2616 | ||
| 2617 | it.effect("refuses workflow approval when GitHub omits the head repository", () => | |
| 2618 | Effect.gen(function* () { | |
| 2619 | mockedExecute.mockReturnValueOnce( | |
| 2620 | Effect.succeed( | |
| 2621 | output( | |
| 2622 | encodeJson( | |
| 2623 | coreResponse({ | |
| 2624 | number: 7, | |
| 2625 | title: "Pull request 7", | |
| 2626 | url: "https://github.com/acme/web/pull/7", | |
| 2627 | headRefName: "feat/page", | |
| 2628 | headRefOid: "abc123", | |
| 2629 | isCrossRepository: true, | |
| 2630 | headRepositoryOwner: null, | |
| 2631 | baseRefName: "main", | |
| 2632 | createdAt: "2026-07-01T00:00:00Z", | |
| 2633 | updatedAt: "2026-07-02T00:00:00Z", | |
| 2634 | }), | |
| 2635 | ), | |
| 2636 | ), | |
| 2637 | ), | |
| 2638 | ); | |
| 2639 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2640 | ||
| 2641 | const error = yield* Effect.flip( | |
| 2642 | cli.runPullRequestAction({ | |
| 2643 | cwd: "/w", | |
| 2644 | repository: "acme/web", | |
| 2645 | host: "github.com", | |
| 2646 | number: 7, | |
| 2647 | action: "approve-workflows", | |
| 2648 | }), | |
| 2649 | ); | |
| 2650 | ||
| 2651 | expect(error).toMatchObject({ | |
| 2652 | _tag: "GitHubWorkflowApprovalHeadUnavailableError", | |
| 2653 | number: 7, | |
| 2654 | }); | |
| 2655 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 2656 | }), | |
| 2657 | ); | |
| 2658 | ||
| 2659 | it.effect("surfaces a workflow run list beyond the safe approval bound", () => | |
| 2660 | Effect.gen(function* () { | |
| 2661 | let runPages = 0; | |
| 2662 | mockedExecute.mockImplementation((call) => { | |
| 2663 | if (call.kind === "graphql") return Effect.succeed(output(encodeJson(heads([7])))); | |
| 2664 | const page = runPages++; | |
| 2665 | return Effect.succeed( | |
| 2666 | output( | |
| 2667 | encodeJson( | |
| 2668 | workflowRuns(Array.from({ length: 100 }, (_, index) => page * 100 + index + 1)), | |
| 2669 | ), | |
| 2670 | ), | |
| 2671 | ); | |
| 2672 | }); | |
| 2673 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2674 | ||
| 2675 | const error = yield* Effect.flip( | |
| 2676 | cli.listWorkflowRunsRequiringApproval({ | |
| 2677 | cwd: "/w", | |
| 2678 | repository: "acme/web", | |
| 2679 | host: "github.com", | |
| 2680 | number: 7, | |
| 2681 | headSha: "abc123", | |
| 2682 | headBranch: "feat/page", | |
| 2683 | headRepositoryOwner: "octocat", | |
| 2684 | isCrossRepository: true, | |
| 2685 | }), | |
| 2686 | ); | |
| 2687 | ||
| 2688 | // Paged a hundred at a time, it stops at the first page past the bound. | |
| 2689 | expect(error).toMatchObject({ | |
| 2690 | _tag: "GitHubWorkflowApprovalRefusedError", | |
| 2691 | reason: "run-list-truncated", | |
| 2692 | number: 7, | |
| 2693 | observedCount: 1_100, | |
| 2694 | limit: 1_000, | |
| 2695 | }); | |
| 2696 | expect(error.message).toContain("more than 1000 workflow runs"); | |
| 2697 | assert.strictEqual(runPages, 11); | |
| 2698 | }), | |
| 2699 | ); | |
| 2700 | ||
| 2701 | it.effect("returns a pull request to draft by converting it", () => | |
| 2702 | Effect.gen(function* () { | |
| 2703 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2704 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2705 | ||
| 2706 | yield* cli.runPullRequestAction({ | |
| 2707 | cwd: "/w", | |
| 2708 | repository: "acme/web", | |
| 2709 | host: "github.com", | |
| 2710 | number: 7, | |
| 2711 | action: "draft", | |
| 2712 | }); | |
| 2713 | ||
| 2714 | expect(variablesOf("convertPullRequestToDraft(")).toEqual([{ pullRequestId: "PR_7" }]); | |
| 2715 | expect(variablesOf("markPullRequestReadyForReview(")).toEqual([]); | |
| 2716 | }), | |
| 2717 | ); | |
| 2718 | ||
| 2719 | it.effect("sends a comment body as a variable, never inside the document", () => | |
| 2720 | Effect.gen(function* () { | |
| 2721 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); | |
| 2722 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2723 | ||
| 2724 | yield* cli.commentOnPullRequest({ | |
| 2725 | cwd: "/w", | |
| 2726 | repository: "acme/web", | |
| 2727 | host: "github.com", | |
| 2728 | number: 7, | |
| 2729 | body: "Looks good.", | |
| 2730 | }); | |
| 2731 | ||
| 2732 | expect(variablesOf("addComment(")).toEqual([{ subjectId: "PR_7", body: "Looks good." }]); | |
| 2733 | expect(queryAt(mockedExecute.mock.calls.length - 1)).not.toContain("Looks good."); | |
| 2734 | }), | |
| 2735 | ); | |
| 2736 | ||
| 2737 | it.effect("names the host on every repository it addresses", () => | |
| 2738 | Effect.gen(function* () { | |
| 2739 | mockedExecute.mockImplementation((call) => | |
| 2740 | Effect.succeed( | |
| 2741 | call.kind === "graphql" && call.query.includes("pullRequests(") | |
| 2742 | ? emptyList() | |
| 2743 | : emptySearch(), | |
| 2744 | ), | |
| 2745 | ); | |
| 2746 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2747 | ||
| 2748 | yield* cli.listPullRequests({ | |
| 2749 | cwd: "/w", | |
| 2750 | repository: "acme/web", | |
| 2751 | host: "github.acme.dev", | |
| 2752 | state: "open", | |
| 2753 | involvement: "all", | |
| 2754 | viewer: "bilal", | |
| 2755 | limit: 10, | |
| 2756 | }); | |
| 2757 | ||
| 2758 | // A request sent to github.com would read a different repository of the same name. | |
| 2759 | assert.isAbove(mockedExecute.mock.calls.length, 0); | |
| 2760 | expect(new Set(mockedExecute.mock.calls.map(([call]) => call.host))).toEqual( | |
| 2761 | new Set(["github.acme.dev"]), | |
| 2762 | ); | |
| 2763 | expect(searchOfCall(0)).toContain("repo:acme/web"); | |
| 2764 | }), | |
| 2765 | ); | |
| 2766 | ||
| 2767 | it.effect("asks a GitHub Enterprise host for its own review threads", () => | |
| 2768 | Effect.gen(function* () { | |
| 2769 | mockedExecute.mockReturnValueOnce( | |
| 2770 | Effect.succeed( | |
| 2771 | output( | |
| 2772 | encodeJson({ | |
| 2773 | data: { | |
| 2774 | repository: { pullRequest: { reviewThreads: { totalCount: 0, nodes: [] } } }, | |
| 2775 | }, | |
| 2776 | }), | |
| 2777 | ), | |
| 2778 | ), | |
| 2779 | ); | |
| 2780 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2781 | ||
| 2782 | yield* cli.listReviewThreadComments({ | |
| 2783 | cwd: "/w", | |
| 2784 | repository: "acme/web", | |
| 2785 | host: "github.acme.dev", | |
| 2786 | number: 7, | |
| 2787 | }); | |
| 2788 | ||
| 2789 | expect(callAt(0).host).toBe("github.acme.dev"); | |
| 2790 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); | |
| 2791 | }), | |
| 2792 | ); | |
| 2793 | ||
| 2794 | it.effect("serves a diff GitHub hands over whole in one request, with no next slice", () => | |
| 2795 | Effect.gen(function* () { | |
| 2796 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("diff --git a/a b/a"))); | |
| 2797 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2798 | ||
| 2799 | const diff = yield* cli.getPullRequestDiff({ | |
| 2800 | cwd: "/w", | |
| 2801 | repository: "acme/web", | |
| 2802 | host: "github.com", | |
| 2803 | number: 7, | |
| 2804 | }); | |
| 2805 | ||
| 2806 | assert.strictEqual(diff.patch, "diff --git a/a b/a"); | |
| 2807 | assert.isNull(diff.nextCursor); | |
| 2808 | assert.isFalse(diff.truncated); | |
| 2809 | // The common case pays for one request and not the files API on top of it. | |
| 2810 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 2811 | // GitHub's combined pull-request diff: one section per changed file, not one per commit. | |
| 2812 | const call = callAt(0); | |
| 2813 | assert.strictEqual(call.kind, "rest"); | |
| 2814 | if (call.kind === "rest") { | |
| 2815 | assert.strictEqual(call.path, "repos/acme/web/pulls/7"); | |
| 2816 | assert.strictEqual(call.accept, "application/vnd.github.diff"); | |
| 2817 | } | |
| 2818 | }), | |
| 2819 | ); | |
| 2820 | ||
| 2821 | it.effect("reads one files page when GitHub refuses the diff, and says it is the last", () => | |
| 2822 | Effect.gen(function* () { | |
| 2823 | // GitHub answers 406 rather than a diff past 300 changed files. | |
| 2824 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 2825 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1)))); | |
| 2826 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2827 | ||
| 2828 | const diff = yield* cli.getPullRequestDiff({ | |
| 2829 | cwd: "/w", | |
| 2830 | repository: "acme/web", | |
| 2831 | host: "github.acme.dev", | |
| 2832 | number: 7, | |
| 2833 | }); | |
| 2834 | ||
| 2835 | assert.isFalse(diff.truncated); | |
| 2836 | // A short page is the end of the change set, so there is nothing to carry on from. | |
| 2837 | assert.isNull(diff.nextCursor); | |
| 2838 | expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); | |
| 2839 | expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts"); | |
| 2840 | assert.strictEqual(callAt(1).host, "github.acme.dev"); | |
| 2841 | assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); | |
| 2842 | }), | |
| 2843 | ); | |
| 2844 | ||
| 2845 | it.effect("hands back a cursor for the next page rather than walking on by itself", () => | |
| 2846 | Effect.gen(function* () { | |
| 2847 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 2848 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); | |
| 2849 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2850 | ||
| 2851 | const diff = yield* cli.getPullRequestDiff({ | |
| 2852 | cwd: "/w", | |
| 2853 | repository: "acme/web", | |
| 2854 | host: "github.com", | |
| 2855 | number: 7, | |
| 2856 | }); | |
| 2857 | ||
| 2858 | // A full page means more files, which the reader asks for; it is not a truncated slice. | |
| 2859 | assert.isFalse(diff.truncated); | |
| 2860 | assert.isNotNull(diff.nextCursor); | |
| 2861 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 2862 | }), | |
| 2863 | ); | |
| 2864 | ||
| 2865 | it.effect("carries on from a cursor without asking for the whole diff again", () => | |
| 2866 | Effect.gen(function* () { | |
| 2867 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 2868 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); | |
| 2869 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2870 | const target = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }; | |
| 2871 | ||
| 2872 | const first = yield* cli.getPullRequestDiff(target); | |
| 2873 | assert.isNotNull(first.nextCursor); | |
| 2874 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(4, 100)))); | |
| 2875 | const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); | |
| 2876 | ||
| 2877 | assert.isNull(second.nextCursor); | |
| 2878 | expect(second.patch).toContain("diff --git a/src/file100.ts b/src/file100.ts"); | |
| 2879 | // The second slice is one request: the cursor already says where to read. | |
| 2880 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | |
| 2881 | assert.strictEqual(pathAt(2), "repos/acme/web/pulls/7/files?per_page=100&page=2"); | |
| 2882 | }), | |
| 2883 | ); | |
| 2884 | ||
| 2885 | it.effect("refuses a cursor it never handed out rather than reading it into a request", () => | |
| 2886 | Effect.gen(function* () { | |
| 2887 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2888 | ||
| 2889 | const error = yield* Effect.flip( | |
| 2890 | cli.getPullRequestDiff({ | |
| 2891 | cwd: "/w", | |
| 2892 | repository: "acme/web", | |
| 2893 | host: "github.com", | |
| 2894 | number: 7, | |
| 2895 | cursor: "1&per_page=1", | |
| 2896 | }), | |
| 2897 | ); | |
| 2898 | ||
| 2899 | assert.strictEqual(error._tag, "GitHubDiffCursorError"); | |
| 2900 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 2901 | }), | |
| 2902 | ); | |
| 2903 | ||
| 2904 | it.effect("reads a named commit from the commit endpoint rather than the whole diff", () => | |
| 2905 | Effect.gen(function* () { | |
| 2906 | mockedExecute.mockReturnValueOnce( | |
| 2907 | Effect.succeed(output(`{"files":${pullRequestFiles(2, 1)}}`)), | |
| 2908 | ); | |
| 2909 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2910 | ||
| 2911 | const diff = yield* cli.getPullRequestDiff({ | |
| 2912 | cwd: "/w", | |
| 2913 | repository: "acme/web", | |
| 2914 | host: "github.com", | |
| 2915 | number: 7, | |
| 2916 | commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", | |
| 2917 | }); | |
| 2918 | ||
| 2919 | // One request: the commit's own changes never take the whole-diff road. | |
| 2920 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 2921 | assert.isNull(diff.nextCursor); | |
| 2922 | // The commit endpoint wraps its files in an object, which the decoder unwraps. | |
| 2923 | expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); | |
| 2924 | assert.strictEqual( | |
| 2925 | pathAt(0), | |
| 2926 | "repos/acme/web/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0?per_page=100&page=1", | |
| 2927 | ); | |
| 2928 | }), | |
| 2929 | ); | |
| 2930 | ||
| 2931 | it.effect("pages inside a commit the way it pages the pull request's own files", () => | |
| 2932 | Effect.gen(function* () { | |
| 2933 | mockedExecute.mockReturnValueOnce( | |
| 2934 | Effect.succeed(output(`{"files":${pullRequestFiles(100, 0)}}`)), | |
| 2935 | ); | |
| 2936 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2937 | const target = { | |
| 2938 | cwd: "/w", | |
| 2939 | repository: "acme/web", | |
| 2940 | host: "github.com", | |
| 2941 | number: 7, | |
| 2942 | commit: "a1b2c3d", | |
| 2943 | }; | |
| 2944 | ||
| 2945 | const first = yield* cli.getPullRequestDiff(target); | |
| 2946 | assert.isNotNull(first.nextCursor); | |
| 2947 | mockedExecute.mockReturnValueOnce( | |
| 2948 | Effect.succeed(output(`{"files":${pullRequestFiles(4, 100)}}`)), | |
| 2949 | ); | |
| 2950 | const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); | |
| 2951 | ||
| 2952 | assert.isNull(second.nextCursor); | |
| 2953 | expect(second.patch).toContain("src/file100.ts"); | |
| 2954 | assert.strictEqual(pathAt(1), "repos/acme/web/commits/a1b2c3d?per_page=100&page=2"); | |
| 2955 | }), | |
| 2956 | ); | |
| 2957 | ||
| 2958 | it.effect("refuses a commit that is not a sha rather than reading it into a request", () => | |
| 2959 | Effect.gen(function* () { | |
| 2960 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2961 | ||
| 2962 | const error = yield* Effect.flip( | |
| 2963 | cli.getPullRequestDiff({ | |
| 2964 | cwd: "/w", | |
| 2965 | repository: "acme/web", | |
| 2966 | host: "github.com", | |
| 2967 | number: 7, | |
| 2968 | commit: "../../pulls/8/files", | |
| 2969 | }), | |
| 2970 | ); | |
| 2971 | ||
| 2972 | assert.strictEqual(error._tag, "GitHubDiffCommitError"); | |
| 2973 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 2974 | }), | |
| 2975 | ); | |
| 2976 | ||
| 2977 | it.effect("expands a new file from a root commit without requiring a parent", () => | |
| 2978 | Effect.gen(function* () { | |
| 2979 | mockedExecute.mockReturnValueOnce( | |
| 2980 | Effect.succeed(output(encodeJson({ sha: "a1b2c3d", parents: [] }))), | |
| 2981 | ); | |
| 2982 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n"))); | |
| 2983 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 2984 | ||
| 2985 | const contents = yield* cli.getPullRequestDiffFileContents({ | |
| 2986 | cwd: "/w", | |
| 2987 | repository: "acme/web", | |
| 2988 | host: "github.com", | |
| 2989 | number: 7, | |
| 2990 | commit: "a1b2c3d", | |
| 2991 | changeType: "new", | |
| 2992 | oldPath: "src/root.ts", | |
| 2993 | newPath: "src/root.ts", | |
| 2994 | }); | |
| 2995 | ||
| 2996 | expect(contents).toEqual({ oldContents: "", newContents: "root contents\n" }); | |
| 2997 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 2998 | assert.strictEqual(pathAt(0), "repos/acme/web/commits/a1b2c3d"); | |
| 2999 | assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/root.ts?ref=a1b2c3d"); | |
| 3000 | }), | |
| 3001 | ); | |
| 3002 | ||
| 3003 | it.effect("reports unusable diff revisions as a structured error", () => | |
| 3004 | Effect.gen(function* () { | |
| 3005 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("not-a-sha\tstill-not-a-sha\n"))); | |
| 3006 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3007 | ||
| 3008 | const error = yield* Effect.flip( | |
| 3009 | cli.getPullRequestDiffFileContents({ | |
| 3010 | cwd: "/w", | |
| 3011 | repository: "acme/web", | |
| 3012 | host: "github.com", | |
| 3013 | number: 7, | |
| 3014 | commit: "a1b2c3d", | |
| 3015 | changeType: "change", | |
| 3016 | oldPath: "src/a.ts", | |
| 3017 | newPath: "src/a.ts", | |
| 3018 | }), | |
| 3019 | ); | |
| 3020 | ||
| 3021 | assert.strictEqual(error._tag, "GitHubDiffRevisionsUnavailableError"); | |
| 3022 | if (error._tag === "GitHubDiffRevisionsUnavailableError") { | |
| 3023 | assert.strictEqual(error.number, 7); | |
| 3024 | assert.strictEqual(error.commit, "a1b2c3d"); | |
| 3025 | } | |
| 3026 | }), | |
| 3027 | ); | |
| 3028 | ||
| 3029 | it.effect("reports an oversized diff file with its path and reason", () => | |
| 3030 | Effect.gen(function* () { | |
| 3031 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); | |
| 3032 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); | |
| 3033 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3034 | ||
| 3035 | const error = yield* Effect.flip( | |
| 3036 | cli.getPullRequestDiffFileContents({ | |
| 3037 | cwd: "/w", | |
| 3038 | repository: "acme/web", | |
| 3039 | host: "github.com", | |
| 3040 | number: 7, | |
| 3041 | changeType: "deleted", | |
| 3042 | oldPath: "src/large.ts", | |
| 3043 | newPath: "src/large.ts", | |
| 3044 | }), | |
| 3045 | ); | |
| 3046 | ||
| 3047 | assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError"); | |
| 3048 | if (error._tag === "GitHubDiffFileContentsUnavailableError") { | |
| 3049 | assert.strictEqual(error.path, "src/large.ts"); | |
| 3050 | assert.strictEqual(error.reason, "oversized"); | |
| 3051 | } | |
| 3052 | // A deleted file is read at the base revision only. | |
| 3053 | assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/large.ts?ref=a1b2c3d"); | |
| 3054 | }), | |
| 3055 | ); | |
| 3056 | ||
| 3057 | it.effect("reports undecodable diff file contents as binary", () => | |
| 3058 | Effect.gen(function* () { | |
| 3059 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); | |
| 3060 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("binary�contents", false, true))); | |
| 3061 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3062 | ||
| 3063 | const error = yield* Effect.flip( | |
| 3064 | cli.getPullRequestDiffFileContents({ | |
| 3065 | cwd: "/w", | |
| 3066 | repository: "acme/web", | |
| 3067 | host: "github.com", | |
| 3068 | number: 7, | |
| 3069 | changeType: "deleted", | |
| 3070 | oldPath: "assets/logo.png", | |
| 3071 | newPath: "assets/logo.png", | |
| 3072 | }), | |
| 3073 | ); | |
| 3074 | ||
| 3075 | assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError"); | |
| 3076 | if (error._tag === "GitHubDiffFileContentsUnavailableError") { | |
| 3077 | assert.strictEqual(error.path, "assets/logo.png"); | |
| 3078 | assert.strictEqual(error.reason, "binary"); | |
| 3079 | } | |
| 3080 | }), | |
| 3081 | ); | |
| 3082 | ||
| 3083 | it.effect("returns valid text containing a literal replacement character", () => | |
| 3084 | Effect.gen(function* () { | |
| 3085 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); | |
| 3086 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("before�after"))); | |
| 3087 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3088 | ||
| 3089 | const contents = yield* cli.getPullRequestDiffFileContents({ | |
| 3090 | cwd: "/w", | |
| 3091 | repository: "acme/web", | |
| 3092 | host: "github.com", | |
| 3093 | number: 7, | |
| 3094 | changeType: "deleted", | |
| 3095 | oldPath: "docs/encoding.md", | |
| 3096 | newPath: "docs/encoding.md", | |
| 3097 | }); | |
| 3098 | ||
| 3099 | assert.strictEqual(contents.oldContents, "before�after"); | |
| 3100 | }), | |
| 3101 | ); | |
| 3102 | ||
| 3103 | it.effect("ends the diff on a page with no files rather than asking for it again", () => | |
| 3104 | Effect.gen(function* () { | |
| 3105 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | |
| 3106 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3107 | ||
| 3108 | const diff = yield* cli.getPullRequestDiff({ | |
| 3109 | cwd: "/w", | |
| 3110 | repository: "acme/web", | |
| 3111 | host: "github.com", | |
| 3112 | number: 7, | |
| 3113 | cursor: "4", | |
| 3114 | }); | |
| 3115 | ||
| 3116 | assert.strictEqual(diff.patch, ""); | |
| 3117 | assert.isNull(diff.nextCursor); | |
| 3118 | }), | |
| 3119 | ); | |
| 3120 | ||
| 3121 | it.effect("reports the refused diff when the files API cannot answer either", () => | |
| 3122 | Effect.gen(function* () { | |
| 3123 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 3124 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("not json"))); | |
| 3125 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3126 | ||
| 3127 | const error = yield* Effect.flip( | |
| 3128 | cli.getPullRequestDiff({ | |
| 3129 | cwd: "/w", | |
| 3130 | repository: "acme/web", | |
| 3131 | host: "github.com", | |
| 3132 | number: 7, | |
| 3133 | }), | |
| 3134 | ); | |
| 3135 | ||
| 3136 | assert.strictEqual(error, diffRefused); | |
| 3137 | }), | |
| 3138 | ); | |
| 3139 | ||
| 3140 | it.effect("skips the avatar lookup when a listing named nobody", () => | |
| 3141 | Effect.gen(function* () { | |
| 3142 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3143 | ||
| 3144 | const avatars = yield* cli.listActorAvatars({ | |
| 3145 | cwd: "/w", | |
| 3146 | repository: "acme/web", | |
| 3147 | host: "github.com", | |
| 3148 | ids: [], | |
| 3149 | }); | |
| 3150 | ||
| 3151 | assert.strictEqual(avatars.size, 0); | |
| 3152 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 3153 | }), | |
| 3154 | ); | |
| 3155 | ||
| 3156 | it.effect("accounts for the avatar lookup in the GraphQL budget", () => | |
| 3157 | Effect.gen(function* () { | |
| 3158 | mockedExecute.mockReturnValueOnce( | |
| 3159 | Effect.succeed( | |
| 3160 | output( | |
| 3161 | encodeJson({ | |
| 3162 | data: { | |
| 3163 | nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }], | |
| 3164 | rateLimit: { | |
| 3165 | cost: 1, | |
| 3166 | limit: 5_000, | |
| 3167 | remaining: 4_999, | |
| 3168 | resetAt: "2099-08-13T14:00:00Z", | |
| 3169 | }, | |
| 3170 | }, | |
| 3171 | }), | |
| 3172 | ), | |
| 3173 | ), | |
| 3174 | ); | |
| 3175 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3176 | ||
| 3177 | const avatars = yield* cli.listActorAvatars({ | |
| 3178 | cwd: "/w", | |
| 3179 | repository: "acme/web", | |
| 3180 | host: "github.com", | |
| 3181 | ids: ["MDQ6VXNlcjE="], | |
| 3182 | }); | |
| 3183 | ||
| 3184 | expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] }); | |
| 3185 | expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); | |
| 3186 | expect(avatars.get("octocat")).toBe("https://avatars/octocat"); | |
| 3187 | }), | |
| 3188 | ); | |
| 3189 | ||
| 3190 | it.effect("fails when the authenticated account has no login", () => | |
| 3191 | Effect.gen(function* () { | |
| 3192 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(" "))); | |
| 3193 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3194 | ||
| 3195 | const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w", host: "github.com" })); | |
| 3196 | ||
| 3197 | assert.strictEqual(error._tag, "GitHubViewerLoginUnavailableError"); | |
| 3198 | }), | |
| 3199 | ); | |
| 3200 | ||
| 3201 | it.effect("looks up the authenticated account on the requested enterprise host", () => | |
| 3202 | Effect.gen(function* () { | |
| 3203 | mockedExecute.mockReturnValueOnce( | |
| 3204 | Effect.succeed(output('{"id":456,"login":"enterprise-user"}')), | |
| 3205 | ); | |
| 3206 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3207 | ||
| 3208 | const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" }); | |
| 3209 | ||
| 3210 | expect(login).toBe("enterprise-user"); | |
| 3211 | expect(mockedCredential).toHaveBeenCalledWith("github.acme.com"); | |
| 3212 | expect(callAt(0)).toMatchObject({ kind: "rest", host: "github.acme.com", path: "user" }); | |
| 3213 | }), | |
| 3214 | ); | |
| 3215 | ||
| 3216 | it.effect("reuses verified credentials offline and refuses an unverified replacement", () => | |
| 3217 | Effect.gen(function* () { | |
| 3218 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3219 | const input = { cwd: "/w", host: "github.identity-cache.test" }; | |
| 3220 | mockedCredential.mockImplementation((host) => | |
| 3221 | Effect.succeed({ token: Redacted.make("test-credential-a"), fingerprint: `${host}:a` }), | |
| 3222 | ); | |
| 3223 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}'))); | |
| 3224 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | |
| 3225 | accountId: "123", | |
| 3226 | viewer: "maria-rcks", | |
| 3227 | }); | |
| 3228 | ||
| 3229 | // The same credential again is answered from what was verified, without asking GitHub. | |
| 3230 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | |
| 3231 | accountId: "123", | |
| 3232 | viewer: "maria-rcks", | |
| 3233 | }); | |
| 3234 | expect(mockedExecute).toHaveBeenCalledTimes(1); | |
| 3235 | ||
| 3236 | // A switched credential is not trusted on the strength of the old one's answer. | |
| 3237 | mockedCredential.mockImplementation((host) => | |
| 3238 | Effect.succeed({ token: Redacted.make("test-credential-b"), fingerprint: `${host}:b` }), | |
| 3239 | ); | |
| 3240 | mockedExecute.mockReturnValueOnce( | |
| 3241 | Effect.fail( | |
| 3242 | new GitHubApi.GitHubApiResponseError({ | |
| 3243 | host: "github.com", | |
| 3244 | operation: "getRoutingIdentity", | |
| 3245 | status: 502, | |
| 3246 | }), | |
| 3247 | ), | |
| 3248 | ); | |
| 3249 | // GitHub's own refusal is reported as itself, so the page can say what went wrong. | |
| 3250 | const failure = yield* cli.getRoutingIdentity(input).pipe(Effect.flip); | |
| 3251 | expect(failure._tag).toBe("GitHubApiResponseError"); | |
| 3252 | expect(String(failure)).not.toContain("test-credential-b"); | |
| 3253 | expect(mockedExecute).toHaveBeenCalledTimes(2); | |
| 3254 | ||
| 3255 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}'))); | |
| 3256 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ | |
| 3257 | accountId: "456", | |
| 3258 | viewer: "maria-rcks", | |
| 3259 | }); | |
| 3260 | }), | |
| 3261 | ); | |
| 3262 | ||
| 3263 | it.effect("sends a whole review as one request", () => | |
| 3264 | Effect.gen(function* () { | |
| 3265 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 3266 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3267 | ||
| 3268 | yield* cli.submitReview({ | |
| 3269 | cwd: "/w", | |
| 3270 | repository: "acme/web", | |
| 3271 | host: "github.com", | |
| 3272 | number: 7, | |
| 3273 | verdict: "approve", | |
| 3274 | body: "Looks right.", | |
| 3275 | comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], | |
| 3276 | }); | |
| 3277 | ||
| 3278 | // One request, so nothing is on the pull request until the verdict is. | |
| 3279 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 3280 | expect(restCallsTo("/reviews")).toMatchObject([ | |
| 3281 | { | |
| 3282 | method: "POST", | |
| 3283 | path: "repos/acme/web/pulls/7/reviews", | |
| 3284 | body: { | |
| 3285 | event: "APPROVE", | |
| 3286 | body: "Looks right.", | |
| 3287 | comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }], | |
| 3288 | }, | |
| 3289 | }, | |
| 3290 | ]); | |
| 3291 | }), | |
| 3292 | ); | |
| 3293 | ||
| 3294 | it.effect("sends a reply body as a variable, never inside the document", () => | |
| 3295 | Effect.gen(function* () { | |
| 3296 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 3297 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3298 | ||
| 3299 | yield* cli.replyToReviewThread({ | |
| 3300 | cwd: "/w", | |
| 3301 | repository: "acme/web", | |
| 3302 | host: "github.com", | |
| 3303 | threadId: "PRRT_1", | |
| 3304 | body: "Fixed in 42ff8ec.", | |
| 3305 | }); | |
| 3306 | ||
| 3307 | expect(variablesOf("addPullRequestReviewThreadReply(")).toEqual([ | |
| 3308 | { threadId: "PRRT_1", body: "Fixed in 42ff8ec." }, | |
| 3309 | ]); | |
| 3310 | expect(queryAt(0)).not.toContain("Fixed in 42ff8ec."); | |
| 3311 | }), | |
| 3312 | ); | |
| 3313 | ||
| 3314 | it.effect("resolves and unresolves through the mutation each one needs", () => | |
| 3315 | Effect.gen(function* () { | |
| 3316 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 3317 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3318 | ||
| 3319 | yield* cli.setReviewThreadResolution({ | |
| 3320 | cwd: "/w", | |
| 3321 | repository: "acme/web", | |
| 3322 | host: "github.acme.dev", | |
| 3323 | threadId: "PRRT_1", | |
| 3324 | resolved: true, | |
| 3325 | }); | |
| 3326 | yield* cli.setReviewThreadResolution({ | |
| 3327 | cwd: "/w", | |
| 3328 | repository: "acme/web", | |
| 3329 | host: "github.acme.dev", | |
| 3330 | threadId: "PRRT_1", | |
| 3331 | resolved: false, | |
| 3332 | }); | |
| 3333 | ||
| 3334 | expect(queryAt(0)).toContain("resolveReviewThread("); | |
| 3335 | expect(queryAt(0)).not.toContain("unresolveReviewThread("); | |
| 3336 | expect(queryAt(1)).toContain("unresolveReviewThread("); | |
| 3337 | expect([varsAt(0), varsAt(1)]).toEqual([{ threadId: "PRRT_1" }, { threadId: "PRRT_1" }]); | |
| 3338 | // A GitHub Enterprise thread is resolved on its own host, not on github.com. | |
| 3339 | expect([callAt(0).host, callAt(1).host]).toEqual(["github.acme.dev", "github.acme.dev"]); | |
| 3340 | }), | |
| 3341 | ); | |
| 3342 | ||
| 3343 | it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () => | |
| 3344 | Effect.gen(function* () { | |
| 3345 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); | |
| 3346 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3347 | ||
| 3348 | yield* cli.setReaction({ | |
| 3349 | cwd: "/w", | |
| 3350 | repository: "acme/web", | |
| 3351 | host: "github.com", | |
| 3352 | number: 7, | |
| 3353 | subjectId: "IC_1", | |
| 3354 | content: "heart", | |
| 3355 | reacted: true, | |
| 3356 | }); | |
| 3357 | ||
| 3358 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3359 | expect(variablesOf(SUBJECT_SCOPE_QUERY)).toEqual([ | |
| 3360 | { owner: "acme", name: "web", number: 7, subjectId: "IC_1" }, | |
| 3361 | ]); | |
| 3362 | expect(variablesOf("addReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); | |
| 3363 | }), | |
| 3364 | ); | |
| 3365 | ||
| 3366 | it.effect("refuses a given subject that belongs to a different pull request", () => | |
| 3367 | Effect.gen(function* () { | |
| 3368 | mockedExecute.mockReturnValueOnce( | |
| 3369 | Effect.succeed( | |
| 3370 | output( | |
| 3371 | encodeJson({ | |
| 3372 | data: { | |
| 3373 | repository: { pullRequest: { id: "PR_thisOne" } }, | |
| 3374 | // A comment on pull request #99 of a different repository, named as though it | |
| 3375 | // belonged to #7 here. | |
| 3376 | node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, | |
| 3377 | }, | |
| 3378 | }), | |
| 3379 | ), | |
| 3380 | ), | |
| 3381 | ); | |
| 3382 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3383 | ||
| 3384 | const error = yield* Effect.flip( | |
| 3385 | cli.setReaction({ | |
| 3386 | cwd: "/w", | |
| 3387 | repository: "acme/web", | |
| 3388 | host: "github.com", | |
| 3389 | number: 7, | |
| 3390 | subjectId: "IC_99", | |
| 3391 | content: "heart", | |
| 3392 | reacted: true, | |
| 3393 | }), | |
| 3394 | ); | |
| 3395 | ||
| 3396 | assert.strictEqual(error._tag, "GitHubSubjectScopeError"); | |
| 3397 | // Refused before any mutation was sent. | |
| 3398 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 3399 | }), | |
| 3400 | ); | |
| 3401 | ||
| 3402 | it.effect("looks up the pull request's own node id when no subject was given", () => | |
| 3403 | Effect.gen(function* () { | |
| 3404 | route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); | |
| 3405 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3406 | ||
| 3407 | // Its own pull request: a node id looked up once is remembered for the life of the service. | |
| 3408 | yield* cli.setReaction({ | |
| 3409 | cwd: "/w", | |
| 3410 | repository: "acme/web", | |
| 3411 | host: "github.com", | |
| 3412 | number: 21, | |
| 3413 | content: "rocket", | |
| 3414 | reacted: true, | |
| 3415 | }); | |
| 3416 | ||
| 3417 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3418 | expect(variablesOf(NODE_ID_QUERY)).toEqual([{ owner: "acme", name: "web", number: 21 }]); | |
| 3419 | expect(variablesOf("addReaction(")).toEqual([{ subjectId: "PR_kwDOA", content: "ROCKET" }]); | |
| 3420 | }), | |
| 3421 | ); | |
| 3422 | ||
| 3423 | it.effect("takes a reaction back through the remove mutation", () => | |
| 3424 | Effect.gen(function* () { | |
| 3425 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); | |
| 3426 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3427 | ||
| 3428 | yield* cli.setReaction({ | |
| 3429 | cwd: "/w", | |
| 3430 | repository: "acme/web", | |
| 3431 | host: "github.com", | |
| 3432 | number: 7, | |
| 3433 | subjectId: "IC_1", | |
| 3434 | content: "heart", | |
| 3435 | reacted: false, | |
| 3436 | }); | |
| 3437 | ||
| 3438 | expect(variablesOf("removeReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); | |
| 3439 | expect(variablesOf("addReaction(")).toEqual([]); | |
| 3440 | }), | |
| 3441 | ); | |
| 3442 | ||
| 3443 | it.effect("rewrites only the words a request named", () => | |
| 3444 | Effect.gen(function* () { | |
| 3445 | route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); | |
| 3446 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3447 | const rewrite = (fields: { readonly title?: string; readonly body?: string }) => | |
| 3448 | cli.updatePullRequest({ | |
| 3449 | cwd: "/w", | |
| 3450 | repository: "acme/web", | |
| 3451 | host: "github.com", | |
| 3452 | number: 22, | |
| 3453 | ...fields, | |
| 3454 | }); | |
| 3455 | ||
| 3456 | yield* rewrite({ title: "A better title" }); | |
| 3457 | yield* rewrite({ body: "A better description." }); | |
| 3458 | yield* rewrite({ title: "Both", body: "at once." }); | |
| 3459 | ||
| 3460 | // One node id lookup for the pull request, then a mutation per rewrite. | |
| 3461 | assert.strictEqual(variablesOf(NODE_ID_QUERY).length, 1); | |
| 3462 | expect(variablesOf("updatePullRequest(")).toEqual([ | |
| 3463 | { pullRequestId: "PR_kwDOA", title: "A better title" }, | |
| 3464 | { pullRequestId: "PR_kwDOA", body: "A better description." }, | |
| 3465 | { pullRequestId: "PR_kwDOA", title: "Both", body: "at once." }, | |
| 3466 | ]); | |
| 3467 | }), | |
| 3468 | ); | |
| 3469 | ||
| 3470 | it.effect("rewrites a remark through the mutation its kind needs", () => | |
| 3471 | Effect.gen(function* () { | |
| 3472 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); | |
| 3473 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3474 | const rewrite = (kind: "issue-comment" | "review-comment") => | |
| 3475 | cli.updateComment({ | |
| 3476 | cwd: "/w", | |
| 3477 | repository: "acme/web", | |
| 3478 | host: "github.com", | |
| 3479 | number: 7, | |
| 3480 | commentId: "IC_1", | |
| 3481 | kind, | |
| 3482 | body: "Reworded.", | |
| 3483 | }); | |
| 3484 | ||
| 3485 | yield* rewrite("issue-comment"); | |
| 3486 | yield* rewrite("review-comment"); | |
| 3487 | ||
| 3488 | expect(variablesOf(SUBJECT_SCOPE_QUERY).map((variables) => variables["subjectId"])).toEqual([ | |
| 3489 | "IC_1", | |
| 3490 | "IC_1", | |
| 3491 | ]); | |
| 3492 | expect(variablesOf("updateIssueComment(")).toEqual([ | |
| 3493 | { commentId: "IC_1", body: "Reworded." }, | |
| 3494 | ]); | |
| 3495 | expect(variablesOf("updatePullRequestReviewComment(")).toEqual([ | |
| 3496 | { commentId: "IC_1", body: "Reworded." }, | |
| 3497 | ]); | |
| 3498 | }), | |
| 3499 | ); | |
| 3500 | ||
| 3501 | it.effect("refuses a comment that belongs to a different pull request", () => | |
| 3502 | Effect.gen(function* () { | |
| 3503 | mockedExecute.mockReturnValueOnce( | |
| 3504 | Effect.succeed( | |
| 3505 | output( | |
| 3506 | encodeJson({ | |
| 3507 | data: { | |
| 3508 | repository: { pullRequest: { id: "PR_thisOne" } }, | |
| 3509 | node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, | |
| 3510 | }, | |
| 3511 | }), | |
| 3512 | ), | |
| 3513 | ), | |
| 3514 | ); | |
| 3515 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3516 | ||
| 3517 | const error = yield* Effect.flip( | |
| 3518 | cli.updateComment({ | |
| 3519 | cwd: "/w", | |
| 3520 | repository: "acme/web", | |
| 3521 | host: "github.com", | |
| 3522 | number: 7, | |
| 3523 | commentId: "IC_99", | |
| 3524 | kind: "issue-comment", | |
| 3525 | body: "Reworded.", | |
| 3526 | }), | |
| 3527 | ); | |
| 3528 | ||
| 3529 | assert.strictEqual(error._tag, "GitHubSubjectScopeError"); | |
| 3530 | if (error._tag === "GitHubSubjectScopeError") | |
| 3531 | assert.strictEqual(error.operation, "updateComment"); | |
| 3532 | // Refused before any mutation was sent. | |
| 3533 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 3534 | }), | |
| 3535 | ); | |
| 3536 | ||
| 3537 | it.effect("fails the read when gh returns something unreadable", () => | |
| 3538 | Effect.gen(function* () { | |
| 3539 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); | |
| 3540 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3541 | ||
| 3542 | const error = yield* Effect.flip( | |
| 3543 | cli.getPullRequestDetail({ | |
| 3544 | cwd: "/w", | |
| 3545 | repository: "acme/web", | |
| 3546 | host: "github.com", | |
| 3547 | number: 7, | |
| 3548 | }), | |
| 3549 | ); | |
| 3550 | ||
| 3551 | assert.strictEqual(error._tag, "GitHubPullRequestReadError"); | |
| 3552 | }), | |
| 3553 | ); | |
| 3554 | ||
| 3555 | it.effect("keeps the core detail read separate from conversation activity", () => | |
| 3556 | Effect.gen(function* () { | |
| 3557 | mockedExecute.mockReturnValueOnce( | |
| 3558 | Effect.succeed( | |
| 3559 | output( | |
| 3560 | encodeJson( | |
| 3561 | coreResponse({ | |
| 3562 | title: "Progressive detail", | |
| 3563 | author: { login: "octocat" }, | |
| 3564 | body: "Core body", | |
| 3565 | changedFiles: 2, | |
| 3566 | }), | |
| 3567 | ), | |
| 3568 | ), | |
| 3569 | ), | |
| 3570 | ); | |
| 3571 | mockedExecute.mockReturnValueOnce( | |
| 3572 | Effect.succeed( | |
| 3573 | output( | |
| 3574 | encodeJson({ | |
| 3575 | data: { | |
| 3576 | repository: { | |
| 3577 | pullRequest: { | |
| 3578 | author: { __typename: "User", login: "octocat", avatarUrl: "https://a/o" }, | |
| 3579 | commits: { nodes: [] }, | |
| 3580 | comments: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, | |
| 3581 | reviews: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, | |
| 3582 | }, | |
| 3583 | }, | |
| 3584 | }, | |
| 3585 | }), | |
| 3586 | ), | |
| 3587 | ), | |
| 3588 | ); | |
| 3589 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3590 | const input = { | |
| 3591 | cwd: "/w", | |
| 3592 | repository: "acme/web", | |
| 3593 | host: "github.com", | |
| 3594 | number: 7, | |
| 3595 | } as const; | |
| 3596 | ||
| 3597 | const detail = yield* cli.getPullRequestDetail(input); | |
| 3598 | const activity = yield* cli.getPullRequestActivity(input); | |
| 3599 | ||
| 3600 | expect(detail.body).toBe("Core body"); | |
| 3601 | expect(activity.author?.login).toBe("octocat"); | |
| 3602 | expect(varsAt(0)).toMatchObject({ number: 7, headRef: "refs/pull/7/head" }); | |
| 3603 | expect(queryAt(0)).toContain("viewerCanUpdateBranch"); | |
| 3604 | expect(queryAt(0)).not.toContain("reviews("); | |
| 3605 | expect(detail.viewerAccess.mergeCapabilities).toEqual({ | |
| 3606 | merge: true, | |
| 3607 | squash: false, | |
| 3608 | rebase: true, | |
| 3609 | }); | |
| 3610 | expect(detail.comparison).toEqual({ behindBy: 2, viewerCanUpdate: true }); | |
| 3611 | // Conversation activity is its own read, and asks for the head of the conversation once. | |
| 3612 | expect(queryAt(1)).toContain("reviews("); | |
| 3613 | expect(varsAt(1)).toMatchObject({ head: true, withComments: true, withReviews: true }); | |
| 3614 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3615 | }), | |
| 3616 | ); | |
| 3617 | ||
| 3618 | it.effect("decodes reviewers, labels and workflow checks without another detail read", () => | |
| 3619 | Effect.gen(function* () { | |
| 3620 | mockedExecute.mockReturnValueOnce( | |
| 3621 | Effect.succeed( | |
| 3622 | output( | |
| 3623 | encodeJson( | |
| 3624 | coreResponse({ | |
| 3625 | baseRef: null, | |
| 3626 | reviewRequests: { | |
| 3627 | nodes: [ | |
| 3628 | { requestedReviewer: { login: "reviewer" } }, | |
| 3629 | { requestedReviewer: { slug: "maintainers", name: "Maintainers" } }, | |
| 3630 | ], | |
| 3631 | }, | |
| 3632 | labels: { nodes: [{ name: "bug", color: "ff0000" }] }, | |
| 3633 | commits: { | |
| 3634 | nodes: [ | |
| 3635 | { | |
| 3636 | commit: { | |
| 3637 | statusCheckRollup: { | |
| 3638 | contexts: { | |
| 3639 | nodes: [ | |
| 3640 | { | |
| 3641 | __typename: "CheckRun", | |
| 3642 | name: "build", | |
| 3643 | status: "COMPLETED", | |
| 3644 | conclusion: "SUCCESS", | |
| 3645 | checkSuite: { workflowRun: { workflow: { name: "linux" } } }, | |
| 3646 | }, | |
| 3647 | { | |
| 3648 | __typename: "CheckRun", | |
| 3649 | name: "build", | |
| 3650 | status: "COMPLETED", | |
| 3651 | conclusion: "FAILURE", | |
| 3652 | checkSuite: { workflowRun: { workflow: { name: "windows" } } }, | |
| 3653 | }, | |
| 3654 | ], | |
| 3655 | pageInfo: { hasNextPage: false }, | |
| 3656 | }, | |
| 3657 | }, | |
| 3658 | }, | |
| 3659 | }, | |
| 3660 | ], | |
| 3661 | }, | |
| 3662 | }), | |
| 3663 | ), | |
| 3664 | ), | |
| 3665 | ), | |
| 3666 | ); | |
| 3667 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3668 | const detail = yield* cli.getPullRequestDetail({ | |
| 3669 | cwd: "/w", | |
| 3670 | repository: "acme/web", | |
| 3671 | host: "github.com", | |
| 3672 | number: 7, | |
| 3673 | }); | |
| 3674 | expect(mockedExecute).toHaveBeenCalledOnce(); | |
| 3675 | expect(detail.comparison).toBeNull(); | |
| 3676 | expect(detail.reviewRequestLogins).toEqual(["reviewer"]); | |
| 3677 | expect(detail.hasTeamReviewRequest).toBe(true); | |
| 3678 | expect(detail.labels).toEqual([{ name: "bug", color: "ff0000" }]); | |
| 3679 | expect(detail.checks).toHaveLength(2); | |
| 3680 | expect(detail.checksState).toBe("failing"); | |
| 3681 | }), | |
| 3682 | ); | |
| 3683 | ||
| 3684 | it.effect("reads every check when the combined response has another page", () => | |
| 3685 | Effect.gen(function* () { | |
| 3686 | mockedExecute.mockReturnValueOnce( | |
| 3687 | Effect.succeed( | |
| 3688 | output( | |
| 3689 | encodeJson( | |
| 3690 | coreResponse({ | |
| 3691 | commits: { | |
| 3692 | nodes: [ | |
| 3693 | { | |
| 3694 | commit: { | |
| 3695 | statusCheckRollup: { | |
| 3696 | contexts: { | |
| 3697 | nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }], | |
| 3698 | pageInfo: { hasNextPage: true, endCursor: "c1" }, | |
| 3699 | }, | |
| 3700 | }, | |
| 3701 | }, | |
| 3702 | }, | |
| 3703 | ], | |
| 3704 | }, | |
| 3705 | }), | |
| 3706 | ), | |
| 3707 | ), | |
| 3708 | ), | |
| 3709 | ); | |
| 3710 | mockedExecute.mockReturnValueOnce( | |
| 3711 | Effect.succeed( | |
| 3712 | output( | |
| 3713 | checkContextsPage( | |
| 3714 | [ | |
| 3715 | { name: "first", status: "COMPLETED", conclusion: "SUCCESS" }, | |
| 3716 | { name: "last", status: "COMPLETED", conclusion: "FAILURE" }, | |
| 3717 | ], | |
| 3718 | null, | |
| 3719 | ), | |
| 3720 | ), | |
| 3721 | ), | |
| 3722 | ); | |
| 3723 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3724 | const detail = yield* cli.getPullRequestDetail({ | |
| 3725 | cwd: "/w", | |
| 3726 | repository: "acme/web", | |
| 3727 | host: "github.com", | |
| 3728 | number: 7, | |
| 3729 | }); | |
| 3730 | expect(detail.checks).toHaveLength(2); | |
| 3731 | expect(detail.checksState).toBe("failing"); | |
| 3732 | expect(detail.checksTruncated).toBe(false); | |
| 3733 | // The whole rollup is walked from its start, so no check is counted twice or skipped. | |
| 3734 | expect(varsAt(1)).toMatchObject({ number: 7, after: null }); | |
| 3735 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3736 | }), | |
| 3737 | ); | |
| 3738 | ||
| 3739 | it.effect("refuses to combine checks from different head revisions", () => | |
| 3740 | Effect.gen(function* () { | |
| 3741 | const response = coreResponse({ | |
| 3742 | commits: { | |
| 3743 | nodes: [ | |
| 3744 | { | |
| 3745 | commit: { | |
| 3746 | statusCheckRollup: { | |
| 3747 | contexts: { | |
| 3748 | nodes: [], | |
| 3749 | pageInfo: { hasNextPage: true }, | |
| 3750 | }, | |
| 3751 | }, | |
| 3752 | }, | |
| 3753 | }, | |
| 3754 | ], | |
| 3755 | }, | |
| 3756 | }); | |
| 3757 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response)))); | |
| 3758 | mockedExecute.mockReturnValueOnce( | |
| 3759 | Effect.succeed( | |
| 3760 | output( | |
| 3761 | encodeJson({ | |
| 3762 | ...response.data.repository.pullRequest, | |
| 3763 | headRefOid: "new-head", | |
| 3764 | reviewRequests: [], | |
| 3765 | labels: [], | |
| 3766 | statusCheckRollup: [], | |
| 3767 | }), | |
| 3768 | ), | |
| 3769 | ), | |
| 3770 | ); | |
| 3771 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3772 | const error = yield* Effect.flip( | |
| 3773 | cli.getPullRequestDetail({ | |
| 3774 | cwd: "/w", | |
| 3775 | repository: "acme/web", | |
| 3776 | host: "github.com", | |
| 3777 | number: 7, | |
| 3778 | }), | |
| 3779 | ); | |
| 3780 | expect(error._tag).toBe("GitHubPullRequestReadError"); | |
| 3781 | }), | |
| 3782 | ); | |
| 3783 | ||
| 3784 | it.effect("preserves the reserve for automatic detail reads and allows manual checks", () => | |
| 3785 | Effect.gen(function* () { | |
| 3786 | const response = coreResponse(); | |
| 3787 | mockedExecute.mockReturnValue( | |
| 3788 | Effect.succeed( | |
| 3789 | output( | |
| 3790 | encodeJson({ | |
| 3791 | ...response, | |
| 3792 | data: { | |
| 3793 | ...response.data, | |
| 3794 | rateLimit: { | |
| 3795 | cost: 1, | |
| 3796 | limit: 5000, | |
| 3797 | remaining: 500, | |
| 3798 | resetAt: "2099-08-13T14:00:00Z", | |
| 3799 | }, | |
| 3800 | }, | |
| 3801 | }), | |
| 3802 | ), | |
| 3803 | ), | |
| 3804 | ); | |
| 3805 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3806 | const input = { | |
| 3807 | cwd: "/w", | |
| 3808 | repository: "acme/web", | |
| 3809 | host: "github.core-reserve.test", | |
| 3810 | number: 7, | |
| 3811 | }; | |
| 3812 | yield* cli.getPullRequestDetail(input); | |
| 3813 | const error = yield* Effect.flip(cli.getPullRequestDetail(input)); | |
| 3814 | expect(error._tag).toBe("SourceControlRateLimitPausedError"); | |
| 3815 | expect(mockedExecute).toHaveBeenCalledOnce(); | |
| 3816 | yield* cli.getPullRequestDetail(input).pipe(Effect.provideService(AllowGitHubReserve, true)); | |
| 3817 | expect(mockedExecute).toHaveBeenCalledTimes(2); | |
| 3818 | }), | |
| 3819 | ); | |
| 3820 | ||
| 3821 | it.effect("fails a files page too large to read rather than calling the diff whole", () => | |
| 3822 | Effect.gen(function* () { | |
| 3823 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | |
| 3824 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1), true))); | |
| 3825 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3826 | ||
| 3827 | const error = yield* Effect.flip( | |
| 3828 | cli.getPullRequestDiff({ | |
| 3829 | cwd: "/w", | |
| 3830 | repository: "acme/web", | |
| 3831 | host: "github.com", | |
| 3832 | number: 7, | |
| 3833 | }), | |
| 3834 | ); | |
| 3835 | ||
| 3836 | // What matters is that it fails at all: an empty patch with no cursor would render as a | |
| 3837 | // change with no files and report the rest of it as already read. The refusal that sent | |
| 3838 | // the read down this road is the one reported, by design. | |
| 3839 | assert.strictEqual(error, diffRefused); | |
| 3840 | }), | |
| 3841 | ); | |
| 3842 | ||
| 3843 | it.effect("pages an oversized patch by file rather than handing back a severed one", () => | |
| 3844 | Effect.gen(function* () { | |
| 3845 | // The whole diff came back, but cut at a byte, which lands mid-file. | |
| 3846 | mockedExecute.mockReturnValueOnce( | |
| 3847 | Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)), | |
| 3848 | ); | |
| 3849 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1)))); | |
| 3850 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3851 | ||
| 3852 | const slice = yield* cli.getPullRequestDiff({ | |
| 3853 | cwd: "/w", | |
| 3854 | repository: "acme/web", | |
| 3855 | host: "github.com", | |
| 3856 | number: 7, | |
| 3857 | }); | |
| 3858 | ||
| 3859 | // The severed patch is thrown away; what comes back is assembled from whole files. | |
| 3860 | assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); | |
| 3861 | expect(slice.patch).toContain("src/file1.ts"); | |
| 3862 | expect(slice.patch).not.toContain("diff --git a/a b/a"); | |
| 3863 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 3864 | }), | |
| 3865 | ); | |
| 3866 | ||
| 3867 | it.effect("follows the cursor to the review threads the first page left behind", () => | |
| 3868 | Effect.gen(function* () { | |
| 3869 | mockedExecute.mockReturnValueOnce( | |
| 3870 | Effect.succeed(output(reviewThreadsPage([thread("PRRT_1", "c1")], "Y3Vyc29yOjE"))), | |
| 3871 | ); | |
| 3872 | mockedExecute.mockReturnValueOnce( | |
| 3873 | Effect.succeed(output(reviewThreadsPage([thread("PRRT_2", "c2")], null))), | |
| 3874 | ); | |
| 3875 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3876 | ||
| 3877 | const conversation = yield* cli.listReviewThreadComments({ | |
| 3878 | cwd: "/w", | |
| 3879 | repository: "acme/web", | |
| 3880 | host: "github.com", | |
| 3881 | number: 7, | |
| 3882 | }); | |
| 3883 | ||
| 3884 | // The first page asks from the beginning; the next carries on from where it stopped. | |
| 3885 | expect(varsAt(0)["cursor"]).toBeNull(); | |
| 3886 | expect(varsAt(1)["cursor"]).toBe("Y3Vyc29yOjE"); | |
| 3887 | expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2"]); | |
| 3888 | assert.isFalse(conversation.truncated); | |
| 3889 | }), | |
| 3890 | ); | |
| 3891 | ||
| 3892 | it.effect("stops at the thread bound and says the conversation was cut short", () => | |
| 3893 | Effect.gen(function* () { | |
| 3894 | // A host that never runs out of pages: the walk has to end itself. | |
| 3895 | mockedExecute.mockReturnValue( | |
| 3896 | Effect.succeed( | |
| 3897 | output( | |
| 3898 | reviewThreadsPage( | |
| 3899 | [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }], | |
| 3900 | "Y3Vyc29yOjE", | |
| 3901 | ), | |
| 3902 | ), | |
| 3903 | ), | |
| 3904 | ); | |
| 3905 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3906 | ||
| 3907 | const conversation = yield* cli.listReviewThreadComments({ | |
| 3908 | cwd: "/w", | |
| 3909 | repository: "acme/web", | |
| 3910 | host: "github.com", | |
| 3911 | number: 7, | |
| 3912 | }); | |
| 3913 | ||
| 3914 | assert.strictEqual(mockedExecute.mock.calls.length, 10); | |
| 3915 | assert.isTrue(conversation.truncated); | |
| 3916 | assert.isTrue(conversation.reviewThreadsTruncated); | |
| 3917 | }), | |
| 3918 | ); | |
| 3919 | ||
| 3920 | it.effect("leaves a long thread paged until the reader asks for more", () => | |
| 3921 | Effect.gen(function* () { | |
| 3922 | mockedExecute.mockReturnValueOnce( | |
| 3923 | Effect.succeed( | |
| 3924 | output( | |
| 3925 | reviewThreadsPage( | |
| 3926 | [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }], | |
| 3927 | null, | |
| 3928 | ), | |
| 3929 | ), | |
| 3930 | ), | |
| 3931 | ); | |
| 3932 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3933 | ||
| 3934 | const conversation = yield* cli.listReviewThreadComments({ | |
| 3935 | cwd: "/w", | |
| 3936 | repository: "acme/web", | |
| 3937 | host: "github.com", | |
| 3938 | number: 7, | |
| 3939 | }); | |
| 3940 | ||
| 3941 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 3942 | expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1"]); | |
| 3943 | expect(conversation.reviewThreads[0]).toMatchObject({ | |
| 3944 | commentCount: 3, | |
| 3945 | nextCommentsCursor: "Y3Vyc29yOjI", | |
| 3946 | }); | |
| 3947 | assert.isTrue(conversation.truncated); | |
| 3948 | assert.isFalse(conversation.reviewThreadsTruncated); | |
| 3949 | }), | |
| 3950 | ); | |
| 3951 | ||
| 3952 | it.effect("reads one requested page from a review thread cursor", () => | |
| 3953 | Effect.gen(function* () { | |
| 3954 | mockedExecute.mockReturnValueOnce( | |
| 3955 | Effect.succeed(output(threadCommentsPage(["c2", "c3"], null, 3))), | |
| 3956 | ); | |
| 3957 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3958 | ||
| 3959 | const page = yield* cli.getReviewThreadComments({ | |
| 3960 | cwd: "/w", | |
| 3961 | repository: "acme/web", | |
| 3962 | host: "github.com", | |
| 3963 | number: 7, | |
| 3964 | threadId: "PRRT_1", | |
| 3965 | cursor: "Y3Vyc29yOjI", | |
| 3966 | }); | |
| 3967 | ||
| 3968 | expect(varsAt(0)).toMatchObject({ | |
| 3969 | owner: "acme", | |
| 3970 | name: "web", | |
| 3971 | number: 7, | |
| 3972 | threadId: "PRRT_1", | |
| 3973 | cursor: "Y3Vyc29yOjI", | |
| 3974 | }); | |
| 3975 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 3976 | expect(page.comments.map((comment) => comment.id)).toEqual(["c2", "c3"]); | |
| 3977 | expect(page.nextCursor).toBeNull(); | |
| 3978 | }), | |
| 3979 | ); | |
| 3980 | ||
| 3981 | it.effect("refuses a review thread from another pull request", () => | |
| 3982 | Effect.gen(function* () { | |
| 3983 | mockedExecute.mockReturnValueOnce( | |
| 3984 | Effect.succeed(output(threadCommentsPage(["foreign"], null, 1, "PR_8"))), | |
| 3985 | ); | |
| 3986 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 3987 | ||
| 3988 | const error = yield* Effect.flip( | |
| 3989 | cli.getReviewThreadComments({ | |
| 3990 | cwd: "/w", | |
| 3991 | repository: "acme/web", | |
| 3992 | host: "github.com", | |
| 3993 | number: 7, | |
| 3994 | threadId: "PRRT_FOREIGN", | |
| 3995 | cursor: "Y3Vyc29yOjI", | |
| 3996 | }), | |
| 3997 | ); | |
| 3998 | ||
| 3999 | assert.strictEqual(error._tag, "GitHubSubjectScopeError"); | |
| 4000 | }), | |
| 4001 | ); | |
| 4002 | ||
| 4003 | it.effect( | |
| 4004 | "asks for the reader's standing on the repository and on the pull request at once", | |
| 4005 | () => | |
| 4006 | Effect.gen(function* () { | |
| 4007 | mockedExecute.mockReturnValue( | |
| 4008 | Effect.succeed( | |
| 4009 | output( | |
| 4010 | encodeJson({ | |
| 4011 | data: { | |
| 4012 | repository: { | |
| 4013 | mergeCommitAllowed: true, | |
| 4014 | squashMergeAllowed: false, | |
| 4015 | rebaseMergeAllowed: true, | |
| 4016 | viewerPermission: "READ", | |
| 4017 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | |
| 4018 | }, | |
| 4019 | }, | |
| 4020 | }), | |
| 4021 | ), | |
| 4022 | ), | |
| 4023 | ); | |
| 4024 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 4025 | ||
| 4026 | const access = yield* cli.getViewerAccess({ | |
| 4027 | cwd: "/w", | |
| 4028 | repository: "acme/web", | |
| 4029 | host: "github.com", | |
| 4030 | number: 7, | |
| 4031 | }); | |
| 4032 | ||
| 4033 | // One request, because both answers hang off the same repository object. | |
| 4034 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 4035 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); | |
| 4036 | expect(queryAt(0)).toContain("mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed"); | |
| 4037 | expect(access).toEqual({ | |
| 4038 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | |
| 4039 | canWrite: false, | |
| 4040 | canTriage: false, | |
| 4041 | canUpdate: true, | |
| 4042 | didAuthor: true, | |
| 4043 | }); | |
| 4044 | }), | |
| 4045 | ); | |
| 4046 | ||
| 4047 | it.effect("stops GraphQL reads at the protected reserve until reset", () => | |
| 4048 | Effect.gen(function* () { | |
| 4049 | mockedExecute.mockReturnValue( | |
| 4050 | Effect.succeed( | |
| 4051 | output( | |
| 4052 | encodeJson({ | |
| 4053 | data: { | |
| 4054 | repository: { | |
| 4055 | mergeCommitAllowed: true, | |
| 4056 | squashMergeAllowed: false, | |
| 4057 | rebaseMergeAllowed: true, | |
| 4058 | viewerPermission: "READ", | |
| 4059 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | |
| 4060 | }, | |
| 4061 | rateLimit: { | |
| 4062 | cost: 1, | |
| 4063 | limit: 5_000, | |
| 4064 | remaining: 500, | |
| 4065 | resetAt: "2099-08-13T14:00:00Z", | |
| 4066 | }, | |
| 4067 | }, | |
| 4068 | }), | |
| 4069 | ), | |
| 4070 | ), | |
| 4071 | ); | |
| 4072 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 4073 | const input = { | |
| 4074 | cwd: "/w", | |
| 4075 | repository: "acme/web", | |
| 4076 | host: "github.com", | |
| 4077 | number: 7, | |
| 4078 | } as const; | |
| 4079 | ||
| 4080 | yield* cli.getViewerAccess(input); | |
| 4081 | expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); | |
| 4082 | ||
| 4083 | const error = yield* Effect.flip(cli.getViewerAccess(input)); | |
| 4084 | ||
| 4085 | assert.strictEqual(error._tag, "SourceControlRateLimitPausedError"); | |
| 4086 | if (error._tag !== "SourceControlRateLimitPausedError") return; | |
| 4087 | assert.strictEqual(error.host, "github.com"); | |
| 4088 | assert.strictEqual(error.retryAt, Date.parse("2099-08-13T14:00:00Z")); | |
| 4089 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 4090 | yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z")); | |
| 4091 | }), | |
| 4092 | ); | |
| 4093 | ||
| 4094 | it.effect("lets an interactive permission read use the protected reserve", () => | |
| 4095 | Effect.gen(function* () { | |
| 4096 | mockedExecute | |
| 4097 | .mockReturnValueOnce( | |
| 4098 | Effect.succeed( | |
| 4099 | output( | |
| 4100 | encodeJson({ | |
| 4101 | data: { | |
| 4102 | repository: { | |
| 4103 | mergeCommitAllowed: true, | |
| 4104 | squashMergeAllowed: false, | |
| 4105 | rebaseMergeAllowed: true, | |
| 4106 | viewerPermission: "READ", | |
| 4107 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | |
| 4108 | }, | |
| 4109 | rateLimit: { | |
| 4110 | cost: 1, | |
| 4111 | limit: 5_000, | |
| 4112 | remaining: 500, | |
| 4113 | resetAt: "2099-08-13T14:00:00Z", | |
| 4114 | }, | |
| 4115 | }, | |
| 4116 | }), | |
| 4117 | ), | |
| 4118 | ), | |
| 4119 | ) | |
| 4120 | .mockReturnValueOnce( | |
| 4121 | Effect.succeed( | |
| 4122 | output( | |
| 4123 | encodeJson({ | |
| 4124 | data: { | |
| 4125 | repository: { | |
| 4126 | mergeCommitAllowed: true, | |
| 4127 | squashMergeAllowed: false, | |
| 4128 | rebaseMergeAllowed: true, | |
| 4129 | viewerPermission: "READ", | |
| 4130 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | |
| 4131 | }, | |
| 4132 | }, | |
| 4133 | }), | |
| 4134 | ), | |
| 4135 | ), | |
| 4136 | ); | |
| 4137 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 4138 | ||
| 4139 | yield* cli.getViewerAccess({ | |
| 4140 | cwd: "/w", | |
| 4141 | repository: "acme/web", | |
| 4142 | host: "github.com", | |
| 4143 | number: 7, | |
| 4144 | }); | |
| 4145 | const access = yield* cli.getViewerAccess({ | |
| 4146 | cwd: "/w", | |
| 4147 | repository: "acme/web", | |
| 4148 | host: "github.com", | |
| 4149 | number: 7, | |
| 4150 | allowReserve: true, | |
| 4151 | }); | |
| 4152 | ||
| 4153 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 4154 | expect(access).toEqual({ | |
| 4155 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | |
| 4156 | canWrite: false, | |
| 4157 | canTriage: false, | |
| 4158 | canUpdate: true, | |
| 4159 | didAuthor: true, | |
| 4160 | }); | |
| 4161 | yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z")); | |
| 4162 | }), | |
| 4163 | ); | |
| 4164 | ||
| 4165 | it.effect("asks GitHub to review, naming the collection a request is added to", () => | |
| 4166 | Effect.gen(function* () { | |
| 4167 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 4168 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 4169 | ||
| 4170 | yield* cli.setReviewerRequest({ | |
| 4171 | cwd: "/w", | |
| 4172 | repository: "acme/web", | |
| 4173 | host: "github.com", | |
| 4174 | number: 7, | |
| 4175 | reviewers: [ | |
| 4176 | { id: "octocat", kind: "user" }, | |
| 4177 | { id: "reviewers", kind: "team" }, | |
| 4178 | ], | |
| 4179 | requested: true, | |
| 4180 | }); | |
| 4181 | ||
| 4182 | expect(callAt(0)).toMatchObject({ | |
| 4183 | kind: "rest", | |
| 4184 | method: "POST", | |
| 4185 | path: "repos/acme/web/pulls/7/requested_reviewers", | |
| 4186 | body: { reviewers: ["octocat"], team_reviewers: ["reviewers"] }, | |
| 4187 | }); | |
| 4188 | }), | |
| 4189 | ); | |
| 4190 | ||
| 4191 | it.effect("takes a request back by deleting from the same collection it was added to", () => | |
| 4192 | Effect.gen(function* () { | |
| 4193 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 4194 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 4195 | ||
| 4196 | yield* cli.setReviewerRequest({ | |
| 4197 | cwd: "/w", | |
| 4198 | repository: "acme/web", | |
| 4199 | host: "github.com", | |
| 4200 | number: 7, | |
| 4201 | reviewers: [{ id: "octocat", kind: "user" }], | |
| 4202 | requested: false, | |
| 4203 | }); | |
| 4204 | ||
| 4205 | expect(callAt(0)).toMatchObject({ | |
| 4206 | kind: "rest", | |
| 4207 | method: "DELETE", | |
| 4208 | path: "repos/acme/web/pulls/7/requested_reviewers", | |
| 4209 | body: { reviewers: ["octocat"], team_reviewers: [] }, | |
| 4210 | }); | |
| 4211 | }), | |
| 4212 | ); | |
| 4213 | ||
| 4214 | it.effect("reads who may review and who already has in one request", () => | |
| 4215 | Effect.gen(function* () { | |
| 4216 | mockedExecute.mockReturnValue( | |
| 4217 | Effect.succeed( | |
| 4218 | output( | |
| 4219 | encodeJson({ | |
| 4220 | data: { | |
| 4221 | repository: { | |
| 4222 | assignableUsers: { | |
| 4223 | pageInfo: { hasNextPage: false }, | |
| 4224 | nodes: [{ login: "bilal" }, { login: "octocat" }, { login: "hubot" }], | |
| 4225 | }, | |
| 4226 | pullRequest: { | |
| 4227 | author: { login: "bilal" }, | |
| 4228 | reviewRequests: { nodes: [{ requestedReviewer: { login: "octocat" } }] }, | |
| 4229 | }, | |
| 4230 | }, | |
| 4231 | }, | |
| 4232 | }), | |
| 4233 | ), | |
| 4234 | ), | |
| 4235 | ); | |
| 4236 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 4237 | ||
| 4238 | const list = yield* cli.listReviewerCandidates({ | |
| 4239 | cwd: "/w", | |
| 4240 | repository: "acme/web", | |
| 4241 | host: "github.com", | |
| 4242 | number: 7, | |
| 4243 | }); | |
| 4244 | ||
| 4245 | // The people, who has been asked and who opened the pull request all hang off the same | |
| 4246 | // repository object, so the menu costs one request. | |
| 4247 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 4248 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); | |
| 4249 | expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ | |
| 4250 | ["octocat", true], | |
| 4251 | ["hubot", false], | |
| 4252 | ]); | |
| 4253 | }), | |
| 4254 | ); | |
| 4255 | ||
| 4256 | it.effect("puts labels on by posting to the issue's own collection, all at once", () => | |
| 4257 | Effect.gen(function* () { | |
| 4258 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | |
| 4259 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 4260 | ||
| 4261 | yield* cli.setLabels({ | |
| 4262 | cwd: "/w", | |
| 4263 | repository: "acme/web", | |
| 4264 | host: "github.com", | |
| 4265 | number: 7, | |
| 4266 | labels: ["bug", "size:XL"], | |
| 4267 | applied: true, | |
| 4268 | }); | |
| 4269 | ||
| 4270 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | |
| 4271 | expect(callAt(0)).toMatchObject({ | |
| 4272 | kind: "rest", | |
| 4273 | method: "POST", | |
| 4274 | path: "repos/acme/web/issues/7/labels", | |
| 4275 | body: { labels: ["bug", "size:XL"] }, | |
| 4276 | }); | |
| 4277 | }), | |
| 4278 | ); | |
| 4279 | ||
| 4280 | it.effect("takes labels off one at a time, naming each in the path encoded", () => | |
| 4281 | Effect.gen(function* () { | |
| 4282 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | |
| 4283 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 4284 | ||
| 4285 | yield* cli.setLabels({ | |
| 4286 | cwd: "/w", | |
| 4287 | repository: "acme/web", | |
| 4288 | host: "github.com", | |
| 4289 | number: 7, | |
| 4290 | labels: ["good first issue", "area/web"], | |
| 4291 | applied: false, | |
| 4292 | }); | |
| 4293 | ||
| 4294 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 4295 | expect(callAt(0)).toMatchObject({ | |
| 4296 | method: "DELETE", | |
| 4297 | path: "repos/acme/web/issues/7/labels/good%20first%20issue", | |
| 4298 | }); | |
| 4299 | expect(callAt(1)).toMatchObject({ | |
| 4300 | method: "DELETE", | |
| 4301 | path: "repos/acme/web/issues/7/labels/area%2Fweb", | |
| 4302 | }); | |
| 4303 | }), | |
| 4304 | ); | |
| 4305 | ||
| 4306 | it.effect("reads every page of viewed files, and says so when there are too many", () => | |
| 4307 | Effect.gen(function* () { | |
| 4308 | const page = (index: number, hasNextPage: boolean) => | |
| 4309 | Effect.succeed( | |
| 4310 | output( | |
| 4311 | encodeJson({ | |
| 4312 | data: { | |
| 4313 | repository: { | |
| 4314 | pullRequest: { | |
| 4315 | files: { | |
| 4316 | pageInfo: { hasNextPage, endCursor: `cursor-${index}` }, | |
| 4317 | nodes: [ | |
| 4318 | { path: `src/file${index}.ts`, viewerViewedState: "VIEWED" }, | |
| 4319 | { path: `src/other${index}.ts`, viewerViewedState: "UNVIEWED" }, | |
| 4320 | ], | |
| 4321 | }, | |
| 4322 | }, | |
| 4323 | }, | |
| 4324 | }, | |
| 4325 | }), | |
| 4326 | ), | |
| 4327 | ); | |
| 4328 | mockedExecute | |
| 4329 | .mockReturnValueOnce(page(0, true)) | |
| 4330 | .mockReturnValueOnce(page(1, true)) | |
| 4331 | .mockReturnValueOnce(page(2, false)); | |
| 4332 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 4333 | ||
| 4334 | const viewed = yield* cli.getPullRequestFilesViewed({ | |
| 4335 | cwd: "/w", | |
| 4336 | repository: "acme/web", | |
| 4337 | host: "github.com", | |
| 4338 | number: 7, | |
| 4339 | }); | |
| 4340 | ||
| 4341 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | |
| 4342 | // The first page asks from the start; each one after it carries the cursor before it. | |
| 4343 | expect([varsAt(0)["after"], varsAt(1)["after"], varsAt(2)["after"]]).toEqual([ | |
| 4344 | null, | |
| 4345 | "cursor-0", | |
| 4346 | "cursor-1", | |
| 4347 | ]); | |
| 4348 | assert.isFalse(viewed.truncated); | |
| 4349 | expect(viewed.files.map((file) => [file.path, file.state])).toEqual([ | |
| 4350 | ["src/file0.ts", "viewed"], | |
| 4351 | ["src/other0.ts", "unviewed"], | |
| 4352 | ["src/file1.ts", "viewed"], | |
| 4353 | ["src/other1.ts", "unviewed"], | |
| 4354 | ["src/file2.ts", "viewed"], | |
| 4355 | ["src/other2.ts", "unviewed"], | |
| 4356 | ]); | |
| 4357 | }), | |
| 4358 | ); | |
| 4359 | ||
| 4360 | it.effect("stops paging viewed files rather than following a change without end", () => | |
| 4361 | Effect.gen(function* () { | |
| 4362 | mockedExecute.mockReturnValue( | |
| 4363 | Effect.succeed( | |
| 4364 | output( | |
| 4365 | encodeJson({ | |
| 4366 | data: { | |
| 4367 | repository: { | |
| 4368 | pullRequest: { | |
| 4369 | files: { | |
| 4370 | pageInfo: { hasNextPage: true, endCursor: "cursor" }, | |
| 4371 | nodes: [{ path: "src/file.ts", viewerViewedState: "VIEWED" }], | |
| 4372 | }, | |
| 4373 | }, | |
| 4374 | }, | |
| 4375 | }, | |
| 4376 | }), | |
| 4377 | ), | |
| 4378 | ), | |
| 4379 | ); | |
| 4380 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 4381 | ||
| 4382 | const viewed = yield* cli.getPullRequestFilesViewed({ | |
| 4383 | cwd: "/w", | |
| 4384 | repository: "acme/web", | |
| 4385 | host: "github.com", | |
| 4386 | number: 7, | |
| 4387 | }); | |
| 4388 | ||
| 4389 | assert.strictEqual(mockedExecute.mock.calls.length, 5); | |
| 4390 | assert.isTrue(viewed.truncated); | |
| 4391 | assert.strictEqual(viewed.files.length, 5); | |
| 4392 | }), | |
| 4393 | ); | |
| 4394 | ||
| 4395 | it.effect("clears and restores a burst of files in one request", () => | |
| 4396 | Effect.gen(function* () { | |
| 4397 | mockedExecute | |
| 4398 | .mockReturnValueOnce( | |
| 4399 | Effect.succeed( | |
| 4400 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_1" } } } })), | |
| 4401 | ), | |
| 4402 | ) | |
| 4403 | .mockReturnValueOnce(Effect.succeed(output("{}"))); | |
| 4404 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 4405 | ||
| 4406 | yield* cli.setPullRequestFilesViewed({ | |
| 4407 | cwd: "/w", | |
| 4408 | repository: "acme/web", | |
| 4409 | host: "github.com", | |
| 4410 | number: 23, | |
| 4411 | files: [ | |
| 4412 | { path: "src/a.ts", viewed: true }, | |
| 4413 | { path: "src/b.ts", viewed: false }, | |
| 4414 | ], | |
| 4415 | }); | |
| 4416 | ||
| 4417 | // One request to learn the pull request's node id, one for every press together. | |
| 4418 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | |
| 4419 | expect(queryAt(1)).toContain("f0: markFileAsViewed"); | |
| 4420 | expect(queryAt(1)).toContain("f1: unmarkFileAsViewed"); | |
| 4421 | expect(varsAt(1)).toEqual({ | |
| 4422 | pullRequestId: "PR_1", | |
| 4423 | path0: "src/a.ts", | |
| 4424 | path1: "src/b.ts", | |
| 4425 | }); | |
| 4426 | }), | |
| 4427 | ); | |
| 4428 | ||
| 4429 | it.effect("asks the host nothing when nothing was pressed", () => | |
| 4430 | Effect.gen(function* () { | |
| 4431 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 4432 | ||
| 4433 | yield* cli.setPullRequestFilesViewed({ | |
| 4434 | cwd: "/w", | |
| 4435 | repository: "acme/web", | |
| 4436 | host: "github.com", | |
| 4437 | number: 7, | |
| 4438 | files: [], | |
| 4439 | }); | |
| 4440 | ||
| 4441 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 4442 | }), | |
| 4443 | ); | |
| 4444 | ||
| 4445 | it.effect("looks a pull request's node id up once, however often it is written to", () => | |
| 4446 | Effect.gen(function* () { | |
| 4447 | mockedExecute | |
| 4448 | .mockReturnValueOnce( | |
| 4449 | Effect.succeed( | |
| 4450 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_24" } } } })), | |
| 4451 | ), | |
| 4452 | ) | |
| 4453 | .mockReturnValue(Effect.succeed(output("{}"))); | |
| 4454 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 4455 | const pullRequest = { cwd: "/w", repository: "acme/web", host: "github.com", number: 24 }; | |
| 4456 | ||
| 4457 | yield* cli.setPullRequestFilesViewed({ | |
| 4458 | ...pullRequest, | |
| 4459 | files: [{ path: "src/a.ts", viewed: true }], | |
| 4460 | }); | |
| 4461 | yield* cli.setPullRequestFilesViewed({ | |
| 4462 | ...pullRequest, | |
| 4463 | files: [{ path: "src/b.ts", viewed: true }], | |
| 4464 | }); | |
| 4465 | yield* cli.updatePullRequest({ ...pullRequest, title: "Ticked through" }); | |
| 4466 | ||
| 4467 | // One lookup, then a mutation per write, every one of them addressed by the id it answered. | |
| 4468 | assert.strictEqual(mockedExecute.mock.calls.length, 4); | |
| 4469 | expect(varsAt(0)).toEqual({ owner: "acme", name: "web", number: 24 }); | |
| 4470 | expect([1, 2, 3].map((index) => varsAt(index)["pullRequestId"])).toEqual([ | |
| 4471 | "PR_24", | |
| 4472 | "PR_24", | |
| 4473 | "PR_24", | |
| 4474 | ]); | |
| 4475 | }), | |
| 4476 | ); | |
| 4477 | ||
| 4478 | it.effect("does not remember a node id lookup that failed", () => | |
| 4479 | Effect.gen(function* () { | |
| 4480 | mockedExecute | |
| 4481 | .mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))) | |
| 4482 | .mockReturnValueOnce( | |
| 4483 | Effect.succeed( | |
| 4484 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_25" } } } })), | |
| 4485 | ), | |
| 4486 | ) | |
| 4487 | .mockReturnValueOnce(Effect.succeed(output("{}"))); | |
| 4488 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 4489 | const write = () => | |
| 4490 | cli.setPullRequestFilesViewed({ | |
| 4491 | cwd: "/w", | |
| 4492 | repository: "acme/web", | |
| 4493 | host: "github.com", | |
| 4494 | number: 25, | |
| 4495 | files: [{ path: "src/a.ts", viewed: true }], | |
| 4496 | }); | |
| 4497 | ||
| 4498 | const error = yield* Effect.flip(write()); | |
| 4499 | assert.strictEqual(error._tag, "GitHubPullRequestReadError"); | |
| 4500 | ||
| 4501 | yield* write(); | |
| 4502 | ||
| 4503 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | |
| 4504 | expect(varsAt(2)["pullRequestId"]).toEqual("PR_25"); | |
| 4505 | }), | |
| 4506 | ); | |
| 4507 | it.effect("keeps the pull request being ticked through, not the one looked up first", () => | |
| 4508 | Effect.gen(function* () { | |
| 4509 | // Ordered by insertion alone, a hit does not renew its entry, so the review the reader is | |
| 4510 | // working down is the first thing evicted once a listing has walked a cache's worth of cold | |
| 4511 | // pull requests, and every press after that pays a round trip again. | |
| 4512 | // This block shares one cache, so these numbers are its own and it runs last. | |
| 4513 | const HOT = 9_000; | |
| 4514 | const lookupsOf = new Map<number, number>(); | |
| 4515 | mockedExecute.mockImplementation((input) => { | |
| 4516 | if (input.kind !== "graphql" || !input.query.includes(NODE_ID_QUERY)) { | |
| 4517 | return Effect.succeed(output("{}")); | |
| 4518 | } | |
| 4519 | const number = Number(input.variables?.["number"]); | |
| 4520 | lookupsOf.set(number, (lookupsOf.get(number) ?? 0) + 1); | |
| 4521 | return Effect.succeed( | |
| 4522 | output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })), | |
| 4523 | ); | |
| 4524 | }); | |
| 4525 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 4526 | const tick = (number: number) => | |
| 4527 | cli.setPullRequestFilesViewed({ | |
| 4528 | cwd: "/w", | |
| 4529 | repository: "acme/web", | |
| 4530 | host: "github.com", | |
| 4531 | number, | |
| 4532 | files: [{ path: "src/a.ts", viewed: true }], | |
| 4533 | }); | |
| 4534 | ||
| 4535 | yield* tick(HOT); | |
| 4536 | // A cache's worth of cold pull requests, with the open one pressed in between each of them. | |
| 4537 | for (let filled = 0; filled < GitHubPullRequestCli.NODE_ID_CACHE_CAPACITY; filled += 1) { | |
| 4538 | yield* tick(HOT + 1 + filled); | |
| 4539 | yield* tick(HOT); | |
| 4540 | } | |
| 4541 | ||
| 4542 | assert.strictEqual(lookupsOf.get(HOT), 1); | |
| 4543 | }), | |
| 4544 | ); | |
| 4545 | }); |
apps/server/src/pullRequest/GitHubPullRequestCli.ts deleted-2874| ... | ... | @@ -1,2874 +0,0 @@ |
| 1 | import { removeAgentCredits } from "./mergeMessage.ts"; | |
| 2 | import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; | |
| 3 | import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; | |
| 4 | import * as Cause from "effect/Cause"; | |
| 5 | import * as Context from "effect/Context"; | |
| 6 | import * as Clock from "effect/Clock"; | |
| 7 | import * as Duration from "effect/Duration"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as FileSystem from "effect/FileSystem"; | |
| 10 | import * as Layer from "effect/Layer"; | |
| 11 | import * as Exit from "effect/Exit"; | |
| 12 | import * as Option from "effect/Option"; | |
| 13 | import * as Request from "effect/Request"; | |
| 14 | import * as RequestResolver from "effect/RequestResolver"; | |
| 15 | import * as Result from "effect/Result"; | |
| 16 | import * as Schema from "effect/Schema"; | |
| 17 | import * as Semaphore from "effect/Semaphore"; | |
| 18 | import { | |
| 19 | resolvePullRequestAuthorFilter, | |
| 20 | PositiveInt, | |
| 21 | TrimmedNonEmptyString, | |
| 22 | type PullRequestAction, | |
| 23 | type PullRequestStackHead, | |
| 24 | type PullRequestActor, | |
| 25 | type PullRequestComment, | |
| 26 | type PullRequestCommit, | |
| 27 | type PullRequestFileViewed, | |
| 28 | type PullRequestInvolvement, | |
| 29 | type PullRequestListFilters, | |
| 30 | type PullRequestListState, | |
| 31 | type PullRequestMergeMethod, | |
| 32 | type PullRequestOmittedFileStat, | |
| 33 | type PullRequestReaction, | |
| 34 | type PullRequestReactionContent, | |
| 35 | type PullRequestReviewCommentDraft, | |
| 36 | type PullRequestReviewVerdict, | |
| 37 | type PullRequestReviewerCandidateList, | |
| 38 | type PullRequestReviewerKind, | |
| 39 | type PullRequestLabelCandidateList, | |
| 40 | type PullRequestThreadCommentsResult, | |
| 41 | type PullRequestUpdateMethod, | |
| 42 | type PullRequestPreview, | |
| 43 | } from "@t3tools/contracts"; | |
| 44 | ||
| 45 | import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; | |
| 46 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 47 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 48 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; | |
| 49 | import { | |
| 50 | ACTOR_AVATARS_GRAPHQL_QUERY, | |
| 51 | ADD_REACTION_GRAPHQL_MUTATION, | |
| 52 | buildReviewSubmission, | |
| 53 | buildReviewerRequest, | |
| 54 | buildSetFilesViewedGraphQlMutation, | |
| 55 | decodeActorAvatarsJson, | |
| 56 | decodePullRequestActivityJson, | |
| 57 | decodePullRequestCheckContextsJson, | |
| 58 | decodePullRequestCoreJson, | |
| 59 | decodeCommitFilesJson, | |
| 60 | pullRequestCheckContextsGraphQlQuery, | |
| 61 | pullRequestChecksFromContexts, | |
| 62 | pullRequestListGraphQlQuery, | |
| 63 | pullRequestSummaryGraphQlQuery, | |
| 64 | PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, | |
| 65 | PULL_REQUEST_HEADS_GRAPHQL_QUERY, | |
| 66 | decodeWorkflowRunsJson, | |
| 67 | type GitHubCheckContext, | |
| 68 | pullRequestCoreGraphQlQuery, | |
| 69 | type GitHubPullRequestCore, | |
| 70 | type GitHubPullRequestSummary, | |
| 71 | type GitHubPullRequestWatchFingerprint, | |
| 72 | decodePullRequestPreviewJson, | |
| 73 | PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, | |
| 74 | decodePullRequestFilesJson, | |
| 75 | decodePullRequestFilesViewedJson, | |
| 76 | decodePullRequestHeadsJson, | |
| 77 | decodePullRequestListJson, | |
| 78 | decodePullRequestNodeIdJson, | |
| 79 | decodePullRequestSearchJson, | |
| 80 | decodePullRequestStacksJson, | |
| 81 | decodePullRequestStatsJson, | |
| 82 | decodePullRequestSummariesJson, | |
| 83 | decodePullRequestWatchFingerprintsJson, | |
| 84 | decodeReactionSubjectScopeJson, | |
| 85 | decodeReviewerCandidatesJson, | |
| 86 | decodeLabelCandidatesJson, | |
| 87 | buildLabelRequest, | |
| 88 | LABEL_CANDIDATES_GRAPHQL_QUERY, | |
| 89 | decodeReviewDismissalsJson, | |
| 90 | decodeReviewThreadCommentsJson, | |
| 91 | decodeReviewThreadsJson, | |
| 92 | buildPullRequestStatsGraphQlQuery, | |
| 93 | buildPullRequestSummariesGraphQlQuery, | |
| 94 | buildPullRequestWatchFingerprintsGraphQlQuery, | |
| 95 | buildPullRequestStackMembershipsGraphQlQuery, | |
| 96 | decodePullRequestStackMembershipsJson, | |
| 97 | pullRequestSearchGraphQlQuery, | |
| 98 | PULL_REQUEST_SEARCH_MAX_ROWS, | |
| 99 | PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, | |
| 100 | PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, | |
| 101 | REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, | |
| 102 | REMOVE_REACTION_GRAPHQL_MUTATION, | |
| 103 | REVERT_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 104 | gitHubReactionContent, | |
| 105 | RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, | |
| 106 | REVIEWER_CANDIDATES_GRAPHQL_QUERY, | |
| 107 | REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, | |
| 108 | REVIEW_DISMISSALS_GRAPHQL_QUERY, | |
| 109 | REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, | |
| 110 | REVIEW_THREADS_GRAPHQL_QUERY, | |
| 111 | reviewThreadConversation, | |
| 112 | UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, | |
| 113 | UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION, | |
| 114 | UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 115 | UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, | |
| 116 | VIEWER_PERMISSIONS_GRAPHQL_QUERY, | |
| 117 | decodeViewerPermissionsJson, | |
| 118 | type GitHubPullRequestActivity, | |
| 119 | type GitHubPullRequestActivityPage, | |
| 120 | type GitHubWorkflowRunPage, | |
| 121 | type GitHubPullRequestHead, | |
| 122 | type GitHubPullRequestListItem, | |
| 123 | type GitHubPullRequestSearchItem, | |
| 124 | type GitHubPullRequestStack, | |
| 125 | type GitHubReviewThreadComments, | |
| 126 | type GitHubRepositoryAccess, | |
| 127 | type GitHubWorkflowRunApproval, | |
| 128 | type GitHubReviewThreadEntry, | |
| 129 | type GitHubReviewThreadPage, | |
| 130 | type GitHubViewerAccess, | |
| 131 | } from "./gitHubPullRequestJson.ts"; | |
| 132 | import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullRequestProvider.ts"; | |
| 133 | ||
| 134 | /** | |
| 135 | * Names the read that produced unusable output, so a failure reports the call it came from | |
| 136 | * rather than borrowing another operation's message. | |
| 137 | */ | |
| 138 | export class GitHubPullRequestReadError extends Schema.TaggedError<GitHubPullRequestReadError>()( | |
| 139 | "GitHubPullRequestReadError", | |
| 140 | { | |
| 141 | command: Schema.Literal("gh"), | |
| 142 | cwd: Schema.String, | |
| 143 | operation: Schema.String, | |
| 144 | cause: Schema.Defect(), | |
| 145 | }, | |
| 146 | ) { | |
| 147 | override get message(): string { | |
| 148 | return `GitHub returned an unreadable ${this.operation} response.`; | |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | /** Not a decode failure: gh answered, the account it answered for just has no login. */ | |
| 153 | export class GitHubViewerLoginUnavailableError extends Schema.TaggedError<GitHubViewerLoginUnavailableError>()( | |
| 154 | "GitHubViewerLoginUnavailableError", | |
| 155 | { | |
| 156 | command: Schema.Literal("gh"), | |
| 157 | cwd: Schema.String, | |
| 158 | }, | |
| 159 | ) { | |
| 160 | override get message(): string { | |
| 161 | return "GitHub returned no login for the authenticated account."; | |
| 162 | } | |
| 163 | } | |
| 164 | ||
| 165 | /** Not a decode failure: gh answered, but the pull request carried no update time. */ | |
| 166 | export class GitHubPullRequestUpdatedAtUnavailableError extends Schema.TaggedError<GitHubPullRequestUpdatedAtUnavailableError>()( | |
| 167 | "GitHubPullRequestUpdatedAtUnavailableError", | |
| 168 | { | |
| 169 | command: Schema.Literal("gh"), | |
| 170 | cwd: Schema.String, | |
| 171 | repository: Schema.String, | |
| 172 | number: Schema.Int, | |
| 173 | }, | |
| 174 | ) { | |
| 175 | override get message(): string { | |
| 176 | return `Pull request ${this.repository}#${this.number} reported no update time.`; | |
| 177 | } | |
| 178 | } | |
| 179 | ||
| 180 | /** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */ | |
| 181 | export class GitHubDiffCursorError extends Schema.TaggedError<GitHubDiffCursorError>()( | |
| 182 | "GitHubDiffCursorError", | |
| 183 | { | |
| 184 | command: Schema.Literal("gh"), | |
| 185 | cwd: Schema.String, | |
| 186 | }, | |
| 187 | ) { | |
| 188 | override get message(): string { | |
| 189 | return "The diff cursor was not one this pull request handed out."; | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 193 | /** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */ | |
| 194 | export class GitHubDiffCommitError extends Schema.TaggedError<GitHubDiffCommitError>()( | |
| 195 | "GitHubDiffCommitError", | |
| 196 | { | |
| 197 | command: Schema.Literal("gh"), | |
| 198 | cwd: Schema.String, | |
| 199 | }, | |
| 200 | ) { | |
| 201 | override get message(): string { | |
| 202 | return "The named commit was not a commit sha."; | |
| 203 | } | |
| 204 | } | |
| 205 | ||
| 206 | /** The revisions read successfully, but cannot name both sides this file needs. */ | |
| 207 | export class GitHubDiffRevisionsUnavailableError extends Schema.TaggedError<GitHubDiffRevisionsUnavailableError>()( | |
| 208 | "GitHubDiffRevisionsUnavailableError", | |
| 209 | { | |
| 210 | command: Schema.Literal("gh"), | |
| 211 | cwd: Schema.String, | |
| 212 | number: Schema.Int, | |
| 213 | commit: Schema.optional(Schema.String), | |
| 214 | }, | |
| 215 | ) { | |
| 216 | override get message(): string { | |
| 217 | return this.commit === undefined | |
| 218 | ? `Pull request #${this.number} reported no usable base and head revisions.` | |
| 219 | : `Commit ${this.commit} reported no usable revisions for this file.`; | |
| 220 | } | |
| 221 | } | |
| 222 | ||
| 223 | /** A blob exists, but expanding it would be unsafe or would not produce text. */ | |
| 224 | export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedError<GitHubDiffFileContentsUnavailableError>()( | |
| 225 | "GitHubDiffFileContentsUnavailableError", | |
| 226 | { | |
| 227 | command: Schema.Literal("gh"), | |
| 228 | cwd: Schema.String, | |
| 229 | path: Schema.String, | |
| 230 | reason: Schema.Literals(["oversized", "binary"]), | |
| 231 | }, | |
| 232 | ) { | |
| 233 | override get message(): string { | |
| 234 | return this.reason === "oversized" | |
| 235 | ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.` | |
| 236 | : `The diff file '${this.path}' is binary.`; | |
| 237 | } | |
| 238 | } | |
| 239 | ||
| 240 | /** | |
| 241 | * Not a decode failure: a repository was named that cannot go into a search or into a GraphQL | |
| 242 | * document as itself. Every qualifier and every alias below is composed from `owner/name`, so a | |
| 243 | * name that is not one is refused here rather than escaped into something GitHub might read as a | |
| 244 | * qualifier of its own. | |
| 245 | */ | |
| 246 | export class GitHubRepositorySelectorError extends Schema.TaggedError<GitHubRepositorySelectorError>()( | |
| 247 | "GitHubRepositorySelectorError", | |
| 248 | { | |
| 249 | command: Schema.Literal("gh"), | |
| 250 | cwd: Schema.String, | |
| 251 | operation: Schema.String, | |
| 252 | }, | |
| 253 | ) { | |
| 254 | override get message(): string { | |
| 255 | return "A repository was named that GitHub cannot address."; | |
| 256 | } | |
| 257 | } | |
| 258 | ||
| 259 | /** Not a decode failure: the reader named a subject this pull request never handed out. */ | |
| 260 | export class GitHubSubjectScopeError extends Schema.TaggedError<GitHubSubjectScopeError>()( | |
| 261 | "GitHubSubjectScopeError", | |
| 262 | { | |
| 263 | command: Schema.Literal("gh"), | |
| 264 | cwd: Schema.String, | |
| 265 | operation: Schema.String, | |
| 266 | }, | |
| 267 | ) { | |
| 268 | override get message(): string { | |
| 269 | return "The named subject did not belong to the named pull request."; | |
| 270 | } | |
| 271 | } | |
| 272 | ||
| 273 | /** GitHub answered successfully, but approving every returned workflow would be unsafe. */ | |
| 274 | export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError<GitHubWorkflowApprovalRefusedError>()( | |
| 275 | "GitHubWorkflowApprovalRefusedError", | |
| 276 | { | |
| 277 | command: Schema.Literal("gh"), | |
| 278 | cwd: Schema.String, | |
| 279 | number: Schema.Int, | |
| 280 | reason: Schema.Literals(["head-list-truncated", "head-not-unique", "run-list-truncated"]), | |
| 281 | observedCount: Schema.Int, | |
| 282 | limit: Schema.Int, | |
| 283 | }, | |
| 284 | ) { | |
| 285 | override get message(): string { | |
| 286 | if (this.reason === "head-list-truncated") { | |
| 287 | return `GitHub returned more than ${this.limit} pull requests for this head branch.`; | |
| 288 | } | |
| 289 | if (this.reason === "head-not-unique") { | |
| 290 | return `The head revision matched ${this.observedCount} pull requests instead of uniquely matching #${this.number}.`; | |
| 291 | } | |
| 292 | return `GitHub returned more than ${this.limit} workflow runs awaiting approval.`; | |
| 293 | } | |
| 294 | } | |
| 295 | ||
| 296 | /** GitHub omitted the immutable head identity needed to scope an approval safely. */ | |
| 297 | export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedError<GitHubWorkflowApprovalHeadUnavailableError>()( | |
| 298 | "GitHubWorkflowApprovalHeadUnavailableError", | |
| 299 | { | |
| 300 | command: Schema.Literal("gh"), | |
| 301 | cwd: Schema.String, | |
| 302 | number: Schema.Int, | |
| 303 | }, | |
| 304 | ) { | |
| 305 | override get message(): string { | |
| 306 | return `GitHub did not report a complete head revision for #${this.number}.`; | |
| 307 | } | |
| 308 | } | |
| 309 | ||
| 310 | /** The pull request moved after its approval candidates were read. */ | |
| 311 | export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError<GitHubWorkflowApprovalHeadChangedError>()( | |
| 312 | "GitHubWorkflowApprovalHeadChangedError", | |
| 313 | { | |
| 314 | command: Schema.Literal("gh"), | |
| 315 | cwd: Schema.String, | |
| 316 | number: Schema.Int, | |
| 317 | }, | |
| 318 | ) { | |
| 319 | override get message(): string { | |
| 320 | return `The head revision of #${this.number} changed before its workflows could be approved.`; | |
| 321 | } | |
| 322 | } | |
| 323 | ||
| 324 | export type GitHubPullRequestCliError = | |
| 325 | | GitHubStackActionError | |
| 326 | | GitHubApi.GitHubApiError | |
| 327 | | GitHubPullRequestReadError | |
| 328 | | GitHubDiffCursorError | |
| 329 | | GitHubDiffCommitError | |
| 330 | | GitHubDiffRevisionsUnavailableError | |
| 331 | | GitHubDiffFileContentsUnavailableError | |
| 332 | | GitHubRepositorySelectorError | |
| 333 | | GitHubSubjectScopeError | |
| 334 | | GitHubWorkflowApprovalRefusedError | |
| 335 | | GitHubWorkflowApprovalHeadUnavailableError | |
| 336 | | GitHubWorkflowApprovalHeadChangedError | |
| 337 | | SourceControlRateLimit.SourceControlRateLimitPausedError | |
| 338 | | GitHubViewerLoginUnavailableError | |
| 339 | | GitHubPullRequestUpdatedAtUnavailableError; | |
| 340 | ||
| 341 | /** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ | |
| 342 | const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; | |
| 343 | const DIFF_TIMEOUT = Duration.seconds(60); | |
| 344 | /** Pierre expansion is for source files, not blobs large enough to stall a review surface. */ | |
| 345 | const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; | |
| 346 | ||
| 347 | /** What `gh pr list --state` asked a repository's own list for, without search. */ | |
| 348 | const LIST_STATES: Record<PullRequestListState, ReadonlyArray<"OPEN" | "CLOSED" | "MERGED">> = { | |
| 349 | all: ["OPEN", "CLOSED", "MERGED"], | |
| 350 | open: ["OPEN"], | |
| 351 | // Closed includes merged here, the way GitHub's own list reads it; merged rows are filtered out | |
| 352 | // locally by `matchesUnsortedListing`. | |
| 353 | closed: ["CLOSED", "MERGED"], | |
| 354 | merged: ["MERGED"], | |
| 355 | }; | |
| 356 | ||
| 357 | interface GitHubPullRequestListPage { | |
| 358 | readonly items: ReadonlyArray<GitHubPullRequestListItem>; | |
| 359 | readonly rawCount: number; | |
| 360 | readonly endCursor: string | null; | |
| 361 | } | |
| 362 | ||
| 363 | /** A search-free fallback may scan older rows for local filters, but never the whole repository. */ | |
| 364 | const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; | |
| 365 | ||
| 366 | /** What the files API serves at most in one response, which is what one slice is made of. */ | |
| 367 | const DIFF_FILES_PAGE_SIZE = 100; | |
| 368 | /** | |
| 369 | * How many hundred-file pages of viewed state one read will walk. A point of the hourly GraphQL | |
| 370 | * budget per page, against a change request nobody reviews in one sitting past the first few | |
| 371 | * hundred files: beyond this the read stops and says it was cut short. | |
| 372 | */ | |
| 373 | const FILES_VIEWED_MAX_PAGES = 5; | |
| 374 | ||
| 375 | /** | |
| 376 | * How many pull requests' node ids are remembered at once. A long-lived server sees far more of | |
| 377 | * them than a reader ever has open, and least recently used rather than first in: a listing | |
| 378 | * walking cold pull requests must not evict the review being ticked through. | |
| 379 | */ | |
| 380 | export const NODE_ID_CACHE_CAPACITY = 128; | |
| 381 | ||
| 382 | /** | |
| 383 | * Pages of review threads to follow before the conversation is reported as truncated. GitHub | |
| 384 | * serves a hundred threads a page, so this is a thousand threads — past anything a pull request | |
| 385 | * a person is reading has, and short of walking a repository-sized conversation forever. | |
| 386 | */ | |
| 387 | const REVIEW_THREAD_PAGES = 10; | |
| 388 | ||
| 389 | /** Pages of a hundred check contexts to walk for one head before the rollup says it was cut. */ | |
| 390 | const CHECK_CONTEXT_PAGES = 10; | |
| 391 | ||
| 392 | export interface GitHubPullRequestListBatch { | |
| 393 | readonly items: ReadonlyArray<GitHubPullRequestListItem>; | |
| 394 | readonly truncated: boolean; | |
| 395 | /** False for a page GitHub would not search, which came back in `gh`'s own order instead. */ | |
| 396 | readonly continues: boolean; | |
| 397 | } | |
| 398 | ||
| 399 | export interface GitHubPullRequestStat { | |
| 400 | readonly repository: string; | |
| 401 | readonly number: number; | |
| 402 | readonly additions: number; | |
| 403 | readonly deletions: number; | |
| 404 | } | |
| 405 | ||
| 406 | /** | |
| 407 | * Aliased lookups per request, and requests at once. Measured over a hundred rows: one request | |
| 408 | * carrying all hundred takes ~5.2s, four of twenty-five in parallel ~2.1s. | |
| 409 | */ | |
| 410 | const STAT_ALIASES_PER_REQUEST = 25; | |
| 411 | const STAT_REQUEST_CONCURRENCY = 4; | |
| 412 | /** | |
| 413 | * How long a summary read waits for company. The background sync asks for every linked pull | |
| 414 | * request at once, and each read reaches the resolver after its own cache check, so a batch | |
| 415 | * needs a moment longer than one scheduler tick to gather them. | |
| 416 | */ | |
| 417 | const SUMMARY_BATCH_WINDOW = "10 millis"; | |
| 418 | ||
| 419 | class PullRequestSummaryRead extends Request.Class< | |
| 420 | { | |
| 421 | readonly cwd: string; | |
| 422 | readonly repository: string; | |
| 423 | readonly host: string; | |
| 424 | readonly number: number; | |
| 425 | }, | |
| 426 | ProviderChangeRequestSummary, | |
| 427 | GitHubPullRequestCliError | |
| 428 | > {} | |
| 429 | ||
| 430 | class PullRequestWatchFingerprintRead extends Request.Class< | |
| 431 | { | |
| 432 | readonly cwd: string; | |
| 433 | readonly repository: string; | |
| 434 | readonly host: string; | |
| 435 | readonly number: number; | |
| 436 | }, | |
| 437 | GitHubPullRequestWatchFingerprint | null, | |
| 438 | GitHubPullRequestCliError | |
| 439 | > {} | |
| 440 | ||
| 441 | export interface GitHubPullRequestSearchBatch { | |
| 442 | /** Rows across every repository asked for, newest update first, each naming its own. */ | |
| 443 | readonly items: ReadonlyArray<GitHubPullRequestSearchItem>; | |
| 444 | readonly truncated: boolean; | |
| 445 | } | |
| 446 | ||
| 447 | export interface GitHubPullRequestDiffSlice { | |
| 448 | readonly patch: string; | |
| 449 | /** Files in this slice had their hunks withheld, as opposed to there being more slices. */ | |
| 450 | readonly truncated: boolean; | |
| 451 | /** Where the next slice starts, or null once the patch is whole. */ | |
| 452 | readonly nextCursor: string | null; | |
| 453 | /** GitHub's own counts for the files whose hunks it withheld from this slice. */ | |
| 454 | readonly omittedFileStats?: ReadonlyArray<PullRequestOmittedFileStat>; | |
| 455 | } | |
| 456 | ||
| 457 | export interface GitHubPullRequestFilesViewed { | |
| 458 | readonly files: ReadonlyArray<PullRequestFileViewed>; | |
| 459 | /** GitHub had more files than the page budget below would read. */ | |
| 460 | readonly truncated: boolean; | |
| 461 | } | |
| 462 | ||
| 463 | export class GitHubPullRequestCli extends Context.Service< | |
| 464 | GitHubPullRequestCli, | |
| 465 | { | |
| 466 | readonly withVerifiedCredential: <A, E, R>( | |
| 467 | input: { readonly cwd: string; readonly host: string }, | |
| 468 | use: (identity: { | |
| 469 | readonly accountId: string; | |
| 470 | readonly viewer: string; | |
| 471 | readonly credentialFingerprint: string; | |
| 472 | }) => Effect.Effect<A, E, R>, | |
| 473 | ) => Effect.Effect<A, E | GitHubPullRequestCliError, R>; | |
| 474 | readonly getRoutingIdentity: (input: { | |
| 475 | readonly cwd: string; | |
| 476 | readonly host: string; | |
| 477 | }) => Effect.Effect< | |
| 478 | { readonly accountId: string; readonly viewer: string }, | |
| 479 | GitHubPullRequestCliError | |
| 480 | >; | |
| 481 | readonly getViewerLogin: (input: { | |
| 482 | readonly cwd: string; | |
| 483 | readonly host: string; | |
| 484 | }) => Effect.Effect<string, GitHubPullRequestCliError>; | |
| 485 | ||
| 486 | readonly listPullRequests: (input: { | |
| 487 | readonly cwd: string; | |
| 488 | readonly repository: string; | |
| 489 | readonly host: string; | |
| 490 | readonly state: PullRequestListState; | |
| 491 | readonly involvement: PullRequestInvolvement; | |
| 492 | readonly viewer: string; | |
| 493 | readonly limit: number; | |
| 494 | /** Free text for `--search`, matched as one literal phrase. */ | |
| 495 | readonly query?: string | undefined; | |
| 496 | /** Where to carry on from, as a `updated:` qualifier on the same search. */ | |
| 497 | readonly cursor?: ProviderListCursor | undefined; | |
| 498 | /** Further narrowings, as qualifiers on the search and as a local pass on the fallback. */ | |
| 499 | readonly filters?: PullRequestListFilters | undefined; | |
| 500 | }) => Effect.Effect<GitHubPullRequestListBatch, GitHubPullRequestCliError>; | |
| 501 | ||
| 502 | /** | |
| 503 | * The same listing for a whole host in one search. `limit` is the size of the slice across | |
| 504 | * all of the repositories rather than per repository, because that is what a search answers: | |
| 505 | * the newest rows of the lot, which is exactly the page. | |
| 506 | */ | |
| 507 | readonly searchPullRequests: (input: { | |
| 508 | /** Any checkout on the host; the search names its repositories itself. */ | |
| 509 | readonly cwd: string; | |
| 510 | readonly host: string; | |
| 511 | readonly repositories: ReadonlyArray<string>; | |
| 512 | readonly state: PullRequestListState; | |
| 513 | readonly involvement: PullRequestInvolvement; | |
| 514 | readonly viewer: string; | |
| 515 | readonly limit: number; | |
| 516 | readonly query?: string | undefined; | |
| 517 | readonly cursor?: ProviderListCursor | undefined; | |
| 518 | readonly filters?: PullRequestListFilters | undefined; | |
| 519 | }) => Effect.Effect<GitHubPullRequestSearchBatch, GitHubPullRequestCliError>; | |
| 520 | ||
| 521 | /** The line counts the search leaves out, for rows already on the page. */ | |
| 522 | readonly listPullRequestStats: (input: { | |
| 523 | readonly cwd: string; | |
| 524 | readonly host: string; | |
| 525 | readonly changeRequests: ReadonlyArray<{ | |
| 526 | readonly repository: string; | |
| 527 | readonly number: number; | |
| 528 | }>; | |
| 529 | }) => Effect.Effect<ReadonlyArray<GitHubPullRequestStat>, GitHubPullRequestCliError>; | |
| 530 | ||
| 531 | readonly getPullRequestSummary: (input: { | |
| 532 | readonly cwd: string; | |
| 533 | readonly repository: string; | |
| 534 | readonly host: string; | |
| 535 | readonly number: number; | |
| 536 | }) => Effect.Effect<ProviderChangeRequestSummary, GitHubPullRequestCliError>; | |
| 537 | /** | |
| 538 | * What a watch compares between passes, batched like summaries. Null when GitHub gave no | |
| 539 | * answer for this pull request, so the watch reads it in full instead. | |
| 540 | */ | |
| 541 | readonly getPullRequestWatchFingerprint: (input: { | |
| 542 | readonly cwd: string; | |
| 543 | readonly repository: string; | |
| 544 | readonly host: string; | |
| 545 | readonly number: number; | |
| 546 | }) => Effect.Effect<GitHubPullRequestWatchFingerprint | null, GitHubPullRequestCliError>; | |
| 547 | ||
| 548 | readonly revalidateChecks: Effect.Success<typeof makeChecksRevalidator>; | |
| 549 | ||
| 550 | readonly getPullRequestDetail: (input: { | |
| 551 | readonly cwd: string; | |
| 552 | readonly repository: string; | |
| 553 | readonly host: string; | |
| 554 | readonly number: number; | |
| 555 | }) => Effect.Effect<GitHubPullRequestCore, GitHubPullRequestCliError>; | |
| 556 | ||
| 557 | readonly getPullRequestPreview: (input: { | |
| 558 | readonly cwd: string; | |
| 559 | readonly repository: string; | |
| 560 | readonly host: string; | |
| 561 | readonly number: number; | |
| 562 | }) => Effect.Effect< | |
| 563 | Omit<PullRequestPreview, "projectId" | "repository">, | |
| 564 | GitHubPullRequestCliError | |
| 565 | >; | |
| 566 | ||
| 567 | readonly listWorkflowRunsRequiringApproval: (input: { | |
| 568 | readonly cwd: string; | |
| 569 | readonly repository: string; | |
| 570 | readonly host: string; | |
| 571 | readonly number: number; | |
| 572 | readonly headSha: string; | |
| 573 | readonly headBranch: string; | |
| 574 | readonly headRepositoryOwner: string; | |
| 575 | readonly isCrossRepository: true; | |
| 576 | }) => Effect.Effect<ReadonlyArray<GitHubWorkflowRunApproval>, GitHubPullRequestCliError>; | |
| 577 | ||
| 578 | /** | |
| 579 | * The host-native stack this pull request is in, or null when it is in none — which is also | |
| 580 | * the answer for a host that refuses the stacks preview altogether. | |
| 581 | */ | |
| 582 | readonly getPullRequestStack: (input: { | |
| 583 | readonly cwd: string; | |
| 584 | readonly includeDetails?: boolean; | |
| 585 | readonly repository: string; | |
| 586 | readonly host: string; | |
| 587 | readonly number: number; | |
| 588 | }) => Effect.Effect<GitHubPullRequestStack | null, GitHubPullRequestCliError>; | |
| 589 | ||
| 590 | readonly getPullRequestActivity: (input: { | |
| 591 | readonly cwd: string; | |
| 592 | readonly repository: string; | |
| 593 | readonly host: string; | |
| 594 | readonly number: number; | |
| 595 | }) => Effect.Effect<GitHubPullRequestActivity, GitHubPullRequestCliError>; | |
| 596 | ||
| 597 | readonly getPullRequestDiff: (input: { | |
| 598 | readonly cwd: string; | |
| 599 | readonly repository: string; | |
| 600 | readonly host: string; | |
| 601 | readonly number: number; | |
| 602 | /** Absent asks for the first slice; anything else is a cursor a slice handed back. */ | |
| 603 | readonly cursor?: string | undefined; | |
| 604 | /** One commit's own changes, rather than everything the pull request carries. */ | |
| 605 | readonly commit?: string | undefined; | |
| 606 | }) => Effect.Effect<GitHubPullRequestDiffSlice, GitHubPullRequestCliError>; | |
| 607 | ||
| 608 | readonly getPullRequestDiffFileContents: (input: { | |
| 609 | readonly cwd: string; | |
| 610 | readonly repository: string; | |
| 611 | readonly host: string; | |
| 612 | readonly number: number; | |
| 613 | readonly commit?: string | undefined; | |
| 614 | readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; | |
| 615 | readonly oldPath: string; | |
| 616 | readonly newPath: string; | |
| 617 | }) => Effect.Effect< | |
| 618 | { readonly oldContents: string; readonly newContents: string }, | |
| 619 | GitHubPullRequestCliError | |
| 620 | >; | |
| 621 | ||
| 622 | /** | |
| 623 | * Which files of the pull request the signed-in account has cleared, and which of those have | |
| 624 | * been pushed to since. Read apart from the patch because GitHub only reports it over GraphQL. | |
| 625 | */ | |
| 626 | readonly getPullRequestFilesViewed: (input: { | |
| 627 | readonly cwd: string; | |
| 628 | readonly repository: string; | |
| 629 | readonly host: string; | |
| 630 | readonly number: number; | |
| 631 | }) => Effect.Effect<GitHubPullRequestFilesViewed, GitHubPullRequestCliError>; | |
| 632 | ||
| 633 | /** | |
| 634 | * Clears files, or puts them back, as one request. GitHub takes a single path per mutation, | |
| 635 | * so a burst is batched with aliases into one document rather than one subprocess per press. | |
| 636 | */ | |
| 637 | readonly setPullRequestFilesViewed: (input: { | |
| 638 | readonly cwd: string; | |
| 639 | readonly repository: string; | |
| 640 | readonly host: string; | |
| 641 | readonly number: number; | |
| 642 | readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>; | |
| 643 | }) => Effect.Effect<void, GitHubPullRequestCliError>; | |
| 644 | ||
| 645 | readonly listReviewThreadComments: (input: { | |
| 646 | readonly cwd: string; | |
| 647 | readonly repository: string; | |
| 648 | readonly host: string; | |
| 649 | readonly number: number; | |
| 650 | }) => Effect.Effect<GitHubReviewThreadComments, GitHubPullRequestCliError>; | |
| 651 | ||
| 652 | /** One request for a listing's authors, since no `gh` JSON field reports an avatar. */ | |
| 653 | readonly listActorAvatars: (input: { | |
| 654 | readonly cwd: string; | |
| 655 | readonly repository: string; | |
| 656 | readonly host: string; | |
| 657 | readonly ids: ReadonlyArray<string>; | |
| 658 | }) => Effect.Effect<ReadonlyMap<string, string>, GitHubPullRequestCliError>; | |
| 659 | ||
| 660 | readonly getReviewThreadComments: (input: { | |
| 661 | readonly cwd: string; | |
| 662 | readonly repository: string; | |
| 663 | readonly host: string; | |
| 664 | readonly number: number; | |
| 665 | readonly threadId: string; | |
| 666 | readonly cursor: string; | |
| 667 | }) => Effect.Effect<PullRequestThreadCommentsResult, GitHubPullRequestCliError>; | |
| 668 | ||
| 669 | /** The viewer's standing on its own, for deciding a write without reading the whole detail. */ | |
| 670 | readonly getViewerAccess: (input: { | |
| 671 | readonly cwd: string; | |
| 672 | readonly repository: string; | |
| 673 | readonly host: string; | |
| 674 | readonly number: number; | |
| 675 | /** Manual action checks may use the quota held back from automatic reads. */ | |
| 676 | readonly allowReserve?: boolean | undefined; | |
| 677 | }) => Effect.Effect<GitHubViewerAccess & GitHubRepositoryAccess, GitHubPullRequestCliError>; | |
| 678 | ||
| 679 | /** Who this pull request may be sent to, and who it has already been sent to. */ | |
| 680 | readonly listReviewerCandidates: (input: { | |
| 681 | readonly cwd: string; | |
| 682 | readonly repository: string; | |
| 683 | readonly host: string; | |
| 684 | readonly number: number; | |
| 685 | }) => Effect.Effect<PullRequestReviewerCandidateList, GitHubPullRequestCliError>; | |
| 686 | ||
| 687 | readonly setReviewerRequest: (input: { | |
| 688 | readonly cwd: string; | |
| 689 | readonly repository: string; | |
| 690 | readonly host: string; | |
| 691 | readonly number: number; | |
| 692 | readonly reviewers: ReadonlyArray<{ | |
| 693 | readonly id: string; | |
| 694 | readonly kind: PullRequestReviewerKind; | |
| 695 | }>; | |
| 696 | /** False deletes the same collection a request posts to, which takes the request back. */ | |
| 697 | readonly requested: boolean; | |
| 698 | }) => Effect.Effect<void, GitHubPullRequestCliError>; | |
| 699 | ||
| 700 | /** The repository's labels, and which of them this pull request already wears. */ | |
| 701 | readonly listLabelCandidates: (input: { | |
| 702 | readonly cwd: string; | |
| 703 | readonly repository: string; | |
| 704 | readonly host: string; | |
| 705 | readonly number: number; | |
| 706 | }) => Effect.Effect<PullRequestLabelCandidateList, GitHubPullRequestCliError>; | |
| 707 | ||
| 708 | readonly setLabels: (input: { | |
| 709 | readonly cwd: string; | |
| 710 | readonly repository: string; | |
| 711 | readonly host: string; | |
| 712 | readonly number: number; | |
| 713 | readonly labels: ReadonlyArray<string>; | |
| 714 | /** False takes each label off; true adds each to whatever is already there. */ | |
| 715 | readonly applied: boolean; | |
| 716 | }) => Effect.Effect<void, GitHubPullRequestCliError>; | |
| 717 | ||
| 718 | readonly runPullRequestAction: (input: { | |
| 719 | readonly cwd: string; | |
| 720 | readonly repository: string; | |
| 721 | readonly host: string; | |
| 722 | readonly number: number; | |
| 723 | readonly action: PullRequestAction; | |
| 724 | readonly stackNumber?: number; | |
| 725 | readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>; | |
| 726 | readonly removeAgentCreditsOnMerge?: boolean; | |
| 727 | readonly mergeMethod?: PullRequestMergeMethod; | |
| 728 | readonly updateMethod?: PullRequestUpdateMethod; | |
| 729 | }) => Effect.Effect<void, GitHubPullRequestCliError>; | |
| 730 | ||
| 731 | readonly commentOnPullRequest: (input: { | |
| 732 | readonly cwd: string; | |
| 733 | readonly repository: string; | |
| 734 | readonly host: string; | |
| 735 | readonly number: number; | |
| 736 | readonly body: string; | |
| 737 | }) => Effect.Effect<void, GitHubPullRequestCliError>; | |
| 738 | ||
| 739 | readonly submitReview: (input: { | |
| 740 | readonly cwd: string; | |
| 741 | readonly repository: string; | |
| 742 | readonly host: string; | |
| 743 | readonly number: number; | |
| 744 | readonly verdict: PullRequestReviewVerdict; | |
| 745 | readonly body: string; | |
| 746 | readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>; | |
| 747 | }) => Effect.Effect<void, GitHubPullRequestCliError>; | |
| 748 | ||
| 749 | readonly replyToReviewThread: (input: { | |
| 750 | readonly cwd: string; | |
| 751 | readonly repository: string; | |
| 752 | readonly host: string; | |
| 753 | readonly threadId: string; | |
| 754 | readonly body: string; | |
| 755 | }) => Effect.Effect<void, GitHubPullRequestCliError>; | |
| 756 | ||
| 757 | readonly setReviewThreadResolution: (input: { | |
| 758 | readonly cwd: string; | |
| 759 | readonly repository: string; | |
| 760 | readonly host: string; | |
| 761 | readonly threadId: string; | |
| 762 | readonly resolved: boolean; | |
| 763 | }) => Effect.Effect<void, GitHubPullRequestCliError>; | |
| 764 | ||
| 765 | /** | |
| 766 | * Adds a reaction to a remark, or takes it back. `subjectId` is any node GitHub calls | |
| 767 | * reactable — a comment, a review, or the pull request itself, which is looked up here | |
| 768 | * because nothing in the conversation names it. A given `subjectId` is confirmed to belong | |
| 769 | * to this pull request before the mutation runs, since nothing else ties the two together. | |
| 770 | */ | |
| 771 | readonly setReaction: (input: { | |
| 772 | readonly cwd: string; | |
| 773 | readonly repository: string; | |
| 774 | readonly host: string; | |
| 775 | readonly number: number; | |
| 776 | readonly subjectId?: string | undefined; | |
| 777 | readonly content: PullRequestReactionContent; | |
| 778 | readonly reacted: boolean; | |
| 779 | }) => Effect.Effect<void, GitHubPullRequestCliError>; | |
| 780 | ||
| 781 | /** Rewrites the pull request's own words, leaving whichever of the two was not given. */ | |
| 782 | readonly updatePullRequest: (input: { | |
| 783 | readonly cwd: string; | |
| 784 | readonly repository: string; | |
| 785 | readonly host: string; | |
| 786 | readonly number: number; | |
| 787 | readonly title?: string | undefined; | |
| 788 | readonly body?: string | undefined; | |
| 789 | }) => Effect.Effect<void, GitHubPullRequestCliError>; | |
| 790 | ||
| 791 | /** | |
| 792 | * Rewrites a remark. `commentId` is trusted to be whatever node it names, so it is confirmed | |
| 793 | * to belong to this pull request before the mutation runs, the way a reaction subject is. | |
| 794 | * Whether the remark is the reader's to rewrite is GitHub's own answer, not one asked here. | |
| 795 | */ | |
| 796 | readonly updateComment: (input: { | |
| 797 | readonly cwd: string; | |
| 798 | readonly repository: string; | |
| 799 | readonly host: string; | |
| 800 | readonly number: number; | |
| 801 | readonly commentId: string; | |
| 802 | readonly kind: "issue-comment" | "review-comment"; | |
| 803 | readonly body: string; | |
| 804 | }) => Effect.Effect<void, GitHubPullRequestCliError>; | |
| 805 | } | |
| 806 | >()("t3/pullRequest/GitHubPullRequestCli") {} | |
| 807 | ||
| 808 | /** | |
| 809 | * The GraphQL API takes owner and name as separate arguments, so `owner/repo` is split here. | |
| 810 | * The host is not read off the identity: it travels alongside it, because the identity a | |
| 811 | * project records is the path below its host and never names the host itself. | |
| 812 | */ | |
| 813 | function parseRepositorySelector(value: string): { | |
| 814 | readonly owner: string; | |
| 815 | readonly name: string; | |
| 816 | } { | |
| 817 | const parts = value.trim().split("/").filter(Boolean); | |
| 818 | return { name: parts.at(-1) ?? "", owner: parts.at(-2) ?? "" }; | |
| 819 | } | |
| 820 | ||
| 821 | /** | |
| 822 | * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor | |
| 823 | * arrives from the reader as a string and goes straight into a request path, so it is parsed | |
| 824 | * rather than trusted; the length bound keeps a page number out of exponential notation. | |
| 825 | */ | |
| 826 | function diffCursorPage(cursor: string): number | null { | |
| 827 | return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null; | |
| 828 | } | |
| 829 | ||
| 830 | /** | |
| 831 | * A commit sha arrives from the reader and goes straight into a request path, so it is checked | |
| 832 | * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a | |
| 833 | * whole sha. | |
| 834 | */ | |
| 835 | function isCommitSha(value: string): boolean { | |
| 836 | return /^[0-9a-f]{7,64}$/i.test(value); | |
| 837 | } | |
| 838 | ||
| 839 | /** | |
| 840 | * The reader's own words as one literal phrase of a GitHub search query. Quoting is the whole | |
| 841 | * defence: outside quotes GitHub reads `is:merged` as a qualifier and `label:x` as another, so | |
| 842 | * text typed into a search box could widen the very listing it is meant to narrow — inside them | |
| 843 | * it is only text. The two characters that could end the phrase early are therefore escaped | |
| 844 | * first, which GitHub reads back as themselves; an unbalanced quote is dropped instead, which | |
| 845 | * would let everything after it out of the phrase. | |
| 846 | * | |
| 847 | * The phrase is one argv element, so nothing in it can become a flag of its own either. | |
| 848 | */ | |
| 849 | function searchPhrase(query: string): string { | |
| 850 | return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; | |
| 851 | } | |
| 852 | ||
| 853 | /** GitHub's own spelling of a review state, which is not the contract's. */ | |
| 854 | const REVIEW_QUALIFIERS = { | |
| 855 | approved: "approved", | |
| 856 | "changes-requested": "changes_requested", | |
| 857 | "review-required": "required", | |
| 858 | none: "none", | |
| 859 | } as const; | |
| 860 | ||
| 861 | /** | |
| 862 | * The extra narrowings as GitHub search qualifiers. Values a reader typed are quoted, and the | |
| 863 | * one character that could end the quoted value early is dropped rather than escaped: no GitHub | |
| 864 | * label or login holds a double quote, so there is nothing to preserve and everything to lose. | |
| 865 | */ | |
| 866 | function qualifierValue(value: string): string { | |
| 867 | return `"${value.replaceAll('"', "").trim()}"`; | |
| 868 | } | |
| 869 | ||
| 870 | function filterQualifiers( | |
| 871 | filters: PullRequestListFilters | undefined, | |
| 872 | viewer: string, | |
| 873 | ): ReadonlyArray<string> { | |
| 874 | if (filters === undefined) return []; | |
| 875 | return [ | |
| 876 | // One qualifier per group, its names joined by commas — GitHub's own OR. | |
| 877 | ...(filters.labels ?? []).flatMap((group) => | |
| 878 | group.length === 0 ? [] : [`label:${group.map(qualifierValue).join(",")}`], | |
| 879 | ), | |
| 880 | ...(filters.excludedLabels ?? []).map((label) => `-label:${qualifierValue(label)}`), | |
| 881 | ...(filters.author === undefined | |
| 882 | ? [] | |
| 883 | : [`author:${qualifierValue(resolvePullRequestAuthorFilter(filters.author, viewer))}`]), | |
| 884 | ...(filters.draft === undefined ? [] : [`draft:${filters.draft === "only"}`]), | |
| 885 | ...(filters.review === undefined ? [] : [`review:${REVIEW_QUALIFIERS[filters.review]}`]), | |
| 886 | ...(filters.checks === undefined | |
| 887 | ? [] | |
| 888 | : [`status:${filters.checks === "passing" ? "success" : "failure"}`]), | |
| 889 | ]; | |
| 890 | } | |
| 891 | ||
| 892 | /** | |
| 893 | * The same narrowings over a row that has already arrived, for the search-free fallback. Every | |
| 894 | * listed row now carries its own `checksState`, so `checks` is judged the way `review` is: by | |
| 895 | * equality against the row's field. Unlike `review`, `checks` has no `"none"` value to catch an | |
| 896 | * absent state on purpose — a row with no checks configured, or whose checks are still `pending`, | |
| 897 | * equals neither `"passing"` nor `"failing"` and so fails both, the same as a row search would | |
| 898 | * not have surfaced for `status:success` or `status:failure`. | |
| 899 | */ | |
| 900 | function matchesFilters( | |
| 901 | item: GitHubPullRequestListItem, | |
| 902 | filters: PullRequestListFilters | undefined, | |
| 903 | viewer: string, | |
| 904 | ): boolean { | |
| 905 | if (filters === undefined) return true; | |
| 906 | const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase())); | |
| 907 | const holds = (label: string) => labels.has(label.trim().toLowerCase()); | |
| 908 | return ( | |
| 909 | (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && | |
| 910 | (filters.review === undefined || | |
| 911 | (filters.review === "none" | |
| 912 | ? item.reviewDecision === null | |
| 913 | : item.reviewDecision === filters.review)) && | |
| 914 | (filters.checks === undefined || item.checksState === filters.checks) && | |
| 915 | (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) && | |
| 916 | (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) && | |
| 917 | (filters.author === undefined || | |
| 918 | item.author?.login.toLowerCase() === | |
| 919 | resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase()) | |
| 920 | ); | |
| 921 | } | |
| 922 | ||
| 923 | /** The search-free fallback is wider than the request, so narrow its decoded rows locally. */ | |
| 924 | function matchesUnsortedListing( | |
| 925 | item: GitHubPullRequestListItem, | |
| 926 | input: { | |
| 927 | readonly state: PullRequestListState; | |
| 928 | readonly involvement: PullRequestInvolvement; | |
| 929 | readonly viewer: string; | |
| 930 | readonly filters?: PullRequestListFilters | undefined; | |
| 931 | }, | |
| 932 | ): boolean { | |
| 933 | const matchesState = input.state === "all" || item.state === input.state; | |
| 934 | const viewer = input.viewer.toLowerCase(); | |
| 935 | const matchesInvolvement = | |
| 936 | input.involvement === "all" || | |
| 937 | (input.involvement === "authored" | |
| 938 | ? item.author?.login.toLowerCase() === viewer | |
| 939 | : item.hasTeamReviewRequest || | |
| 940 | item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer)); | |
| 941 | return matchesState && matchesInvolvement && matchesFilters(item, input.filters, input.viewer); | |
| 942 | } | |
| 943 | ||
| 944 | /** What a repository selector may hold before it goes into a search as itself. */ | |
| 945 | const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; | |
| 946 | ||
| 947 | /** | |
| 948 | * A listing as one GitHub search, across one repository or several: the only way to read a | |
| 949 | * whole host in one request, and the only order (`updated`) a continuation can carry on from. | |
| 950 | * | |
| 951 | * `--state closed` in GitHub's own list includes merged pull requests, so the Closed tab is | |
| 952 | * `is:closed is:unmerged` here. | |
| 953 | * | |
| 954 | * Null where a repository is not `owner/name`. A name is written into the query as itself, and a | |
| 955 | * name holding a space could otherwise end the `repo:` qualifier and start a qualifier of its | |
| 956 | * own — so an unaddressable one refuses the whole read rather than being escaped into something | |
| 957 | * GitHub might still read. | |
| 958 | */ | |
| 959 | function searchQuery(input: { | |
| 960 | readonly repositories: ReadonlyArray<string>; | |
| 961 | readonly state: PullRequestListState; | |
| 962 | readonly involvement: PullRequestInvolvement; | |
| 963 | readonly viewer: string; | |
| 964 | readonly query?: string | undefined; | |
| 965 | readonly cursor?: ProviderListCursor | undefined; | |
| 966 | readonly filters?: PullRequestListFilters | undefined; | |
| 967 | }): string | null { | |
| 968 | if (input.repositories.length === 0) return null; | |
| 969 | const repositories = input.repositories.map((repository) => repository.trim()); | |
| 970 | if (!repositories.every((repository) => SEARCH_REPOSITORY.test(repository))) return null; | |
| 971 | const query = input.query?.trim() ?? ""; | |
| 972 | return [ | |
| 973 | "is:pr", | |
| 974 | // "all" is every state, which `is:pr` already is. | |
| 975 | ...(input.state === "open" ? ["is:open"] : []), | |
| 976 | ...(input.state === "closed" ? ["is:closed", "is:unmerged"] : []), | |
| 977 | ...(input.state === "merged" ? ["is:merged"] : []), | |
| 978 | ...(input.involvement === "authored" ? [`author:${input.viewer}`] : []), | |
| 979 | ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []), | |
| 980 | ...(query.length === 0 ? [] : [searchPhrase(query)]), | |
| 981 | // Inclusive, and de-duplicated by the caller, for the reason the per-repository read gives. | |
| 982 | ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]), | |
| 983 | ...filterQualifiers(input.filters, input.viewer), | |
| 984 | // The order the page reads its rows in, and the only order a continuation can carry on from. | |
| 985 | "sort:updated-desc", | |
| 986 | ...repositories.map((repository) => `repo:${repository}`), | |
| 987 | ].join(" "); | |
| 988 | } | |
| 989 | ||
| 990 | const MERGE_MESSAGE_GRAPHQL_QUERY = ` | |
| 991 | query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) { | |
| 992 | repository(owner: $owner, name: $name) { | |
| 993 | pullRequest(number: $number) { | |
| 994 | isMergeQueueEnabled | |
| 995 | headRefOid | |
| 996 | viewerMergeBodyText(mergeType: $method) | |
| 997 | } | |
| 998 | } | |
| 999 | }`; | |
| 1000 | ||
| 1001 | /** The two revisions a file is expanded between: a pull request's base and head, or a commit's parent and itself. */ | |
| 1002 | const decodeRevisionRefs = Schema.decodeUnknownOption( | |
| 1003 | Schema.fromJsonString( | |
| 1004 | Schema.Struct({ | |
| 1005 | sha: Schema.optional(Schema.String), | |
| 1006 | parents: Schema.optional(Schema.Array(Schema.Struct({ sha: Schema.String }))), | |
| 1007 | base: Schema.optional(Schema.Struct({ sha: Schema.String })), | |
| 1008 | head: Schema.optional(Schema.Struct({ sha: Schema.String })), | |
| 1009 | }), | |
| 1010 | ), | |
| 1011 | ); | |
| 1012 | ||
| 1013 | const decodeMergeMessageResponse = Schema.decodeUnknownResult( | |
| 1014 | Schema.fromJsonString( | |
| 1015 | Schema.Struct({ | |
| 1016 | data: Schema.Struct({ | |
| 1017 | repository: Schema.Struct({ | |
| 1018 | pullRequest: Schema.Struct({ | |
| 1019 | isMergeQueueEnabled: Schema.Boolean, | |
| 1020 | headRefOid: Schema.String, | |
| 1021 | viewerMergeBodyText: Schema.String, | |
| 1022 | }), | |
| 1023 | }), | |
| 1024 | }), | |
| 1025 | }), | |
| 1026 | ), | |
| 1027 | ); | |
| 1028 | const decodeMergeMessage = (raw: string) => | |
| 1029 | Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest); | |
| 1030 | ||
| 1031 | /** What `gh pr merge` and `gh pr update-branch` read before they act. */ | |
| 1032 | const ACTION_STATE_GRAPHQL_QUERY = ` | |
| 1033 | query PullRequestActionState($owner: String!, $name: String!, $number: Int!, $headRef: String!) { | |
| 1034 | repository(owner: $owner, name: $name) { | |
| 1035 | pullRequest(number: $number) { | |
| 1036 | id | |
| 1037 | headRefOid | |
| 1038 | isMergeQueueEnabled | |
| 1039 | mergeStateStatus | |
| 1040 | baseRef { compare(headRef: $headRef) { behindBy } } | |
| 1041 | } | |
| 1042 | } | |
| 1043 | }`; | |
| 1044 | ||
| 1045 | const decodeActionStateResponse = Schema.decodeUnknownResult( | |
| 1046 | Schema.fromJsonString( | |
| 1047 | Schema.Struct({ | |
| 1048 | data: Schema.Struct({ | |
| 1049 | repository: Schema.Struct({ | |
| 1050 | pullRequest: Schema.Struct({ | |
| 1051 | id: Schema.String, | |
| 1052 | headRefOid: Schema.String, | |
| 1053 | isMergeQueueEnabled: Schema.optional(Schema.Boolean), | |
| 1054 | mergeStateStatus: Schema.optional(Schema.NullOr(Schema.String)), | |
| 1055 | baseRef: Schema.optional( | |
| 1056 | Schema.NullOr( | |
| 1057 | Schema.Struct({ compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })) }), | |
| 1058 | ), | |
| 1059 | ), | |
| 1060 | }), | |
| 1061 | }), | |
| 1062 | }), | |
| 1063 | }), | |
| 1064 | ), | |
| 1065 | ); | |
| 1066 | const decodeActionState = (raw: string) => | |
| 1067 | Result.map(decodeActionStateResponse(raw), (response) => response.data.repository.pullRequest); | |
| 1068 | ||
| 1069 | const MERGE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($input: MergePullRequestInput!) { | |
| 1070 | mergePullRequest(input: $input) { clientMutationId } | |
| 1071 | }`; | |
| 1072 | const ENABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($input: EnablePullRequestAutoMergeInput!) { | |
| 1073 | enablePullRequestAutoMerge(input: $input) { clientMutationId } | |
| 1074 | }`; | |
| 1075 | const DISABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1076 | disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1077 | }`; | |
| 1078 | const UPDATE_BRANCH_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!, $updateMethod: PullRequestBranchUpdateMethod!) { | |
| 1079 | updatePullRequestBranch(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid, updateMethod: $updateMethod }) { clientMutationId } | |
| 1080 | }`; | |
| 1081 | const READY_FOR_REVIEW_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1082 | markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1083 | }`; | |
| 1084 | const CONVERT_TO_DRAFT_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1085 | convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1086 | }`; | |
| 1087 | const CLOSE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1088 | closePullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1089 | }`; | |
| 1090 | const REOPEN_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 1091 | reopenPullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } | |
| 1092 | }`; | |
| 1093 | const ADD_COMMENT_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $body: String!) { | |
| 1094 | addComment(input: { subjectId: $subjectId, body: $body }) { clientMutationId } | |
| 1095 | }`; | |
| 1096 | ||
| 1097 | const GRAPHQL_MERGE_METHODS = { | |
| 1098 | merge: "MERGE", | |
| 1099 | squash: "SQUASH", | |
| 1100 | rebase: "REBASE", | |
| 1101 | } as const satisfies Record<PullRequestMergeMethod, string>; | |
| 1102 | ||
| 1103 | /** States in which `gh pr merge --auto` merges at once instead of arming auto-merge. */ | |
| 1104 | const IMMEDIATELY_MERGEABLE = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]); | |
| 1105 | ||
| 1106 | /** The mutations that only need the pull request's node id. */ | |
| 1107 | const SIMPLE_ACTION_MUTATIONS = { | |
| 1108 | "disable-auto-merge": DISABLE_AUTO_MERGE_GRAPHQL_MUTATION, | |
| 1109 | ready: READY_FOR_REVIEW_GRAPHQL_MUTATION, | |
| 1110 | draft: CONVERT_TO_DRAFT_GRAPHQL_MUTATION, | |
| 1111 | close: CLOSE_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 1112 | reopen: REOPEN_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 1113 | } as const satisfies Partial<Record<PullRequestAction, string>>; | |
| 1114 | ||
| 1115 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 1116 | export const make = Effect.gen(function* () { | |
| 1117 | const api = yield* GitHubApi.GitHubApi; | |
| 1118 | const vcsProcess = yield* VcsProcess.VcsProcess; | |
| 1119 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1120 | const revalidateChecks = yield* makeChecksRevalidator; | |
| 1121 | const routingIdentities = new Map< | |
| 1122 | string, | |
| 1123 | { | |
| 1124 | at: number; | |
| 1125 | value: { accountId: string; viewer: string }; | |
| 1126 | } | |
| 1127 | >(); | |
| 1128 | const identityLocks = new Map<string, { gate: Semaphore.Semaphore; users: number }>(); | |
| 1129 | const decodeRoutingIdentity = Schema.decodeUnknownEffect( | |
| 1130 | Schema.fromJsonString( | |
| 1131 | Schema.Struct({ | |
| 1132 | id: PositiveInt, | |
| 1133 | login: TrimmedNonEmptyString, | |
| 1134 | }), | |
| 1135 | ), | |
| 1136 | ); | |
| 1137 | const captureVerifiedCredential = Effect.fn("GitHubPullRequestCli.captureVerifiedCredential")( | |
| 1138 | function* (input: { readonly cwd: string; readonly host: string }) { | |
| 1139 | const unavailable = () => | |
| 1140 | new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd }); | |
| 1141 | const host = input.host.toLowerCase(); | |
| 1142 | // A missing or signed-out credential keeps its own error, so the page can say which. | |
| 1143 | const { token, fingerprint: key } = yield* api.credential(host); | |
| 1144 | const credential = { host, token, credentialFingerprint: key }; | |
| 1145 | // A cold page may ask several times. Wait per credential and check again after the | |
| 1146 | // first verification; cancellation releases the next waiter without losing its request. | |
| 1147 | return yield* Effect.acquireUseRelease( | |
| 1148 | Effect.sync(() => { | |
| 1149 | const lock = identityLocks.get(key) ?? { gate: Semaphore.makeUnsafe(1), users: 0 }; | |
| 1150 | lock.users++; | |
| 1151 | identityLocks.set(key, lock); | |
| 1152 | return lock; | |
| 1153 | }), | |
| 1154 | (lock) => | |
| 1155 | lock.gate.withPermit( | |
| 1156 | Effect.gen(function* () { | |
| 1157 | const now = yield* Clock.currentTimeMillis; | |
| 1158 | const cached = routingIdentities.get(key); | |
| 1159 | if (cached !== undefined && now - cached.at < 10 * 60_000) | |
| 1160 | return { ...credential, ...cached.value }; | |
| 1161 | // Pin this read so an auth switch cannot poison its cache entry. | |
| 1162 | const response = yield* api | |
| 1163 | .rest({ host, operation: "getRoutingIdentity", path: "user", allowReserve: true }) | |
| 1164 | .pipe( | |
| 1165 | Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), | |
| 1166 | Effect.provideService(SourceControlRateLimit.CredentialScope, key), | |
| 1167 | ); | |
| 1168 | const identity = yield* decodeRoutingIdentity(response.body).pipe( | |
| 1169 | Effect.mapError(unavailable), | |
| 1170 | ); | |
| 1171 | const value = { accountId: String(identity.id), viewer: identity.login }; | |
| 1172 | if (routingIdentities.size >= 128) | |
| 1173 | routingIdentities.delete(routingIdentities.keys().next().value!); | |
| 1174 | routingIdentities.set(key, { at: now, value }); | |
| 1175 | return { ...credential, ...value }; | |
| 1176 | }), | |
| 1177 | ), | |
| 1178 | (lock) => | |
| 1179 | Effect.sync(() => { | |
| 1180 | lock.users--; | |
| 1181 | if (lock.users === 0) identityLocks.delete(key); | |
| 1182 | }), | |
| 1183 | ); | |
| 1184 | }, | |
| 1185 | ); | |
| 1186 | const withVerifiedCredential: GitHubPullRequestCli["Service"]["withVerifiedCredential"] = ( | |
| 1187 | input, | |
| 1188 | use, | |
| 1189 | ) => | |
| 1190 | captureVerifiedCredential(input).pipe( | |
| 1191 | Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) => | |
| 1192 | use({ accountId, viewer, credentialFingerprint }).pipe( | |
| 1193 | Effect.provideService(SourceControlRateLimit.CredentialScope, credentialFingerprint), | |
| 1194 | Effect.provideService(GitHubApi.PinnedGitHubCredential, { | |
| 1195 | host, | |
| 1196 | token, | |
| 1197 | credentialFingerprint, | |
| 1198 | }), | |
| 1199 | ), | |
| 1200 | ), | |
| 1201 | ); | |
| 1202 | const getRoutingIdentity: GitHubPullRequestCli["Service"]["getRoutingIdentity"] = (input) => | |
| 1203 | captureVerifiedCredential(input).pipe( | |
| 1204 | Effect.map(({ accountId, viewer }) => ({ accountId, viewer })), | |
| 1205 | ); | |
| 1206 | ||
| 1207 | /** | |
| 1208 | * The pull request's own node id, which is what a mutation against the pull request itself is | |
| 1209 | * addressed by: a reaction on its description, or a rewrite of its words. | |
| 1210 | * | |
| 1211 | * A pull request keeps its node id for life, so it is remembered rather than re-read: a reader | |
| 1212 | * ticking files viewed would otherwise pay a GraphQL round trip per press. | |
| 1213 | */ | |
| 1214 | const nodeIds = new Map<string, string>(); | |
| 1215 | ||
| 1216 | const pullRequestNodeId = (input: { | |
| 1217 | readonly cwd: string; | |
| 1218 | readonly repository: string; | |
| 1219 | readonly host: string; | |
| 1220 | readonly number: number; | |
| 1221 | readonly operation: string; | |
| 1222 | }): Effect.Effect<string, GitHubPullRequestCliError> => { | |
| 1223 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1224 | const key = `${input.host} ${owner}/${name} ${input.number}`; | |
| 1225 | const held = nodeIds.get(key); | |
| 1226 | if (held !== undefined) { | |
| 1227 | // Put back at the end on every hit, so what falls out is the pull request nobody has looked | |
| 1228 | // at rather than the one being ticked through: a run of cold reads would otherwise evict the | |
| 1229 | // open review and make it pay a round trip per press. | |
| 1230 | nodeIds.delete(key); | |
| 1231 | nodeIds.set(key, held); | |
| 1232 | return Effect.succeed(held); | |
| 1233 | } | |
| 1234 | return graphqlRead({ | |
| 1235 | cwd: input.cwd, | |
| 1236 | host: input.host, | |
| 1237 | operation: input.operation, | |
| 1238 | allowReserve: true, | |
| 1239 | variables: { owner, name, number: input.number }, | |
| 1240 | query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, | |
| 1241 | decode: decodePullRequestNodeIdJson, | |
| 1242 | }).pipe( | |
| 1243 | Effect.tap((nodeId) => | |
| 1244 | Effect.sync(() => { | |
| 1245 | if (nodeIds.size >= NODE_ID_CACHE_CAPACITY) { | |
| 1246 | const oldest = nodeIds.keys().next().value; | |
| 1247 | if (oldest !== undefined) nodeIds.delete(oldest); | |
| 1248 | } | |
| 1249 | nodeIds.set(key, nodeId); | |
| 1250 | }), | |
| 1251 | ), | |
| 1252 | ); | |
| 1253 | }; | |
| 1254 | ||
| 1255 | /** | |
| 1256 | * Whether a client-given subject actually belongs to the pull request the request names. A | |
| 1257 | * subject id is trusted to be whatever node it names, and that node can hang off any pull | |
| 1258 | * request on the host — so the mutation itself would write wherever the id actually belongs, | |
| 1259 | * not wherever the request says it does, unless this confirms the two agree first. | |
| 1260 | */ | |
| 1261 | const subjectBelongsToPullRequest = (input: { | |
| 1262 | readonly cwd: string; | |
| 1263 | readonly repository: string; | |
| 1264 | readonly host: string; | |
| 1265 | readonly number: number; | |
| 1266 | readonly subjectId: string; | |
| 1267 | readonly operation: string; | |
| 1268 | }) => { | |
| 1269 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1270 | return graphqlRead({ | |
| 1271 | cwd: input.cwd, | |
| 1272 | host: input.host, | |
| 1273 | operation: input.operation, | |
| 1274 | allowReserve: true, | |
| 1275 | variables: { owner, name, number: input.number, subjectId: input.subjectId }, | |
| 1276 | query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, | |
| 1277 | decode: decodeReactionSubjectScopeJson, | |
| 1278 | }); | |
| 1279 | }; | |
| 1280 | ||
| 1281 | /** A GraphQL mutation whose answer is not read back; a GraphQL error fails it. */ | |
| 1282 | const graphql = (input: { | |
| 1283 | readonly host: string; | |
| 1284 | readonly operation: string; | |
| 1285 | readonly query: string; | |
| 1286 | readonly variables: Readonly<Record<string, unknown>>; | |
| 1287 | }) => api.graphql(input).pipe(Effect.asVoid); | |
| 1288 | ||
| 1289 | const readError = (cwd: string, operation: string, cause: unknown) => | |
| 1290 | new GitHubPullRequestReadError({ command: "gh", cwd, operation, cause }); | |
| 1291 | ||
| 1292 | /** Decodes an answer, reporting a failure against the read that made it. */ | |
| 1293 | const decodeWith = <A>( | |
| 1294 | cwd: string, | |
| 1295 | operation: string, | |
| 1296 | decode: (raw: string) => Result.Result<A, unknown>, | |
| 1297 | raw: string, | |
| 1298 | ): Effect.Effect<A, GitHubPullRequestReadError> => { | |
| 1299 | const decoded = decode(raw.trim()); | |
| 1300 | return Result.isSuccess(decoded) | |
| 1301 | ? Effect.succeed(decoded.success) | |
| 1302 | : Effect.fail(readError(cwd, operation, decoded.failure)); | |
| 1303 | }; | |
| 1304 | ||
| 1305 | /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */ | |
| 1306 | const graphqlRead = <A>(input: { | |
| 1307 | readonly cwd: string; | |
| 1308 | readonly host: string; | |
| 1309 | readonly operation: string; | |
| 1310 | readonly allowReserve?: boolean | undefined; | |
| 1311 | readonly variables?: Readonly<Record<string, unknown>>; | |
| 1312 | readonly query: string; | |
| 1313 | readonly decode: (raw: string) => Result.Result<A, unknown>; | |
| 1314 | }): Effect.Effect<A, GitHubPullRequestCliError> => | |
| 1315 | api | |
| 1316 | .graphql({ | |
| 1317 | host: input.host, | |
| 1318 | operation: input.operation, | |
| 1319 | query: input.query, | |
| 1320 | ...(input.variables === undefined ? {} : { variables: input.variables }), | |
| 1321 | ...(input.allowReserve === true ? { allowReserve: true } : {}), | |
| 1322 | }) | |
| 1323 | .pipe(Effect.flatMap((raw) => decodeWith(input.cwd, input.operation, input.decode, raw))); | |
| 1324 | ||
| 1325 | /** A REST read whose JSON answer is decoded the same way. */ | |
| 1326 | const restRead = <A>(input: { | |
| 1327 | readonly cwd: string; | |
| 1328 | readonly host: string; | |
| 1329 | readonly operation: string; | |
| 1330 | readonly path: string; | |
| 1331 | readonly decode: (raw: string) => Result.Result<A, unknown>; | |
| 1332 | }): Effect.Effect<A, GitHubPullRequestCliError> => | |
| 1333 | api | |
| 1334 | .rest({ host: input.host, operation: input.operation, path: input.path }) | |
| 1335 | .pipe( | |
| 1336 | Effect.flatMap((response) => | |
| 1337 | decodeWith(input.cwd, input.operation, input.decode, response.body), | |
| 1338 | ), | |
| 1339 | ); | |
| 1340 | ||
| 1341 | /** | |
| 1342 | * One page of the patch, read from the files API. GitHub refuses a whole diff past 300 changed | |
| 1343 | * files, and still serves those files' hunks here. | |
| 1344 | * | |
| 1345 | * A page is a whole number of files, so each one parses on its own; the caller carries on from | |
| 1346 | * `nextCursor` for as long as GitHub keeps handing pages back. | |
| 1347 | * | |
| 1348 | * A named commit is read from the commit endpoint, which lists the same file entries and pages | |
| 1349 | * them the same way, only wrapped in an object. | |
| 1350 | */ | |
| 1351 | const diffFilesPage = (input: { | |
| 1352 | readonly cwd: string; | |
| 1353 | readonly repository: string; | |
| 1354 | readonly host: string; | |
| 1355 | readonly number: number; | |
| 1356 | readonly page: number; | |
| 1357 | readonly commit?: string | undefined; | |
| 1358 | }): Effect.Effect<GitHubPullRequestDiffSlice, GitHubPullRequestCliError> => { | |
| 1359 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1360 | const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`; | |
| 1361 | return api | |
| 1362 | .rest({ | |
| 1363 | host: input.host, | |
| 1364 | operation: "getPullRequestDiff", | |
| 1365 | path: | |
| 1366 | input.commit === undefined | |
| 1367 | ? `repos/${owner}/${name}/pulls/${input.number}/files?${paging}` | |
| 1368 | : `repos/${owner}/${name}/commits/${input.commit}?${paging}`, | |
| 1369 | maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, | |
| 1370 | timeout: DIFF_TIMEOUT, | |
| 1371 | }) | |
| 1372 | .pipe( | |
| 1373 | Effect.flatMap((response) => { | |
| 1374 | // Checked before decoding: a byte-truncated response is a JSON prefix, which would | |
| 1375 | // fail to parse. Nothing of this page can be shown, and an empty patch would render | |
| 1376 | // as a change with no files rather than as the failure it is; slices already handed | |
| 1377 | // over stay with the reader either way. | |
| 1378 | if (response.truncated) { | |
| 1379 | return Effect.fail( | |
| 1380 | readError( | |
| 1381 | input.cwd, | |
| 1382 | "getPullRequestDiff", | |
| 1383 | new Error(`Page ${input.page} of the changed files was too large to read.`), | |
| 1384 | ), | |
| 1385 | ); | |
| 1386 | } | |
| 1387 | return decodeWith( | |
| 1388 | input.cwd, | |
| 1389 | "getPullRequestDiff", | |
| 1390 | input.commit === undefined ? decodePullRequestFilesJson : decodeCommitFilesJson, | |
| 1391 | response.body, | |
| 1392 | ); | |
| 1393 | }), | |
| 1394 | Effect.map((files) => { | |
| 1395 | // Counted before decoding, so a page whose files all failed to decode still moves on | |
| 1396 | // rather than pointing the reader back at the page it just read. | |
| 1397 | const morePages = files.rawCount >= DIFF_FILES_PAGE_SIZE; | |
| 1398 | return { | |
| 1399 | patch: files.patch, | |
| 1400 | truncated: files.truncated, | |
| 1401 | nextCursor: morePages ? String(input.page + 1) : null, | |
| 1402 | ...(files.omittedFileStats.length === 0 | |
| 1403 | ? {} | |
| 1404 | : { omittedFileStats: files.omittedFileStats }), | |
| 1405 | }; | |
| 1406 | }), | |
| 1407 | ); | |
| 1408 | }; | |
| 1409 | ||
| 1410 | const getPullRequestDiffFileContents: GitHubPullRequestCli["Service"]["getPullRequestDiffFileContents"] = | |
| 1411 | (input) => | |
| 1412 | Effect.gen(function* () { | |
| 1413 | if (input.commit !== undefined && !isCommitSha(input.commit)) { | |
| 1414 | return yield* new GitHubDiffCommitError({ command: "gh", cwd: input.cwd }); | |
| 1415 | } | |
| 1416 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1417 | const refsResponse = yield* api.rest({ | |
| 1418 | host: input.host, | |
| 1419 | operation: "getPullRequestDiffFileContents", | |
| 1420 | path: | |
| 1421 | input.commit === undefined | |
| 1422 | ? `repos/${owner}/${name}/pulls/${input.number}` | |
| 1423 | : `repos/${owner}/${name}/commits/${input.commit}`, | |
| 1424 | timeout: DIFF_TIMEOUT, | |
| 1425 | }); | |
| 1426 | const refs = decodeRevisionRefs(refsResponse.body); | |
| 1427 | // A root commit has no parent, which is an absent old revision. Every file in it is new, | |
| 1428 | // so that is a usable answer whenever the caller does not need the old side. | |
| 1429 | const baseRef = Option.isSome(refs) | |
| 1430 | ? input.commit === undefined | |
| 1431 | ? refs.value.base?.sha | |
| 1432 | : (refs.value.parents?.[0]?.sha ?? "") | |
| 1433 | : undefined; | |
| 1434 | const headRef = Option.isSome(refs) | |
| 1435 | ? input.commit === undefined | |
| 1436 | ? refs.value.head?.sha | |
| 1437 | : refs.value.sha | |
| 1438 | : undefined; | |
| 1439 | const rootCommitNewFile = | |
| 1440 | input.commit !== undefined && input.changeType === "new" && baseRef === ""; | |
| 1441 | if ( | |
| 1442 | headRef === undefined || | |
| 1443 | (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) || | |
| 1444 | !isCommitSha(headRef) | |
| 1445 | ) { | |
| 1446 | return yield* new GitHubDiffRevisionsUnavailableError({ | |
| 1447 | command: "gh", | |
| 1448 | cwd: input.cwd, | |
| 1449 | number: input.number, | |
| 1450 | ...(input.commit === undefined ? {} : { commit: input.commit }), | |
| 1451 | }); | |
| 1452 | } | |
| 1453 | ||
| 1454 | const readFile = (revision: string, filePath: string) => | |
| 1455 | api | |
| 1456 | .rest({ | |
| 1457 | host: input.host, | |
| 1458 | operation: "getPullRequestDiffFileContents", | |
| 1459 | accept: "application/vnd.github.raw+json", | |
| 1460 | path: `repos/${owner}/${name}/contents/${filePath | |
| 1461 | .split("/") | |
| 1462 | .map(encodeURIComponent) | |
| 1463 | .join("/")}?ref=${encodeURIComponent(revision)}`, | |
| 1464 | maxResponseBytes: DIFF_FILE_MAX_OUTPUT_BYTES, | |
| 1465 | timeout: DIFF_TIMEOUT, | |
| 1466 | }) | |
| 1467 | .pipe( | |
| 1468 | Effect.flatMap((response) => | |
| 1469 | response.truncated || response.body.includes("\0") || response.invalidUtf8 | |
| 1470 | ? Effect.fail( | |
| 1471 | new GitHubDiffFileContentsUnavailableError({ | |
| 1472 | command: "gh", | |
| 1473 | cwd: input.cwd, | |
| 1474 | path: filePath, | |
| 1475 | reason: response.truncated ? "oversized" : "binary", | |
| 1476 | }), | |
| 1477 | ) | |
| 1478 | : Effect.succeed(response.body), | |
| 1479 | ), | |
| 1480 | ); | |
| 1481 | ||
| 1482 | const [oldContents, newContents] = yield* Effect.all( | |
| 1483 | [ | |
| 1484 | input.changeType === "new" | |
| 1485 | ? Effect.succeed("") | |
| 1486 | : readFile(baseRef ?? "", input.oldPath), | |
| 1487 | input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath), | |
| 1488 | ], | |
| 1489 | { concurrency: 2 }, | |
| 1490 | ); | |
| 1491 | return { oldContents, newContents }; | |
| 1492 | }); | |
| 1493 | ||
| 1494 | /** | |
| 1495 | * Every check context of the head commit, a page at a time, for a rollup the detail read cut at | |
| 1496 | * its first hundred. Each page names the head it read, so a push mid-walk fails rather than | |
| 1497 | * mixing two revisions' checks. | |
| 1498 | */ | |
| 1499 | const readAllCheckContexts = ( | |
| 1500 | input: Parameters<GitHubPullRequestCli["Service"]["getPullRequestDetail"]>[0], | |
| 1501 | allowReserve: boolean, | |
| 1502 | ) => | |
| 1503 | Effect.gen(function* () { | |
| 1504 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1505 | const contexts: GitHubCheckContext[] = []; | |
| 1506 | let headSha: string | null = null; | |
| 1507 | let after: string | null = null; | |
| 1508 | for (let page = 0; page < CHECK_CONTEXT_PAGES; page++) { | |
| 1509 | const read: { | |
| 1510 | readonly headSha: string; | |
| 1511 | readonly contexts: ReadonlyArray<GitHubCheckContext>; | |
| 1512 | readonly nextCursor: string | null; | |
| 1513 | } = yield* graphqlRead({ | |
| 1514 | cwd: input.cwd, | |
| 1515 | host: input.host, | |
| 1516 | operation: "getPullRequestDetail", | |
| 1517 | allowReserve, | |
| 1518 | variables: { owner, name, number: input.number, after }, | |
| 1519 | query: pullRequestCheckContextsGraphQlQuery(input.host), | |
| 1520 | decode: decodePullRequestCheckContextsJson, | |
| 1521 | }); | |
| 1522 | if (headSha !== null && read.headSha !== headSha) { | |
| 1523 | return yield* readError( | |
| 1524 | input.cwd, | |
| 1525 | "getPullRequestDetail", | |
| 1526 | new Error("Pull request head changed while reading checks."), | |
| 1527 | ); | |
| 1528 | } | |
| 1529 | headSha = read.headSha; | |
| 1530 | contexts.push(...read.contexts); | |
| 1531 | after = read.nextCursor; | |
| 1532 | if (after === null) break; | |
| 1533 | } | |
| 1534 | return { headSha, contexts, truncated: after !== null }; | |
| 1535 | }); | |
| 1536 | ||
| 1537 | const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) => { | |
| 1538 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1539 | return AllowGitHubReserve.pipe( | |
| 1540 | Effect.flatMap((allowReserve) => | |
| 1541 | graphqlRead({ | |
| 1542 | allowReserve, | |
| 1543 | cwd: input.cwd, | |
| 1544 | host: input.host, | |
| 1545 | operation: "getPullRequestDetail", | |
| 1546 | variables: { | |
| 1547 | owner, | |
| 1548 | name, | |
| 1549 | number: input.number, | |
| 1550 | headRef: `refs/pull/${input.number}/head`, | |
| 1551 | }, | |
| 1552 | query: pullRequestCoreGraphQlQuery(input.host), | |
| 1553 | decode: decodePullRequestCoreJson, | |
| 1554 | }).pipe( | |
| 1555 | // Past a hundred checks the first page would let the rest imply success, so the whole | |
| 1556 | // rollup is walked instead. | |
| 1557 | Effect.filterOrElse( | |
| 1558 | (core) => !core.checksTruncated, | |
| 1559 | (core) => | |
| 1560 | readAllCheckContexts(input, allowReserve).pipe( | |
| 1561 | Effect.filterOrFail( | |
| 1562 | (all) => all.headSha === core.headSha, | |
| 1563 | () => | |
| 1564 | readError( | |
| 1565 | input.cwd, | |
| 1566 | "getPullRequestDetail", | |
| 1567 | new Error("Pull request head changed while reading checks."), | |
| 1568 | ), | |
| 1569 | ), | |
| 1570 | Effect.map((all) => ({ | |
| 1571 | ...core, | |
| 1572 | ...pullRequestChecksFromContexts(all.contexts), | |
| 1573 | checksTruncated: all.truncated, | |
| 1574 | })), | |
| 1575 | ), | |
| 1576 | ), | |
| 1577 | ), | |
| 1578 | ), | |
| 1579 | ); | |
| 1580 | }; | |
| 1581 | ||
| 1582 | const workflowApprovalLimit = 1_000; | |
| 1583 | const workflowApprovalReadError = (cwd: string, cause: unknown) => | |
| 1584 | readError(cwd, "listWorkflowRunsRequiringApproval", cause); | |
| 1585 | ||
| 1586 | /** Every open pull request whose head branch carries this name, up to one past the limit. */ | |
| 1587 | const listHeadsByBranch = (input: { | |
| 1588 | readonly cwd: string; | |
| 1589 | readonly repository: string; | |
| 1590 | readonly host: string; | |
| 1591 | readonly headBranch: string; | |
| 1592 | }) => | |
| 1593 | Effect.gen(function* () { | |
| 1594 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1595 | const heads: GitHubPullRequestHead[] = []; | |
| 1596 | let after: string | null = null; | |
| 1597 | do { | |
| 1598 | const page: { | |
| 1599 | readonly heads: ReadonlyArray<GitHubPullRequestHead>; | |
| 1600 | readonly nextCursor: string | null; | |
| 1601 | } = yield* graphqlRead({ | |
| 1602 | cwd: input.cwd, | |
| 1603 | host: input.host, | |
| 1604 | operation: "listWorkflowRunsRequiringApproval", | |
| 1605 | allowReserve: true, | |
| 1606 | variables: { owner, name, head: input.headBranch, after }, | |
| 1607 | query: PULL_REQUEST_HEADS_GRAPHQL_QUERY, | |
| 1608 | decode: decodePullRequestHeadsJson, | |
| 1609 | }); | |
| 1610 | heads.push(...page.heads); | |
| 1611 | after = page.nextCursor; | |
| 1612 | } while (after !== null && heads.length <= workflowApprovalLimit); | |
| 1613 | return heads; | |
| 1614 | }); | |
| 1615 | ||
| 1616 | /** | |
| 1617 | * The runs waiting on a maintainer for this exact head, up to one past the limit. `head_sha` is | |
| 1618 | * what scopes them; `head_branch` is checked as well, the way `gh run list --branch` did. | |
| 1619 | */ | |
| 1620 | const listActionRequiredRuns = (input: { | |
| 1621 | readonly cwd: string; | |
| 1622 | readonly repository: string; | |
| 1623 | readonly host: string; | |
| 1624 | readonly headSha: string; | |
| 1625 | readonly headBranch: string; | |
| 1626 | }) => | |
| 1627 | Effect.gen(function* () { | |
| 1628 | // The checks revalidator may have just confirmed every run of this head as current. | |
| 1629 | const known = yield* KnownWorkflowRuns; | |
| 1630 | if (known !== null && known.headSha === input.headSha) { | |
| 1631 | return known.runs.flatMap((run) => | |
| 1632 | // A run waiting on a maintainer reports `completed` with an `action_required` | |
| 1633 | // conclusion; the `status=action_required` query matches either. | |
| 1634 | (run.conclusion === "action_required" || run.status === "action_required") && | |
| 1635 | run.head_branch === input.headBranch | |
| 1636 | ? [ | |
| 1637 | { | |
| 1638 | id: run.id, | |
| 1639 | name: run.name?.trim() || `Workflow run ${run.id}`, | |
| 1640 | url: run.html_url?.trim() || null, | |
| 1641 | }, | |
| 1642 | ] | |
| 1643 | : [], | |
| 1644 | ); | |
| 1645 | } | |
| 1646 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1647 | const runs: GitHubWorkflowRunApproval[] = []; | |
| 1648 | for (let page = 1; runs.length <= workflowApprovalLimit; page++) { | |
| 1649 | const read: GitHubWorkflowRunPage = yield* restRead({ | |
| 1650 | cwd: input.cwd, | |
| 1651 | host: input.host, | |
| 1652 | operation: "listWorkflowRunsRequiringApproval", | |
| 1653 | path: `repos/${owner}/${name}/actions/runs?head_sha=${encodeURIComponent(input.headSha)}&branch=${encodeURIComponent(input.headBranch)}&event=pull_request&status=action_required&per_page=100&page=${page}`, | |
| 1654 | decode: decodeWorkflowRunsJson, | |
| 1655 | }).pipe(Effect.mapError((error) => workflowApprovalReadError(input.cwd, error))); | |
| 1656 | runs.push(...read.runs); | |
| 1657 | if (read.rawCount < 100) break; | |
| 1658 | } | |
| 1659 | return runs; | |
| 1660 | }); | |
| 1661 | ||
| 1662 | const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] = | |
| 1663 | (input) => | |
| 1664 | Effect.all( | |
| 1665 | [ | |
| 1666 | listHeadsByBranch(input).pipe( | |
| 1667 | Effect.flatMap( | |
| 1668 | ( | |
| 1669 | heads, | |
| 1670 | ): Effect.Effect< | |
| 1671 | GitHubPullRequestHead, | |
| 1672 | GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError | |
| 1673 | > => { | |
| 1674 | const exactHeads = heads.filter( | |
| 1675 | (pullRequest) => | |
| 1676 | pullRequest.headSha === input.headSha && | |
| 1677 | pullRequest.isCrossRepository === true && | |
| 1678 | pullRequest.headRepositoryOwner?.toLowerCase() === | |
| 1679 | input.headRepositoryOwner.toLowerCase(), | |
| 1680 | ); | |
| 1681 | if (heads.length > workflowApprovalLimit) { | |
| 1682 | return Effect.fail( | |
| 1683 | new GitHubWorkflowApprovalRefusedError({ | |
| 1684 | command: "gh", | |
| 1685 | cwd: input.cwd, | |
| 1686 | number: input.number, | |
| 1687 | reason: "head-list-truncated", | |
| 1688 | observedCount: heads.length, | |
| 1689 | limit: workflowApprovalLimit, | |
| 1690 | }), | |
| 1691 | ); | |
| 1692 | } | |
| 1693 | if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { | |
| 1694 | return Effect.fail( | |
| 1695 | new GitHubWorkflowApprovalRefusedError({ | |
| 1696 | command: "gh", | |
| 1697 | cwd: input.cwd, | |
| 1698 | number: input.number, | |
| 1699 | reason: "head-not-unique", | |
| 1700 | observedCount: exactHeads.length, | |
| 1701 | limit: workflowApprovalLimit, | |
| 1702 | }), | |
| 1703 | ); | |
| 1704 | } | |
| 1705 | return Effect.succeed(exactHeads[0]); | |
| 1706 | }, | |
| 1707 | ), | |
| 1708 | ), | |
| 1709 | listActionRequiredRuns(input).pipe( | |
| 1710 | Effect.flatMap((runs) => | |
| 1711 | runs.length > workflowApprovalLimit | |
| 1712 | ? Effect.fail( | |
| 1713 | new GitHubWorkflowApprovalRefusedError({ | |
| 1714 | command: "gh", | |
| 1715 | cwd: input.cwd, | |
| 1716 | number: input.number, | |
| 1717 | reason: "run-list-truncated", | |
| 1718 | observedCount: runs.length, | |
| 1719 | limit: workflowApprovalLimit, | |
| 1720 | }), | |
| 1721 | ) | |
| 1722 | : Effect.succeed(runs), | |
| 1723 | ), | |
| 1724 | ), | |
| 1725 | ], | |
| 1726 | { concurrency: 2 }, | |
| 1727 | ).pipe(Effect.map(([, runs]) => runs)); | |
| 1728 | ||
| 1729 | /** One pull request's summary, through the same aliased query the batch uses. */ | |
| 1730 | const viewPullRequestSummary = (input: PullRequestSummaryRead) => { | |
| 1731 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1732 | return graphqlRead({ | |
| 1733 | cwd: input.cwd, | |
| 1734 | host: input.host, | |
| 1735 | operation: "getPullRequestSummary", | |
| 1736 | variables: { owner, name, number: input.number }, | |
| 1737 | query: pullRequestSummaryGraphQlQuery(input.host === "github.com"), | |
| 1738 | decode: decodePullRequestSummariesJson, | |
| 1739 | }).pipe( | |
| 1740 | Effect.flatMap((summaries) => { | |
| 1741 | const summary = summaries.get(0); | |
| 1742 | return summary === undefined | |
| 1743 | ? Effect.fail( | |
| 1744 | readError( | |
| 1745 | input.cwd, | |
| 1746 | "getPullRequestSummary", | |
| 1747 | new Error(`GitHub answered nothing for ${input.repository}#${input.number}.`), | |
| 1748 | ), | |
| 1749 | ) | |
| 1750 | : Effect.succeed(summary); | |
| 1751 | }), | |
| 1752 | ); | |
| 1753 | }; | |
| 1754 | ||
| 1755 | /** | |
| 1756 | * Summaries asked for together, on one host under one credential, share aliased GraphQL reads | |
| 1757 | * of twenty-five: the background sync reads every linked pull request each minute, and one | |
| 1758 | * `gh pr view` apiece is most of what it spends. Whatever the batch cannot answer — a selector | |
| 1759 | * GraphQL cannot address, a pull request GitHub returned nothing for — is read on its own. | |
| 1760 | */ | |
| 1761 | const summaryResolver = RequestResolver.makeGrouped<PullRequestSummaryRead, string>({ | |
| 1762 | key: ({ request, context }) => | |
| 1763 | JSON.stringify([ | |
| 1764 | request.host.toLowerCase(), | |
| 1765 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) | |
| 1766 | ?.credentialFingerprint ?? null, | |
| 1767 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), | |
| 1768 | ]), | |
| 1769 | resolver: (entries) => { | |
| 1770 | const [first] = entries; | |
| 1771 | const batchable = entries.filter( | |
| 1772 | (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null, | |
| 1773 | ); | |
| 1774 | // Stack membership rides along where GitHub serves stacks, so the background sync can skip | |
| 1775 | // the REST stack read for pull requests that are in none. | |
| 1776 | const query = buildPullRequestSummariesGraphQlQuery( | |
| 1777 | batchable.map((entry) => entry.request), | |
| 1778 | first.request.host === "github.com", | |
| 1779 | ); | |
| 1780 | const batched = | |
| 1781 | query === null | |
| 1782 | ? Effect.succeed(new Map<number, GitHubPullRequestSummary>()) | |
| 1783 | : graphqlRead({ | |
| 1784 | cwd: first.request.cwd, | |
| 1785 | host: first.request.host, | |
| 1786 | operation: "getPullRequestSummary", | |
| 1787 | query, | |
| 1788 | decode: decodePullRequestSummariesJson, | |
| 1789 | }); | |
| 1790 | return batched.pipe( | |
| 1791 | // A GraphQL error anywhere fails the whole document — one repository gone or out of | |
| 1792 | // reach — so a batch that could not be read leaves every entry to its own read. A paused | |
| 1793 | // budget is the exception: reading one at a time would only spend what is being saved. | |
| 1794 | Effect.catchCauseIf( | |
| 1795 | (cause) => | |
| 1796 | !Cause.hasInterruptsOnly(cause) && | |
| 1797 | !Cause.findErrorOption(cause).pipe( | |
| 1798 | Option.exists((error) => error._tag === "SourceControlRateLimitPausedError"), | |
| 1799 | ), | |
| 1800 | (cause) => | |
| 1801 | Effect.logDebug("batched pull request summary read failed", { cause }).pipe( | |
| 1802 | Effect.as(new Map<number, GitHubPullRequestSummary>()), | |
| 1803 | ), | |
| 1804 | ), | |
| 1805 | Effect.flatMap((summaries) => { | |
| 1806 | const unanswered = entries.filter((entry) => { | |
| 1807 | const summary = summaries.get(batchable.indexOf(entry)); | |
| 1808 | if (summary === undefined) return true; | |
| 1809 | entry.completeUnsafe(Exit.succeed(summary)); | |
| 1810 | return false; | |
| 1811 | }); | |
| 1812 | return Effect.forEach( | |
| 1813 | unanswered, | |
| 1814 | (entry) => | |
| 1815 | viewPullRequestSummary(entry.request).pipe( | |
| 1816 | Effect.exit, | |
| 1817 | Effect.map((exit) => entry.completeUnsafe(exit)), | |
| 1818 | ), | |
| 1819 | { concurrency: STAT_REQUEST_CONCURRENCY, discard: true }, | |
| 1820 | ); | |
| 1821 | }), | |
| 1822 | Effect.catchCause((cause) => | |
| 1823 | Effect.sync(() => { | |
| 1824 | for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); | |
| 1825 | }), | |
| 1826 | ), | |
| 1827 | ); | |
| 1828 | }, | |
| 1829 | }).pipe( | |
| 1830 | RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), | |
| 1831 | RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), | |
| 1832 | ); | |
| 1833 | const getPullRequestSummary: GitHubPullRequestCli["Service"]["getPullRequestSummary"] = (input) => | |
| 1834 | Effect.request(new PullRequestSummaryRead(input), summaryResolver); | |
| 1835 | ||
| 1836 | // Every watched pull request on a host in one read per pass. A pull request the batch has no | |
| 1837 | // answer for gets null, and its watch reads it in full; a failed batch fails every entry. | |
| 1838 | const watchFingerprintResolver = RequestResolver.makeGrouped< | |
| 1839 | PullRequestWatchFingerprintRead, | |
| 1840 | string | |
| 1841 | >({ | |
| 1842 | key: ({ request, context }) => | |
| 1843 | JSON.stringify([ | |
| 1844 | request.host.toLowerCase(), | |
| 1845 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) | |
| 1846 | ?.credentialFingerprint ?? null, | |
| 1847 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), | |
| 1848 | ]), | |
| 1849 | resolver: (entries) => { | |
| 1850 | const [first] = entries; | |
| 1851 | const batchable = entries.filter( | |
| 1852 | (entry) => buildPullRequestWatchFingerprintsGraphQlQuery([entry.request]) !== null, | |
| 1853 | ); | |
| 1854 | const query = buildPullRequestWatchFingerprintsGraphQlQuery( | |
| 1855 | batchable.map((entry) => entry.request), | |
| 1856 | ); | |
| 1857 | const read = | |
| 1858 | query === null | |
| 1859 | ? Effect.succeed(new Map<number, GitHubPullRequestWatchFingerprint>()) | |
| 1860 | : graphqlRead({ | |
| 1861 | cwd: first.request.cwd, | |
| 1862 | host: first.request.host, | |
| 1863 | operation: "getPullRequestWatchFingerprint", | |
| 1864 | query, | |
| 1865 | decode: decodePullRequestWatchFingerprintsJson, | |
| 1866 | }); | |
| 1867 | return read.pipe( | |
| 1868 | Effect.map((fingerprints) => { | |
| 1869 | for (const entry of entries) { | |
| 1870 | const index = batchable.indexOf(entry); | |
| 1871 | entry.completeUnsafe( | |
| 1872 | Exit.succeed(index === -1 ? null : (fingerprints.get(index) ?? null)), | |
| 1873 | ); | |
| 1874 | } | |
| 1875 | }), | |
| 1876 | Effect.catchCause((cause) => | |
| 1877 | Effect.sync(() => { | |
| 1878 | for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); | |
| 1879 | }), | |
| 1880 | ), | |
| 1881 | ); | |
| 1882 | }, | |
| 1883 | }).pipe( | |
| 1884 | RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), | |
| 1885 | RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), | |
| 1886 | ); | |
| 1887 | const getPullRequestWatchFingerprint: GitHubPullRequestCli["Service"]["getPullRequestWatchFingerprint"] = | |
| 1888 | (input) => Effect.request(new PullRequestWatchFingerprintRead(input), watchFingerprintResolver); | |
| 1889 | ||
| 1890 | return GitHubPullRequestCli.of({ | |
| 1891 | withVerifiedCredential, | |
| 1892 | revalidateChecks, | |
| 1893 | getRoutingIdentity, | |
| 1894 | getViewerLogin: (input) => | |
| 1895 | getRoutingIdentity(input).pipe(Effect.map((identity) => identity.viewer)), | |
| 1896 | ||
| 1897 | listPullRequests: (input) => { | |
| 1898 | const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS); | |
| 1899 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1900 | const query = searchQuery({ ...input, repositories: [input.repository] }); | |
| 1901 | if (query === null) { | |
| 1902 | return Effect.fail( | |
| 1903 | new GitHubRepositorySelectorError({ | |
| 1904 | command: "gh", | |
| 1905 | cwd: input.cwd, | |
| 1906 | operation: "listPullRequests", | |
| 1907 | }), | |
| 1908 | ); | |
| 1909 | } | |
| 1910 | /** Pages of up to a hundred until `rows` have arrived or GitHub has no more. */ | |
| 1911 | const collect = ( | |
| 1912 | rows: number, | |
| 1913 | page: ( | |
| 1914 | after: string | null, | |
| 1915 | rows: number, | |
| 1916 | ) => Effect.Effect<GitHubPullRequestListPage, GitHubPullRequestCliError>, | |
| 1917 | ) => | |
| 1918 | Effect.gen(function* () { | |
| 1919 | const items: GitHubPullRequestListItem[] = []; | |
| 1920 | let rawCount = 0; | |
| 1921 | let after: string | null = null; | |
| 1922 | do { | |
| 1923 | const read: GitHubPullRequestListPage = yield* page(after, rows - rawCount); | |
| 1924 | items.push(...read.items); | |
| 1925 | rawCount += read.rawCount; | |
| 1926 | after = read.endCursor; | |
| 1927 | } while (after !== null && rawCount < rows); | |
| 1928 | return { items, rawCount }; | |
| 1929 | }); | |
| 1930 | const read = ( | |
| 1931 | continues: boolean, | |
| 1932 | requestedRows = input.limit + 1, | |
| 1933 | ): Effect.Effect<GitHubPullRequestListBatch, GitHubPullRequestCliError> => | |
| 1934 | collect(requestedRows, (after, rows) => | |
| 1935 | continues | |
| 1936 | ? graphqlRead({ | |
| 1937 | cwd: input.cwd, | |
| 1938 | host: input.host, | |
| 1939 | operation: "listPullRequests", | |
| 1940 | // The reader's own words are in the query. | |
| 1941 | variables: { q: query, after }, | |
| 1942 | query: pullRequestSearchGraphQlQuery(rows, false, true), | |
| 1943 | decode: decodePullRequestSearchJson, | |
| 1944 | }) | |
| 1945 | : graphqlRead({ | |
| 1946 | cwd: input.cwd, | |
| 1947 | host: input.host, | |
| 1948 | operation: "listPullRequests", | |
| 1949 | variables: { owner, name, states: LIST_STATES[input.state], after }, | |
| 1950 | query: pullRequestListGraphQlQuery(rows), | |
| 1951 | decode: decodePullRequestListJson, | |
| 1952 | }), | |
| 1953 | ).pipe( | |
| 1954 | Effect.flatMap(({ items: rawItems, rawCount }) => { | |
| 1955 | const items = continues | |
| 1956 | ? rawItems | |
| 1957 | : rawItems.filter((item) => matchesUnsortedListing(item, input)); | |
| 1958 | if ( | |
| 1959 | !continues && | |
| 1960 | items.length < input.limit && | |
| 1961 | rawCount >= requestedRows && | |
| 1962 | requestedRows < fallbackMaxRows | |
| 1963 | ) { | |
| 1964 | const nextRows = Math.min(requestedRows * 2, fallbackMaxRows); | |
| 1965 | if (nextRows > requestedRows) return read(false, nextRows); | |
| 1966 | } | |
| 1967 | return Effect.succeed({ | |
| 1968 | items: items.slice(0, input.limit), | |
| 1969 | // One row over the page size is the probe for a next page, and it is | |
| 1970 | // counted before decoding: a skipped malformed row must not end paging. | |
| 1971 | truncated: continues | |
| 1972 | ? rawCount > input.limit | |
| 1973 | : items.length > input.limit || rawCount >= requestedRows, | |
| 1974 | continues, | |
| 1975 | }); | |
| 1976 | }), | |
| 1977 | ); | |
| 1978 | // GitHub does not index every repository for search, and one it will not search answers | |
| 1979 | // with no rows rather than with an error — so an empty listing is read again from the | |
| 1980 | // repository's own list. Those rows come back newest-created first, an order no `updated:` | |
| 1981 | // qualifier can carry on from, so that page says it cannot be continued and the reader | |
| 1982 | // reaches the rest of it by asking for a larger page, as every listing used to. | |
| 1983 | // | |
| 1984 | // Only ever the first slice: a repository that answered the search once will answer it | |
| 1985 | // again, so an empty slice under a cursor is a repository that has run out. | |
| 1986 | // A text search that finds nothing has found nothing: falling back would answer it with the | |
| 1987 | // repository's whole list, which is every row the reader did not search for. The fallback | |
| 1988 | // is for a repository the index does not cover, and a listing with no text to match is the | |
| 1989 | // only place an empty answer can mean that. | |
| 1990 | // Every filter is a qualifier `matchesFilters` can judge over the fallback's own rows just | |
| 1991 | // as well as search judges them over its own, so carrying them into the fallback answers | |
| 1992 | // the same read rather than a wider one. Free text is the one thing the fallback cannot | |
| 1993 | // judge locally — it lists rows, it does not search their text — so a query still rules | |
| 1994 | // the fallback out: an empty answer under one is already the answer. | |
| 1995 | const hasQuery = (input.query?.trim().length ?? 0) > 0; | |
| 1996 | return read(true).pipe( | |
| 1997 | Effect.filterOrElse( | |
| 1998 | (batch) => batch.items.length > 0 || input.cursor !== undefined || hasQuery, | |
| 1999 | () => read(false), | |
| 2000 | ), | |
| 2001 | Effect.flatMap((batch) => { | |
| 2002 | // Match the search query's host support, and enrich only rows that survived paging. | |
| 2003 | if (input.host !== "github.com" || batch.items.length === 0) return Effect.succeed(batch); | |
| 2004 | const chunks: Array<ReadonlyArray<GitHubPullRequestListItem>> = []; | |
| 2005 | for (let start = 0; start < batch.items.length; start += STAT_ALIASES_PER_REQUEST) { | |
| 2006 | chunks.push(batch.items.slice(start, start + STAT_ALIASES_PER_REQUEST)); | |
| 2007 | } | |
| 2008 | return Effect.forEach( | |
| 2009 | chunks, | |
| 2010 | (chunk) => { | |
| 2011 | const query = buildPullRequestStackMembershipsGraphQlQuery( | |
| 2012 | input.repository, | |
| 2013 | chunk.map((item) => item.number), | |
| 2014 | ); | |
| 2015 | if (query === null) return Effect.succeed(chunk); | |
| 2016 | return graphqlRead({ | |
| 2017 | cwd: input.cwd, | |
| 2018 | host: input.host, | |
| 2019 | operation: "listPullRequestStackMemberships", | |
| 2020 | query, | |
| 2021 | decode: decodePullRequestStackMembershipsJson, | |
| 2022 | }).pipe( | |
| 2023 | Effect.map((memberships) => | |
| 2024 | chunk.map((item, index) => { | |
| 2025 | const stack = memberships.get(index); | |
| 2026 | return stack === undefined ? item : { ...item, stack }; | |
| 2027 | }), | |
| 2028 | ), | |
| 2029 | // Optional badges must not take down a listing that already read successfully. | |
| 2030 | Effect.catch(() => | |
| 2031 | Effect.logWarning("Pull request stack membership enrichment failed", { | |
| 2032 | operation: "listPullRequestStackMemberships", | |
| 2033 | host: input.host, | |
| 2034 | rows: chunk.length, | |
| 2035 | }).pipe(Effect.as(chunk)), | |
| 2036 | ), | |
| 2037 | ); | |
| 2038 | }, | |
| 2039 | { concurrency: STAT_REQUEST_CONCURRENCY }, | |
| 2040 | ).pipe(Effect.map((chunks) => ({ ...batch, items: chunks.flat() }))); | |
| 2041 | }), | |
| 2042 | ); | |
| 2043 | }, | |
| 2044 | ||
| 2045 | searchPullRequests: (input) => { | |
| 2046 | const query = searchQuery(input); | |
| 2047 | if (query === null) { | |
| 2048 | return Effect.fail( | |
| 2049 | new GitHubRepositorySelectorError({ | |
| 2050 | command: "gh", | |
| 2051 | cwd: input.cwd, | |
| 2052 | operation: "searchPullRequests", | |
| 2053 | }), | |
| 2054 | ); | |
| 2055 | } | |
| 2056 | // One extra row reveals that the host has more than the slice shows, the way the | |
| 2057 | // per-repository read does — up to GitHub's own ceiling on a search page, past which | |
| 2058 | // `hasNextPage` is what says there is more. | |
| 2059 | const rows = Math.min(input.limit + 1, PULL_REQUEST_SEARCH_MAX_ROWS); | |
| 2060 | return graphqlRead({ | |
| 2061 | cwd: input.cwd, | |
| 2062 | host: input.host, | |
| 2063 | operation: "searchPullRequests", | |
| 2064 | variables: { q: query, after: null }, | |
| 2065 | query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"), | |
| 2066 | decode: decodePullRequestSearchJson, | |
| 2067 | }).pipe( | |
| 2068 | Effect.map((batch) => ({ | |
| 2069 | items: batch.items.slice(0, input.limit), | |
| 2070 | truncated: batch.rawCount > input.limit || batch.hasNextPage, | |
| 2071 | })), | |
| 2072 | ); | |
| 2073 | }, | |
| 2074 | ||
| 2075 | listPullRequestStats: (input) => { | |
| 2076 | const chunks: Array<ReadonlyArray<{ readonly repository: string; readonly number: number }>> = | |
| 2077 | []; | |
| 2078 | for (let start = 0; start < input.changeRequests.length; start += STAT_ALIASES_PER_REQUEST) { | |
| 2079 | chunks.push(input.changeRequests.slice(start, start + STAT_ALIASES_PER_REQUEST)); | |
| 2080 | } | |
| 2081 | return Effect.forEach( | |
| 2082 | chunks, | |
| 2083 | (chunk) => { | |
| 2084 | const query = buildPullRequestStatsGraphQlQuery(chunk); | |
| 2085 | if (query === null) { | |
| 2086 | return Effect.fail( | |
| 2087 | new GitHubRepositorySelectorError({ | |
| 2088 | command: "gh", | |
| 2089 | cwd: input.cwd, | |
| 2090 | operation: "listPullRequestStats", | |
| 2091 | }), | |
| 2092 | ); | |
| 2093 | } | |
| 2094 | return graphqlRead({ | |
| 2095 | cwd: input.cwd, | |
| 2096 | host: input.host, | |
| 2097 | operation: "listPullRequestStats", | |
| 2098 | query, | |
| 2099 | decode: decodePullRequestStatsJson, | |
| 2100 | }).pipe( | |
| 2101 | Effect.map((stats) => | |
| 2102 | chunk.flatMap((changeRequest, index) => { | |
| 2103 | const stat = stats.get(index); | |
| 2104 | return stat === undefined ? [] : [{ ...changeRequest, ...stat }]; | |
| 2105 | }), | |
| 2106 | ), | |
| 2107 | ); | |
| 2108 | }, | |
| 2109 | { concurrency: STAT_REQUEST_CONCURRENCY }, | |
| 2110 | ).pipe(Effect.map((results) => results.flat())); | |
| 2111 | }, | |
| 2112 | ||
| 2113 | getPullRequestSummary, | |
| 2114 | getPullRequestWatchFingerprint, | |
| 2115 | ||
| 2116 | getPullRequestDetail, | |
| 2117 | getPullRequestPreview: (input) => { | |
| 2118 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2119 | return graphqlRead({ | |
| 2120 | cwd: input.cwd, | |
| 2121 | host: input.host, | |
| 2122 | operation: "getPullRequestPreview", | |
| 2123 | variables: { owner, name, number: input.number }, | |
| 2124 | query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, | |
| 2125 | decode: decodePullRequestPreviewJson, | |
| 2126 | }); | |
| 2127 | }, | |
| 2128 | listWorkflowRunsRequiringApproval, | |
| 2129 | ||
| 2130 | getPullRequestStack: (input) => { | |
| 2131 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2132 | return restRead({ | |
| 2133 | cwd: input.cwd, | |
| 2134 | host: input.host, | |
| 2135 | operation: "getPullRequestStack", | |
| 2136 | path: `repos/${owner}/${name}/stacks?pull_request=${input.number}`, | |
| 2137 | decode: decodePullRequestStacksJson, | |
| 2138 | }).pipe( | |
| 2139 | // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine. | |
| 2140 | Effect.flatMap((stack) => { | |
| 2141 | if (!input.includeDetails || stack === null) return Effect.succeed(stack); | |
| 2142 | return restRead({ | |
| 2143 | cwd: input.cwd, | |
| 2144 | host: input.host, | |
| 2145 | operation: "getPullRequestStack", | |
| 2146 | path: `repos/${owner}/${name}/stacks/${stack.number}`, | |
| 2147 | decode: (raw) => decodePullRequestStacksJson(`[${raw}]`), | |
| 2148 | }); | |
| 2149 | }), | |
| 2150 | // Hosts without the stacks preview return 404. Other failures must preserve the | |
| 2151 | // previously synced stack and let the caller retry. | |
| 2152 | Effect.catchTags({ | |
| 2153 | GitHubApiNotFoundError: () => Effect.succeed(null), | |
| 2154 | }), | |
| 2155 | ); | |
| 2156 | }, | |
| 2157 | ||
| 2158 | getPullRequestActivity: (input) => | |
| 2159 | Effect.gen(function* () { | |
| 2160 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2161 | let author: PullRequestActor | null = null; | |
| 2162 | let commits: ReadonlyArray<PullRequestCommit> = []; | |
| 2163 | const remarks: PullRequestComment[] = []; | |
| 2164 | let commentsAfter: string | null = null; | |
| 2165 | let reviewsAfter: string | null = null; | |
| 2166 | let withComments = true; | |
| 2167 | let withReviews = true; | |
| 2168 | // Both remark lists page on their own; a page asks only for the ones with more to give. | |
| 2169 | for (let page = 0; page < REVIEW_THREAD_PAGES && (withComments || withReviews); page++) { | |
| 2170 | const read: GitHubPullRequestActivityPage = yield* graphqlRead({ | |
| 2171 | cwd: input.cwd, | |
| 2172 | host: input.host, | |
| 2173 | operation: "getPullRequestActivity", | |
| 2174 | variables: { | |
| 2175 | owner, | |
| 2176 | name, | |
| 2177 | number: input.number, | |
| 2178 | head: page === 0, | |
| 2179 | withComments, | |
| 2180 | commentsAfter, | |
| 2181 | withReviews, | |
| 2182 | reviewsAfter, | |
| 2183 | }, | |
| 2184 | query: PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, | |
| 2185 | decode: decodePullRequestActivityJson, | |
| 2186 | }); | |
| 2187 | if (page === 0) { | |
| 2188 | author = read.author ?? null; | |
| 2189 | commits = read.commits ?? []; | |
| 2190 | } | |
| 2191 | remarks.push(...read.remarks); | |
| 2192 | commentsAfter = read.nextCommentsCursor; | |
| 2193 | reviewsAfter = read.nextReviewsCursor; | |
| 2194 | withComments = withComments && commentsAfter !== null; | |
| 2195 | withReviews = withReviews && reviewsAfter !== null; | |
| 2196 | } | |
| 2197 | return { | |
| 2198 | author, | |
| 2199 | comments: remarks.toSorted((left, right) => | |
| 2200 | left.createdAt.localeCompare(right.createdAt), | |
| 2201 | ), | |
| 2202 | commits, | |
| 2203 | } satisfies GitHubPullRequestActivity; | |
| 2204 | }), | |
| 2205 | ||
| 2206 | getPullRequestDiff: (input) => { | |
| 2207 | const filesPage = (page: number) => | |
| 2208 | diffFilesPage({ | |
| 2209 | cwd: input.cwd, | |
| 2210 | repository: input.repository, | |
| 2211 | host: input.host, | |
| 2212 | number: input.number, | |
| 2213 | page, | |
| 2214 | ...(input.commit === undefined ? {} : { commit: input.commit }), | |
| 2215 | }); | |
| 2216 | if (input.commit !== undefined && !isCommitSha(input.commit)) { | |
| 2217 | return Effect.fail(new GitHubDiffCommitError({ command: "gh", cwd: input.cwd })); | |
| 2218 | } | |
| 2219 | // A cursor only ever comes from the files walk, so a reader carrying one is already past | |
| 2220 | // the point where `gh pr diff` had anything to say. | |
| 2221 | if (input.cursor !== undefined) { | |
| 2222 | const page = diffCursorPage(input.cursor); | |
| 2223 | return page === null | |
| 2224 | ? Effect.fail(new GitHubDiffCursorError({ command: "gh", cwd: input.cwd })) | |
| 2225 | : filesPage(page); | |
| 2226 | } | |
| 2227 | // `gh pr diff` speaks for the whole pull request and has no way to name one commit of it. | |
| 2228 | if (input.commit !== undefined) { | |
| 2229 | return filesPage(1); | |
| 2230 | } | |
| 2231 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2232 | return api | |
| 2233 | .rest({ | |
| 2234 | host: input.host, | |
| 2235 | operation: "getPullRequestDiff", | |
| 2236 | path: `repos/${owner}/${name}/pulls/${input.number}`, | |
| 2237 | accept: "application/vnd.github.diff", | |
| 2238 | maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, | |
| 2239 | timeout: DIFF_TIMEOUT, | |
| 2240 | }) | |
| 2241 | .pipe( | |
| 2242 | Effect.flatMap((response) => | |
| 2243 | // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor | |
| 2244 | // something the reader can carry on from. The files API can serve the same change a | |
| 2245 | // whole number of files at a time, so an oversized patch takes that road as well. | |
| 2246 | response.truncated | |
| 2247 | ? filesPage(1) | |
| 2248 | : // One read served the whole patch, so there is no next slice to ask for. | |
| 2249 | Effect.succeed({ patch: response.body, truncated: false, nextCursor: null }), | |
| 2250 | ), | |
| 2251 | // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read | |
| 2252 | // from the files API instead, a page per call. Only once the direct read has failed: a | |
| 2253 | // pull request GitHub will serve a diff for must not pay for a second request. A | |
| 2254 | // fallback that fails too reports the original refusal, which is the one that explains | |
| 2255 | // the page. Narrowed to a request GitHub answered and refused: a missing credential or | |
| 2256 | // a rate limit fails the same way for every request. | |
| 2257 | Effect.catchTags({ | |
| 2258 | GitHubApiResponseError: (error) => filesPage(1).pipe(Effect.mapError(() => error)), | |
| 2259 | }), | |
| 2260 | ); | |
| 2261 | }, | |
| 2262 | ||
| 2263 | getPullRequestDiffFileContents, | |
| 2264 | ||
| 2265 | getReviewThreadComments: (input) => { | |
| 2266 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2267 | return graphqlRead({ | |
| 2268 | cwd: input.cwd, | |
| 2269 | host: input.host, | |
| 2270 | operation: "getReviewThreadComments", | |
| 2271 | variables: { | |
| 2272 | owner, | |
| 2273 | name, | |
| 2274 | number: input.number, | |
| 2275 | threadId: input.threadId, | |
| 2276 | cursor: input.cursor, | |
| 2277 | }, | |
| 2278 | query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, | |
| 2279 | decode: decodeReviewThreadCommentsJson, | |
| 2280 | }).pipe( | |
| 2281 | Effect.flatMap(({ belongsToPullRequest, comments, nextCursor }) => | |
| 2282 | belongsToPullRequest | |
| 2283 | ? Effect.succeed({ comments, nextCursor }) | |
| 2284 | : Effect.fail( | |
| 2285 | new GitHubSubjectScopeError({ | |
| 2286 | command: "gh", | |
| 2287 | cwd: input.cwd, | |
| 2288 | operation: "getReviewThreadComments", | |
| 2289 | }), | |
| 2290 | ), | |
| 2291 | ), | |
| 2292 | ); | |
| 2293 | }, | |
| 2294 | ||
| 2295 | listReviewThreadComments: (input) => | |
| 2296 | Effect.gen(function* () { | |
| 2297 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2298 | const threadPage = ( | |
| 2299 | cursor: string | null, | |
| 2300 | ): Effect.Effect<GitHubReviewThreadPage, GitHubPullRequestCliError> => | |
| 2301 | graphqlRead({ | |
| 2302 | cwd: input.cwd, | |
| 2303 | host: input.host, | |
| 2304 | operation: "listReviewThreadComments", | |
| 2305 | variables: { owner, name, number: input.number, cursor }, | |
| 2306 | query: REVIEW_THREADS_GRAPHQL_QUERY, | |
| 2307 | decode: decodeReviewThreadsJson, | |
| 2308 | }); | |
| 2309 | const entries: GitHubReviewThreadEntry[] = []; | |
| 2310 | const avatarsByLogin = new Map<string, string>(); | |
| 2311 | const botLogins = new Set<string>(); | |
| 2312 | const commitStats = new Map< | |
| 2313 | string, | |
| 2314 | { readonly additions: number; readonly deletions: number } | |
| 2315 | >(); | |
| 2316 | let reviewers: ReadonlyArray<PullRequestActor> = []; | |
| 2317 | let reactions: GitHubReviewThreadPage["reactions"] = []; | |
| 2318 | const reactionsById = new Map<string, ReadonlyArray<PullRequestReaction>>(); | |
| 2319 | const editedAtById = new Map<string, string>(); | |
| 2320 | let commits: GitHubReviewThreadPage["commits"] = []; | |
| 2321 | let viewer: GitHubReviewThreadPage["viewer"] = { canUpdate: true, didAuthor: false }; | |
| 2322 | const dismissalsByReviewId = new Map<string, string>(); | |
| 2323 | let dismissalCursor: string | null = null; | |
| 2324 | let cursor: string | null = null; | |
| 2325 | let page = 0; | |
| 2326 | do { | |
| 2327 | const read: GitHubReviewThreadPage = yield* threadPage(cursor); | |
| 2328 | entries.push(...read.threads); | |
| 2329 | for (const login of read.botLogins) botLogins.add(login); | |
| 2330 | for (const [login, avatarUrl] of read.avatarsByLogin) | |
| 2331 | avatarsByLogin.set(login, avatarUrl); | |
| 2332 | // The roster, the commits and the viewer's standing travel with every page, and the | |
| 2333 | // first one already carries all of them. | |
| 2334 | if (page === 0) { | |
| 2335 | reviewers = read.reviewers; | |
| 2336 | reactions = read.reactions; | |
| 2337 | for (const [id, entry] of read.reactionsById) reactionsById.set(id, entry); | |
| 2338 | for (const [id, editedAt] of read.editedAtById) editedAtById.set(id, editedAt); | |
| 2339 | commits = read.commits; | |
| 2340 | viewer = read.viewer; | |
| 2341 | for (const [id, message] of read.dismissalsByReviewId) | |
| 2342 | dismissalsByReviewId.set(id, message); | |
| 2343 | dismissalCursor = read.nextDismissalCursor; | |
| 2344 | for (const [oid, stat] of read.commitStats) commitStats.set(oid, stat); | |
| 2345 | } | |
| 2346 | cursor = read.nextCursor; | |
| 2347 | page += 1; | |
| 2348 | } while (cursor !== null && page < REVIEW_THREAD_PAGES); | |
| 2349 | ||
| 2350 | // Almost never entered: the embedded page already holds every dismissal a pull request | |
| 2351 | // ordinarily accrues. Followed so a review whose event fell past that page still finds | |
| 2352 | // its reason. | |
| 2353 | let dismissalPage = 0; | |
| 2354 | while (dismissalCursor !== null && dismissalPage < REVIEW_THREAD_PAGES) { | |
| 2355 | const read: { | |
| 2356 | readonly dismissalsByReviewId: ReadonlyMap<string, string>; | |
| 2357 | readonly nextCursor: string | null; | |
| 2358 | } = yield* graphqlRead({ | |
| 2359 | cwd: input.cwd, | |
| 2360 | host: input.host, | |
| 2361 | operation: "listReviewThreadComments", | |
| 2362 | variables: { owner, name, number: input.number, cursor: dismissalCursor }, | |
| 2363 | query: REVIEW_DISMISSALS_GRAPHQL_QUERY, | |
| 2364 | decode: decodeReviewDismissalsJson, | |
| 2365 | }); | |
| 2366 | for (const [id, message] of read.dismissalsByReviewId) | |
| 2367 | dismissalsByReviewId.set(id, message); | |
| 2368 | dismissalCursor = read.nextCursor; | |
| 2369 | dismissalPage += 1; | |
| 2370 | } | |
| 2371 | ||
| 2372 | const reviewThreads = entries.map((entry) => ({ | |
| 2373 | ...entry.thread, | |
| 2374 | commentCount: entry.commentCount, | |
| 2375 | ...(entry.nextCommentCursor === null | |
| 2376 | ? {} | |
| 2377 | : { nextCommentsCursor: entry.nextCommentCursor }), | |
| 2378 | })); | |
| 2379 | return { | |
| 2380 | comments: reviewThreadConversation(reviewThreads), | |
| 2381 | dismissalsByReviewId, | |
| 2382 | reviewThreads, | |
| 2383 | // GitHub's own count of each thread, so the number the page shows is the host's even | |
| 2384 | // where a bound kept some of the words on GitHub. | |
| 2385 | commentCount: entries.reduce((total, entry) => total + entry.commentCount, 0), | |
| 2386 | truncated: cursor !== null || entries.some((entry) => entry.nextCommentCursor !== null), | |
| 2387 | reviewThreadsTruncated: cursor !== null, | |
| 2388 | reactions, | |
| 2389 | reactionsById, | |
| 2390 | editedAtById, | |
| 2391 | reviewers, | |
| 2392 | avatarsByLogin, | |
| 2393 | botLogins, | |
| 2394 | commitStats, | |
| 2395 | commits, | |
| 2396 | viewer, | |
| 2397 | }; | |
| 2398 | }), | |
| 2399 | ||
| 2400 | listActorAvatars: (input) => { | |
| 2401 | if (input.ids.length === 0) { | |
| 2402 | return Effect.succeed(new Map<string, string>()); | |
| 2403 | } | |
| 2404 | return graphqlRead({ | |
| 2405 | cwd: input.cwd, | |
| 2406 | host: input.host, | |
| 2407 | operation: "listActorAvatars", | |
| 2408 | variables: { ids: input.ids }, | |
| 2409 | query: ACTOR_AVATARS_GRAPHQL_QUERY, | |
| 2410 | decode: decodeActorAvatarsJson, | |
| 2411 | }); | |
| 2412 | }, | |
| 2413 | ||
| 2414 | getViewerAccess: (input) => { | |
| 2415 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2416 | return graphqlRead({ | |
| 2417 | cwd: input.cwd, | |
| 2418 | host: input.host, | |
| 2419 | operation: "getViewerAccess", | |
| 2420 | ...(input.allowReserve === true ? { allowReserve: true } : {}), | |
| 2421 | variables: { owner, name, number: input.number }, | |
| 2422 | query: VIEWER_PERMISSIONS_GRAPHQL_QUERY, | |
| 2423 | decode: decodeViewerPermissionsJson, | |
| 2424 | }); | |
| 2425 | }, | |
| 2426 | ||
| 2427 | listReviewerCandidates: (input) => { | |
| 2428 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2429 | return graphqlRead({ | |
| 2430 | cwd: input.cwd, | |
| 2431 | host: input.host, | |
| 2432 | operation: "listReviewerCandidates", | |
| 2433 | allowReserve: true, | |
| 2434 | variables: { owner, name, number: input.number }, | |
| 2435 | query: REVIEWER_CANDIDATES_GRAPHQL_QUERY, | |
| 2436 | decode: decodeReviewerCandidatesJson, | |
| 2437 | }); | |
| 2438 | }, | |
| 2439 | ||
| 2440 | setReviewerRequest: (input) => { | |
| 2441 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2442 | // Posting to a login GitHub has already been asked about is what a re-request is, so | |
| 2443 | // there is nothing to say here about somebody who has reviewed once already. | |
| 2444 | return api | |
| 2445 | .rest({ | |
| 2446 | host: input.host, | |
| 2447 | operation: "setReviewerRequest", | |
| 2448 | method: input.requested ? "POST" : "DELETE", | |
| 2449 | path: `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`, | |
| 2450 | body: buildReviewerRequest(input.reviewers), | |
| 2451 | }) | |
| 2452 | .pipe(Effect.asVoid); | |
| 2453 | }, | |
| 2454 | ||
| 2455 | listLabelCandidates: (input) => { | |
| 2456 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2457 | return graphqlRead({ | |
| 2458 | cwd: input.cwd, | |
| 2459 | host: input.host, | |
| 2460 | operation: "listLabelCandidates", | |
| 2461 | allowReserve: true, | |
| 2462 | variables: { owner, name, number: input.number }, | |
| 2463 | query: LABEL_CANDIDATES_GRAPHQL_QUERY, | |
| 2464 | decode: decodeLabelCandidatesJson, | |
| 2465 | }); | |
| 2466 | }, | |
| 2467 | ||
| 2468 | setLabels: (input) => { | |
| 2469 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2470 | // A pull request is an issue to the labels API. Adding posts a list and leaves what was | |
| 2471 | // already there; taking off is one delete per label, since the endpoint names one in its | |
| 2472 | // path. The name goes into the path encoded, because a label may carry a space or a slash. | |
| 2473 | const issue = `repos/${owner}/${name}/issues/${input.number}/labels`; | |
| 2474 | if (input.applied) { | |
| 2475 | return api | |
| 2476 | .rest({ | |
| 2477 | host: input.host, | |
| 2478 | operation: "setLabels", | |
| 2479 | method: "POST", | |
| 2480 | path: issue, | |
| 2481 | body: buildLabelRequest(input.labels), | |
| 2482 | }) | |
| 2483 | .pipe(Effect.asVoid); | |
| 2484 | } | |
| 2485 | return Effect.forEach( | |
| 2486 | input.labels, | |
| 2487 | (label) => | |
| 2488 | api.rest({ | |
| 2489 | host: input.host, | |
| 2490 | operation: "setLabels", | |
| 2491 | method: "DELETE", | |
| 2492 | path: `${issue}/${encodeURIComponent(label)}`, | |
| 2493 | }), | |
| 2494 | { concurrency: 1, discard: true }, | |
| 2495 | ); | |
| 2496 | }, | |
| 2497 | ||
| 2498 | runPullRequestAction: (input) => { | |
| 2499 | if (input.stackNumber !== undefined) | |
| 2500 | return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( | |
| 2501 | Effect.provideService(GitHubApi.GitHubApi, api), | |
| 2502 | Effect.provideService(VcsProcess.VcsProcess, vcsProcess), | |
| 2503 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 2504 | ); | |
| 2505 | if (input.action === "revert") { | |
| 2506 | return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( | |
| 2507 | Effect.flatMap((pullRequestId) => | |
| 2508 | graphql({ | |
| 2509 | host: input.host, | |
| 2510 | operation: "revertPullRequest", | |
| 2511 | query: REVERT_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 2512 | variables: { pullRequestId }, | |
| 2513 | }), | |
| 2514 | ), | |
| 2515 | ); | |
| 2516 | } | |
| 2517 | if (input.action === "approve-workflows") { | |
| 2518 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2519 | return getPullRequestDetail(input).pipe( | |
| 2520 | Effect.flatMap((detail) => { | |
| 2521 | if (detail.isCrossRepository !== true) return Effect.void; | |
| 2522 | if (detail.headSha == null || detail.headRepositoryOwner == null) { | |
| 2523 | return Effect.fail( | |
| 2524 | new GitHubWorkflowApprovalHeadUnavailableError({ | |
| 2525 | command: "gh", | |
| 2526 | cwd: input.cwd, | |
| 2527 | number: input.number, | |
| 2528 | }), | |
| 2529 | ); | |
| 2530 | } | |
| 2531 | const expectedHeadSha = detail.headSha; | |
| 2532 | const expectedHeadBranch = detail.headBranch; | |
| 2533 | const expectedHeadRepositoryOwner = detail.headRepositoryOwner; | |
| 2534 | return listWorkflowRunsRequiringApproval({ | |
| 2535 | ...input, | |
| 2536 | headSha: expectedHeadSha, | |
| 2537 | headBranch: expectedHeadBranch, | |
| 2538 | headRepositoryOwner: expectedHeadRepositoryOwner, | |
| 2539 | isCrossRepository: true, | |
| 2540 | }).pipe( | |
| 2541 | Effect.flatMap((runs) => | |
| 2542 | Effect.forEach( | |
| 2543 | runs, | |
| 2544 | (run) => | |
| 2545 | getPullRequestDetail(input).pipe( | |
| 2546 | Effect.flatMap((current) => { | |
| 2547 | if (current.headSha == null || current.headRepositoryOwner == null) { | |
| 2548 | return Effect.fail( | |
| 2549 | new GitHubWorkflowApprovalHeadUnavailableError({ | |
| 2550 | command: "gh", | |
| 2551 | cwd: input.cwd, | |
| 2552 | number: input.number, | |
| 2553 | }), | |
| 2554 | ); | |
| 2555 | } | |
| 2556 | if ( | |
| 2557 | current.isCrossRepository !== true || | |
| 2558 | current.headSha !== expectedHeadSha || | |
| 2559 | current.headBranch !== expectedHeadBranch || | |
| 2560 | current.headRepositoryOwner.toLowerCase() !== | |
| 2561 | expectedHeadRepositoryOwner.toLowerCase() | |
| 2562 | ) { | |
| 2563 | return Effect.fail( | |
| 2564 | new GitHubWorkflowApprovalHeadChangedError({ | |
| 2565 | command: "gh", | |
| 2566 | cwd: input.cwd, | |
| 2567 | number: input.number, | |
| 2568 | }), | |
| 2569 | ); | |
| 2570 | } | |
| 2571 | return listWorkflowRunsRequiringApproval({ | |
| 2572 | ...input, | |
| 2573 | headSha: current.headSha, | |
| 2574 | headBranch: current.headBranch, | |
| 2575 | headRepositoryOwner: current.headRepositoryOwner, | |
| 2576 | isCrossRepository: true, | |
| 2577 | }); | |
| 2578 | }), | |
| 2579 | Effect.flatMap((currentRuns) => | |
| 2580 | currentRuns.some((current) => current.id === run.id) | |
| 2581 | ? api | |
| 2582 | .rest({ | |
| 2583 | host: input.host, | |
| 2584 | operation: "approveWorkflowRun", | |
| 2585 | method: "POST", | |
| 2586 | path: `repos/${owner}/${name}/actions/runs/${run.id}/approve`, | |
| 2587 | }) | |
| 2588 | .pipe(Effect.asVoid) | |
| 2589 | : Effect.void, | |
| 2590 | ), | |
| 2591 | ), | |
| 2592 | { concurrency: 1, discard: true }, | |
| 2593 | ), | |
| 2594 | ), | |
| 2595 | ); | |
| 2596 | }), | |
| 2597 | ); | |
| 2598 | } | |
| 2599 | const action = input.action; | |
| 2600 | if (action in SIMPLE_ACTION_MUTATIONS) { | |
| 2601 | return pullRequestNodeId({ ...input, operation: "runPullRequestAction" }).pipe( | |
| 2602 | Effect.flatMap((pullRequestId) => | |
| 2603 | graphql({ | |
| 2604 | host: input.host, | |
| 2605 | operation: "runPullRequestAction", | |
| 2606 | query: SIMPLE_ACTION_MUTATIONS[action as keyof typeof SIMPLE_ACTION_MUTATIONS], | |
| 2607 | variables: { pullRequestId }, | |
| 2608 | }), | |
| 2609 | ), | |
| 2610 | ); | |
| 2611 | } | |
| 2612 | return Effect.gen(function* () { | |
| 2613 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2614 | // Read fresh rather than from the node id cache: merging and updating act on the head | |
| 2615 | // as it stands now, and the merge queue decides which mutation a merge is. | |
| 2616 | const state = yield* graphqlRead({ | |
| 2617 | cwd: input.cwd, | |
| 2618 | host: input.host, | |
| 2619 | operation: "runPullRequestAction", | |
| 2620 | allowReserve: true, | |
| 2621 | query: ACTION_STATE_GRAPHQL_QUERY, | |
| 2622 | variables: { | |
| 2623 | owner, | |
| 2624 | name, | |
| 2625 | number: input.number, | |
| 2626 | headRef: `refs/pull/${input.number}/head`, | |
| 2627 | }, | |
| 2628 | decode: decodeActionState, | |
| 2629 | }); | |
| 2630 | if (action === "update-branch") { | |
| 2631 | // Already current is done, the way `gh pr update-branch` reports it. | |
| 2632 | if (state.baseRef?.compare?.behindBy === 0) return; | |
| 2633 | // GitHub updates with a merge commit unless asked to rebase, which is its own default. | |
| 2634 | return yield* graphql({ | |
| 2635 | host: input.host, | |
| 2636 | operation: "runPullRequestAction", | |
| 2637 | query: UPDATE_BRANCH_GRAPHQL_MUTATION, | |
| 2638 | variables: { | |
| 2639 | pullRequestId: state.id, | |
| 2640 | expectedHeadOid: state.headRefOid, | |
| 2641 | updateMethod: input.updateMethod === "rebase" ? "REBASE" : "MERGE", | |
| 2642 | }, | |
| 2643 | }); | |
| 2644 | } | |
| 2645 | let body: string | undefined; | |
| 2646 | let expectedHead: string | undefined; | |
| 2647 | if (input.removeAgentCreditsOnMerge === true && input.mergeMethod !== "rebase") { | |
| 2648 | const message = yield* graphqlRead({ | |
| 2649 | cwd: input.cwd, | |
| 2650 | host: input.host, | |
| 2651 | operation: "runPullRequestAction", | |
| 2652 | allowReserve: true, | |
| 2653 | query: MERGE_MESSAGE_GRAPHQL_QUERY, | |
| 2654 | variables: { | |
| 2655 | owner, | |
| 2656 | name, | |
| 2657 | number: input.number, | |
| 2658 | method: input.mergeMethod === "squash" ? "SQUASH" : "MERGE", | |
| 2659 | }, | |
| 2660 | decode: decodeMergeMessage, | |
| 2661 | }); | |
| 2662 | // GitHub's merge queue chooses its own message and ignores custom text. | |
| 2663 | if (!message.isMergeQueueEnabled) { | |
| 2664 | const cleaned = removeAgentCredits(message.viewerMergeBodyText); | |
| 2665 | if (cleaned !== message.viewerMergeBodyText) { | |
| 2666 | body = cleaned; | |
| 2667 | expectedHead = message.headRefOid; | |
| 2668 | } | |
| 2669 | } | |
| 2670 | } | |
| 2671 | // A merge queue takes a pull request through auto-merge rather than a direct merge, and | |
| 2672 | // `--auto` on a pull request that is mergeable right now simply merges it, as `gh` does. | |
| 2673 | // GitHub stores the strategy with a standing instruction rather than choosing one at | |
| 2674 | // merge time, so arming still names it. | |
| 2675 | const auto = | |
| 2676 | state.isMergeQueueEnabled === true || | |
| 2677 | (action === "enable-auto-merge" && | |
| 2678 | !IMMEDIATELY_MERGEABLE.has(state.mergeStateStatus?.toUpperCase() ?? "")); | |
| 2679 | yield* graphql({ | |
| 2680 | host: input.host, | |
| 2681 | operation: "runPullRequestAction", | |
| 2682 | query: auto ? ENABLE_AUTO_MERGE_GRAPHQL_MUTATION : MERGE_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 2683 | variables: { | |
| 2684 | input: { | |
| 2685 | pullRequestId: state.id, | |
| 2686 | mergeMethod: GRAPHQL_MERGE_METHODS[input.mergeMethod ?? "merge"], | |
| 2687 | ...(expectedHead === undefined ? {} : { expectedHeadOid: expectedHead }), | |
| 2688 | ...(body === undefined ? {} : { commitBody: body }), | |
| 2689 | }, | |
| 2690 | }, | |
| 2691 | }); | |
| 2692 | }); | |
| 2693 | }, | |
| 2694 | ||
| 2695 | commentOnPullRequest: (input) => | |
| 2696 | pullRequestNodeId({ ...input, operation: "commentOnPullRequest" }).pipe( | |
| 2697 | Effect.flatMap((subjectId) => | |
| 2698 | graphql({ | |
| 2699 | host: input.host, | |
| 2700 | operation: "commentOnPullRequest", | |
| 2701 | query: ADD_COMMENT_GRAPHQL_MUTATION, | |
| 2702 | variables: { subjectId, body: input.body }, | |
| 2703 | }), | |
| 2704 | ), | |
| 2705 | ), | |
| 2706 | ||
| 2707 | submitReview: (input) => { | |
| 2708 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2709 | // The whole review is one request, so nothing is visible to anyone else until the verdict | |
| 2710 | // is sent. | |
| 2711 | return api | |
| 2712 | .rest({ | |
| 2713 | host: input.host, | |
| 2714 | operation: "submitReview", | |
| 2715 | method: "POST", | |
| 2716 | path: `repos/${owner}/${name}/pulls/${input.number}/reviews`, | |
| 2717 | body: buildReviewSubmission({ | |
| 2718 | verdict: input.verdict, | |
| 2719 | body: input.body, | |
| 2720 | comments: input.comments, | |
| 2721 | }), | |
| 2722 | }) | |
| 2723 | .pipe(Effect.asVoid); | |
| 2724 | }, | |
| 2725 | ||
| 2726 | replyToReviewThread: (input) => | |
| 2727 | graphql({ | |
| 2728 | host: input.host, | |
| 2729 | operation: "replyToReviewThread", | |
| 2730 | query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, | |
| 2731 | variables: { threadId: input.threadId, body: input.body }, | |
| 2732 | }), | |
| 2733 | ||
| 2734 | getPullRequestFilesViewed: (input) => { | |
| 2735 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 2736 | const read = ( | |
| 2737 | after: string | null, | |
| 2738 | collected: ReadonlyArray<PullRequestFileViewed>, | |
| 2739 | pagesLeft: number, | |
| 2740 | ): Effect.Effect<GitHubPullRequestFilesViewed, GitHubPullRequestCliError> => | |
| 2741 | graphqlRead({ | |
| 2742 | cwd: input.cwd, | |
| 2743 | host: input.host, | |
| 2744 | operation: "getPullRequestFilesViewed", | |
| 2745 | variables: { owner, name, number: input.number, after }, | |
| 2746 | query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, | |
| 2747 | decode: decodePullRequestFilesViewedJson, | |
| 2748 | }).pipe( | |
| 2749 | Effect.flatMap((page) => { | |
| 2750 | const files = [...collected, ...page.files]; | |
| 2751 | if (page.nextCursor === null) { | |
| 2752 | return Effect.succeed({ files, truncated: false }); | |
| 2753 | } | |
| 2754 | return pagesLeft <= 1 | |
| 2755 | ? Effect.succeed({ files, truncated: true }) | |
| 2756 | : read(page.nextCursor, files, pagesLeft - 1); | |
| 2757 | }), | |
| 2758 | ); | |
| 2759 | return read(null, [], FILES_VIEWED_MAX_PAGES); | |
| 2760 | }, | |
| 2761 | ||
| 2762 | setPullRequestFilesViewed: (input) => { | |
| 2763 | const mutation = buildSetFilesViewedGraphQlMutation(input.files); | |
| 2764 | if (mutation === null) return Effect.void; | |
| 2765 | return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe( | |
| 2766 | Effect.flatMap((pullRequestId) => | |
| 2767 | graphql({ | |
| 2768 | host: input.host, | |
| 2769 | operation: "setPullRequestFilesViewed", | |
| 2770 | query: mutation.query, | |
| 2771 | variables: { pullRequestId, ...mutation.variables }, | |
| 2772 | }), | |
| 2773 | ), | |
| 2774 | ); | |
| 2775 | }, | |
| 2776 | ||
| 2777 | setReviewThreadResolution: (input) => | |
| 2778 | graphql({ | |
| 2779 | host: input.host, | |
| 2780 | operation: "setReviewThreadResolution", | |
| 2781 | query: input.resolved | |
| 2782 | ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION | |
| 2783 | : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, | |
| 2784 | variables: { threadId: input.threadId }, | |
| 2785 | }), | |
| 2786 | ||
| 2787 | setReaction: (input) => { | |
| 2788 | const givenSubjectId = input.subjectId; | |
| 2789 | const subjectId = | |
| 2790 | givenSubjectId === undefined | |
| 2791 | ? pullRequestNodeId({ ...input, operation: "setReaction" }) | |
| 2792 | : subjectBelongsToPullRequest({ | |
| 2793 | ...input, | |
| 2794 | subjectId: givenSubjectId, | |
| 2795 | operation: "setReaction", | |
| 2796 | }).pipe( | |
| 2797 | Effect.flatMap((belongs) => | |
| 2798 | belongs | |
| 2799 | ? Effect.succeed(givenSubjectId) | |
| 2800 | : Effect.fail( | |
| 2801 | new GitHubSubjectScopeError({ | |
| 2802 | command: "gh", | |
| 2803 | cwd: input.cwd, | |
| 2804 | operation: "setReaction", | |
| 2805 | }), | |
| 2806 | ), | |
| 2807 | ), | |
| 2808 | ); | |
| 2809 | return subjectId.pipe( | |
| 2810 | Effect.flatMap((subjectId) => | |
| 2811 | graphql({ | |
| 2812 | host: input.host, | |
| 2813 | operation: "setReaction", | |
| 2814 | query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION, | |
| 2815 | variables: { subjectId, content: gitHubReactionContent(input.content) }, | |
| 2816 | }), | |
| 2817 | ), | |
| 2818 | ); | |
| 2819 | }, | |
| 2820 | ||
| 2821 | updatePullRequest: (input) => | |
| 2822 | pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe( | |
| 2823 | Effect.flatMap((pullRequestId) => | |
| 2824 | graphql({ | |
| 2825 | host: input.host, | |
| 2826 | operation: "updatePullRequest", | |
| 2827 | query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 2828 | // A field the caller did not name is left out of the request entirely, so GitHub | |
| 2829 | // keeps the words that are there rather than being asked for an empty one. | |
| 2830 | variables: { | |
| 2831 | pullRequestId, | |
| 2832 | ...(input.title === undefined ? {} : { title: input.title }), | |
| 2833 | ...(input.body === undefined ? {} : { body: input.body }), | |
| 2834 | }, | |
| 2835 | }), | |
| 2836 | ), | |
| 2837 | ), | |
| 2838 | ||
| 2839 | updateComment: (input) => | |
| 2840 | subjectBelongsToPullRequest({ | |
| 2841 | cwd: input.cwd, | |
| 2842 | repository: input.repository, | |
| 2843 | host: input.host, | |
| 2844 | number: input.number, | |
| 2845 | subjectId: input.commentId, | |
| 2846 | operation: "updateComment", | |
| 2847 | }).pipe( | |
| 2848 | Effect.flatMap((belongs) => | |
| 2849 | belongs | |
| 2850 | ? Effect.succeed(input.commentId) | |
| 2851 | : Effect.fail( | |
| 2852 | new GitHubSubjectScopeError({ | |
| 2853 | command: "gh", | |
| 2854 | cwd: input.cwd, | |
| 2855 | operation: "updateComment", | |
| 2856 | }), | |
| 2857 | ), | |
| 2858 | ), | |
| 2859 | Effect.flatMap((commentId) => | |
| 2860 | graphql({ | |
| 2861 | host: input.host, | |
| 2862 | operation: "updateComment", | |
| 2863 | query: | |
| 2864 | input.kind === "issue-comment" | |
| 2865 | ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION | |
| 2866 | : UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, | |
| 2867 | variables: { commentId, body: input.body }, | |
| 2868 | }), | |
| 2869 | ), | |
| 2870 | ), | |
| 2871 | }); | |
| 2872 | }); | |
| 2873 | ||
| 2874 | export const layer = Layer.effect(GitHubPullRequestCli, make); |
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+25-29| ... | ... | @@ -7,8 +7,8 @@ import type { PullRequestReaction } from "@t3tools/contracts"; |
| 7 | 7 | |
| 8 | 8 | import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; |
| 9 | 9 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 10 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | |
| 11 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; | |
| 10 | import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; | |
| 11 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; | |
| 12 | 12 | import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts"; |
| 13 | 13 | import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; |
| 14 | 14 | import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; |
| ... | ... | @@ -31,13 +31,12 @@ it.effect("maps credential verification failures without relabeling operation fa |
| 31 | 31 | let operations = 0; |
| 32 | 32 | const provider = yield* make.pipe( |
| 33 | 33 | Effect.provide( |
| 34 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 34 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 35 | 35 | revalidateChecks: (_input, read) => read, |
| 36 | 36 | withVerifiedCredential: (_input, use) => |
| 37 | 37 | verificationFails |
| 38 | 38 | ? Effect.fail( |
| 39 | new GitHubPullRequestCli.GitHubViewerLoginUnavailableError({ | |
| 40 | command: "gh", | |
| 39 | new GitHubPullRequestApi.GitHubViewerLoginUnavailableError({ | |
| 41 | 40 | cwd: "/w", |
| 42 | 41 | }), |
| 43 | 42 | ) |
| ... | ... | @@ -76,7 +75,7 @@ it.effect("refreshes checks without permissions or comparison reads", () => |
| 76 | 75 | ); |
| 77 | 76 | const provider = yield* make.pipe( |
| 78 | 77 | Effect.provide( |
| 79 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 78 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 80 | 79 | revalidateChecks: (_input, read) => read, |
| 81 | 80 | getPullRequestDetail: () => |
| 82 | 81 | Effect.sync(() => { |
| ... | ... | @@ -119,7 +118,7 @@ it.effect("uses one narrow read for a linked pull request summary", () => |
| 119 | 118 | let summaryReads = 0; |
| 120 | 119 | const provider = yield* make.pipe( |
| 121 | 120 | Effect.provide( |
| 122 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 121 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 123 | 122 | revalidateChecks: (_input, read) => read, |
| 124 | 123 | getPullRequestSummary: () => |
| 125 | 124 | Effect.sync(() => { |
| ... | ... | @@ -169,7 +168,7 @@ it.effect("declares host-native stacks and passes the one the CLI reads through" |
| 169 | 168 | }; |
| 170 | 169 | const provider = yield* make.pipe( |
| 171 | 170 | Effect.provide( |
| 172 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 171 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 173 | 172 | revalidateChecks: (_input, read) => read, |
| 174 | 173 | getPullRequestStack: (input) => Effect.succeed(input.number === 7 ? stack : null), |
| 175 | 174 | }), |
| ... | ... | @@ -189,12 +188,11 @@ it.effect("reports a failed stack read against its own operation", () => |
| 189 | 188 | Effect.gen(function* () { |
| 190 | 189 | const provider = yield* make.pipe( |
| 191 | 190 | Effect.provide( |
| 192 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 191 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 193 | 192 | revalidateChecks: (_input, read) => read, |
| 194 | 193 | getPullRequestStack: () => |
| 195 | 194 | Effect.fail( |
| 196 | new GitHubPullRequestCli.GitHubPullRequestReadError({ | |
| 197 | command: "gh", | |
| 195 | new GitHubPullRequestApi.GitHubPullRequestReadError({ | |
| 198 | 196 | cwd: "/w", |
| 199 | 197 | operation: "getPullRequestStack", |
| 200 | 198 | cause: new Error("unreadable"), |
| ... | ... | @@ -343,7 +341,7 @@ describe("gitHubViewerPermissions", () => { |
| 343 | 341 | } |
| 344 | 342 | }).pipe( |
| 345 | 343 | Effect.provide( |
| 346 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 344 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 347 | 345 | revalidateChecks: (_input, read) => read, |
| 348 | 346 | getPullRequestDetail: () => |
| 349 | 347 | Effect.succeed({ |
| ... | ... | @@ -437,7 +435,7 @@ describe("gitHubViewerPermissions", () => { |
| 437 | 435 | ]); |
| 438 | 436 | }).pipe( |
| 439 | 437 | Effect.provide( |
| 440 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 438 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 441 | 439 | revalidateChecks: (_input, read) => read, |
| 442 | 440 | getPullRequestDetail: () => |
| 443 | 441 | Effect.succeed({ |
| ... | ... | @@ -541,7 +539,7 @@ it.effect( |
| 541 | 539 | Effect.gen(function* () { |
| 542 | 540 | const provider = yield* make.pipe( |
| 543 | 541 | Effect.provide( |
| 544 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 542 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 545 | 543 | getPullRequestDetail: () => |
| 546 | 544 | Effect.succeed({ ...openDetail, comparison: { behindBy: 2, viewerCanUpdate: true } }), |
| 547 | 545 | listWorkflowRunsRequiringApproval: () => |
| ... | ... | @@ -582,7 +580,7 @@ it.effect("does not classify same-repository gates as fork workflow approvals", |
| 582 | 580 | expect(detail.checks).toEqual([]); |
| 583 | 581 | }).pipe( |
| 584 | 582 | Effect.provide( |
| 585 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 583 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 586 | 584 | revalidateChecks: (_input, read) => read, |
| 587 | 585 | getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }), |
| 588 | 586 | listWorkflowRunsRequiringApproval: () => |
| ... | ... | @@ -621,13 +619,12 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () => |
| 621 | 619 | ]); |
| 622 | 620 | }).pipe( |
| 623 | 621 | Effect.provide( |
| 624 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 622 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 625 | 623 | revalidateChecks: (_input, read) => read, |
| 626 | 624 | getPullRequestDetail: () => Effect.succeed(openDetail), |
| 627 | 625 | listWorkflowRunsRequiringApproval: () => |
| 628 | 626 | Effect.fail( |
| 629 | new GitHubPullRequestCli.GitHubWorkflowApprovalRefusedError({ | |
| 630 | command: "gh", | |
| 627 | new GitHubPullRequestApi.GitHubWorkflowApprovalRefusedError({ | |
| 631 | 628 | cwd: "/w", |
| 632 | 629 | number: 7, |
| 633 | 630 | reason: "head-not-unique", |
| ... | ... | @@ -664,7 +661,7 @@ it.effect("propagates workflow discovery rate limits", () => |
| 664 | 661 | expect(error.reason).toBe("rate-limited"); |
| 665 | 662 | }).pipe( |
| 666 | 663 | Effect.provide( |
| 667 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 664 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 668 | 665 | revalidateChecks: (_input, read) => read, |
| 669 | 666 | getPullRequestDetail: () => Effect.succeed(openDetail), |
| 670 | 667 | listWorkflowRunsRequiringApproval: () => |
| ... | ... | @@ -713,7 +710,7 @@ describe("getViewerPermissions", () => { |
| 713 | 710 | expect(permissions.actions).not.toContain("update-branch"); |
| 714 | 711 | }).pipe( |
| 715 | 712 | Effect.provide( |
| 716 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 713 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 717 | 714 | revalidateChecks: (_input, read) => read, |
| 718 | 715 | getPullRequestDetail: () => Effect.die("Unexpected detail read"), |
| 719 | 716 | getViewerAccess: () => Effect.sync(() => (accessReads++, access)), |
| ... | ... | @@ -723,13 +720,13 @@ describe("getViewerPermissions", () => { |
| 723 | 720 | }); |
| 724 | 721 | |
| 725 | 722 | const layerWithDetail = ( |
| 726 | detail: Effect.Effect<GitHubPullRequestCore, GitHubPullRequestCli.GitHubPullRequestCliError>, | |
| 723 | detail: Effect.Effect<GitHubPullRequestCore, GitHubPullRequestApi.GitHubPullRequestApiError>, | |
| 727 | 724 | onDetail: (allowReserve: boolean) => void = () => {}, |
| 728 | 725 | ) => |
| 729 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 726 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 730 | 727 | revalidateChecks: (_input, read) => read, |
| 731 | 728 | getPullRequestDetail: () => |
| 732 | GitHubCli.AllowGitHubReserve.pipe( | |
| 729 | GitHubRepositoryApi.AllowGitHubReserve.pipe( | |
| 733 | 730 | Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))), |
| 734 | 731 | Effect.flatMap(() => detail), |
| 735 | 732 | ), |
| ... | ... | @@ -789,12 +786,11 @@ describe("getViewerPermissions", () => { |
| 789 | 786 | expect(permissions.actions).not.toContain("update-branch"); |
| 790 | 787 | }).pipe( |
| 791 | 788 | Effect.provide( |
| 792 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 789 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 793 | 790 | revalidateChecks: (_input, read) => read, |
| 794 | 791 | getPullRequestDetail: () => |
| 795 | 792 | Effect.fail( |
| 796 | new GitHubPullRequestCli.GitHubPullRequestReadError({ | |
| 797 | command: "gh", | |
| 793 | new GitHubPullRequestApi.GitHubPullRequestReadError({ | |
| 798 | 794 | cwd: "/w", |
| 799 | 795 | operation: "getPullRequestDetail", |
| 800 | 796 | cause: new Error("head changed"), |
| ... | ... | @@ -869,7 +865,7 @@ describe("getChangeRequest commits", () => { |
| 869 | 865 | }; |
| 870 | 866 | |
| 871 | 867 | const layerWith = (commits: GitHubReviewThreadComments["commits"]) => |
| 872 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 868 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 873 | 869 | revalidateChecks: (_input, read) => read, |
| 874 | 870 | getPullRequestActivity: () => |
| 875 | 871 | Effect.succeed({ |
| ... | ... | @@ -956,7 +952,7 @@ describe("getChangeRequestActivity dismissed reviews", () => { |
| 956 | 952 | viewer: { canUpdate: true, didAuthor: false }, |
| 957 | 953 | }; |
| 958 | 954 | const layerFor = (body: string) => |
| 959 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 955 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 960 | 956 | revalidateChecks: (_input, read) => read, |
| 961 | 957 | getPullRequestActivity: () => |
| 962 | 958 | Effect.succeed({ author: null, comments: [dismissedReview(body)], commits: [] }), |
| ... | ... | @@ -1040,7 +1036,7 @@ describe("editing", () => { |
| 1040 | 1036 | ]); |
| 1041 | 1037 | }).pipe( |
| 1042 | 1038 | Effect.provide( |
| 1043 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 1039 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ | |
| 1044 | 1040 | revalidateChecks: (_input, read) => read, |
| 1045 | 1041 | updatePullRequest: (input) => Effect.sync(() => void rewrites.push(input)), |
| 1046 | 1042 | updateComment: (input) => Effect.sync(() => void rewrites.push(input)), |
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+6-6| ... | ... | @@ -7,8 +7,8 @@ import type { |
| 7 | 7 | PullRequestViewerPermissions, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | |
| 10 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | |
| 11 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; | |
| 10 | import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; | |
| 11 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; | |
| 12 | 12 | import { |
| 13 | 13 | PullRequestProviderError, |
| 14 | 14 | type PullRequestProviderFailure, |
| ... | ... | @@ -104,7 +104,7 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest |
| 104 | 104 | |
| 105 | 105 | /** The tags that mean GitHub is out of reach for this account, rather than one request failing. */ |
| 106 | 106 | export function gitHubProviderFailure( |
| 107 | error: GitHubPullRequestCli.GitHubPullRequestCliError, | |
| 107 | error: GitHubPullRequestApi.GitHubPullRequestApiError, | |
| 108 | 108 | ): PullRequestProviderFailure { |
| 109 | 109 | switch (error._tag) { |
| 110 | 110 | case "GitHubCliMissingError": |
| ... | ... | @@ -197,9 +197,9 @@ const rendersEmpty = (body: string): boolean => |
| 197 | 197 | body.replace(/<!--[\s\S]*?-->/g, "").trim().length === 0; |
| 198 | 198 | |
| 199 | 199 | export const make = Effect.gen(function* () { |
| 200 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 200 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; | |
| 201 | 201 | |
| 202 | const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) => | |
| 202 | const fail = (operation: string) => (error: GitHubPullRequestApi.GitHubPullRequestApiError) => | |
| 203 | 203 | new PullRequestProviderError({ |
| 204 | 204 | provider: "github", |
| 205 | 205 | operation, |
| ... | ... | @@ -526,7 +526,7 @@ export const make = Effect.gen(function* () { |
| 526 | 526 | // comparison, so one read usually answers what used to take three. When that heavier read |
| 527 | 527 | // fails, the light access read still answers, withholding only update-branch. |
| 528 | 528 | return cli.getPullRequestDetail(input).pipe( |
| 529 | Effect.provideService(GitHubCli.AllowGitHubReserve, true), | |
| 529 | Effect.provideService(GitHubRepositoryApi.AllowGitHubReserve, true), | |
| 530 | 530 | Effect.map((pullRequest) => |
| 531 | 531 | gitHubViewerPermissions({ |
| 532 | 532 | ...pullRequest.viewerAccess, |
apps/server/src/pullRequest/PullRequestProviderRegistry.ts+3-3| ... | ... | @@ -16,7 +16,7 @@ import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; |
| 16 | 16 | import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; |
| 17 | 17 | import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; |
| 18 | 18 | import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; |
| 19 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; | |
| 19 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; | |
| 20 | 20 | import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; |
| 21 | 21 | import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; |
| 22 | 22 | import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; |
| ... | ... | @@ -61,11 +61,11 @@ export const make = Effect.map( |
| 61 | 61 | |
| 62 | 62 | export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( |
| 63 | 63 | Layer.provide( |
| 64 | GitHubPullRequestCli.layer.pipe( | |
| 64 | GitHubPullRequestApi.layer.pipe( | |
| 65 | 65 | Layer.provide( |
| 66 | 66 | GitHubApi.layer.pipe( |
| 67 | 67 | Layer.provide(GitHubCredentials.layer), |
| 68 | // The same layers GitHubCli merges, so both share one budget and one pause per host. | |
| 68 | // The same layers GitHubRepositoryApi merges, so both share one budget and one pause per host. | |
| 69 | 69 | Layer.provide(GitHubGraphQlBudget.layer), |
| 70 | 70 | Layer.provide(SourceControlRateLimit.layer), |
| 71 | 71 | ), |
apps/server/src/pullRequest/PullRequestService.ts+1-1| ... | ... | @@ -77,7 +77,7 @@ import { |
| 77 | 77 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 78 | 78 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; |
| 79 | 79 | |
| 80 | import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; | |
| 80 | import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; | |
| 81 | 81 | import * as ProjectService from "../project/ProjectService.ts"; |
| 82 | 82 | import * as ServerSettings from "../serverSettings.ts"; |
| 83 | 83 | import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; |
apps/server/src/pullRequest/gitHubConditionalChecks.ts+2-2| ... | ... | @@ -9,7 +9,7 @@ import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts"; |
| 9 | 9 | |
| 10 | 10 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 11 | 11 | import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts"; |
| 12 | import type { GitHubPullRequestCliError } from "./GitHubPullRequestCli.ts"; | |
| 12 | import type { GitHubPullRequestApiError } from "./GitHubPullRequestApi.ts"; | |
| 13 | 13 | import type { ProviderRepositoryRef } from "./PullRequestProvider.ts"; |
| 14 | 14 | |
| 15 | 15 | const HeadSchema = Schema.Struct({ |
| ... | ... | @@ -70,7 +70,7 @@ export const makeChecksRevalidator = Effect.gen(function* () { |
| 70 | 70 | Pick<GitHubPullRequestDetail, "state" | "checks" | "headSha"> & { |
| 71 | 71 | workflowApprovalsRequired?: number; |
| 72 | 72 | }, |
| 73 | GitHubPullRequestCliError | |
| 73 | GitHubPullRequestApiError | |
| 74 | 74 | >, |
| 75 | 75 | ) => |
| 76 | 76 | Effect.gen(function* () { |
apps/server/src/server.ts+3-3| ... | ... | @@ -50,7 +50,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| 50 | 50 | import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; |
| 51 | 51 | import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; |
| 52 | 52 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; |
| 53 | import * as GitHubCli from "./sourceControl/GitHubCli.ts"; | |
| 53 | import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; | |
| 54 | 54 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; |
| 55 | 55 | import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; |
| 56 | 56 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; |
| ... | ... | @@ -270,7 +270,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p |
| 270 | 270 | Layer.mergeAll( |
| 271 | 271 | AzureDevOpsCli.layer, |
| 272 | 272 | BitbucketApi.layer, |
| 273 | GitHubCli.layer, | |
| 273 | GitHubRepositoryApi.layer, | |
| 274 | 274 | GitLabCli.layer, |
| 275 | 275 | ForgejoCli.layer, |
| 276 | 276 | ), |
| ... | ... | @@ -565,7 +565,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( |
| 565 | 565 | Layer.provideMerge(layerServerSettings), |
| 566 | 566 | // The asset route uses the registry's GitHub credential for private PR media. |
| 567 | 567 | Layer.provideMerge(layerSourceControlProviderRegistry), |
| 568 | Layer.provideMerge(GitHubCli.layer), | |
| 568 | Layer.provideMerge(GitHubRepositoryApi.layer), | |
| 569 | 569 | Layer.provideMerge(layerGit), |
| 570 | 570 | Layer.provideMerge(layerVcs), |
| 571 | 571 | Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), |
apps/server/src/sourceControl/GitHubCli.test.ts deleted-645| ... | ... | @@ -1,645 +0,0 @@ |
| 1 | import { assert, it, describe } from "@effect/vitest"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import * as TestClock from "effect/testing/TestClock"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as FileSystem from "effect/FileSystem"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import { ChildProcessSpawner } from "effect/process"; | |
| 10 | ||
| 11 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 12 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 13 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 14 | import * as GitHubCli from "./GitHubCli.ts"; | |
| 15 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 16 | ||
| 17 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 18 | ||
| 19 | const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({ | |
| 20 | exitCode: ChildProcessSpawner.ExitCode(exitCode), | |
| 21 | stdout, | |
| 22 | stderr: "", | |
| 23 | stdoutTruncated: false, | |
| 24 | stderrTruncated: false, | |
| 25 | }); | |
| 26 | ||
| 27 | const remotesOutput = (...entries: ReadonlyArray<readonly [string, string]>) => | |
| 28 | entries | |
| 29 | .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) | |
| 30 | .join("\n"); | |
| 31 | ||
| 32 | const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({ | |
| 33 | status, | |
| 34 | headers: {}, | |
| 35 | body: body === undefined ? "" : encodeJson(body), | |
| 36 | truncated: false, | |
| 37 | invalidUtf8: false, | |
| 38 | }); | |
| 39 | ||
| 40 | const node = (number: number, headRefName: string, owner = "acme") => ({ | |
| 41 | number, | |
| 42 | title: `PR ${number}`, | |
| 43 | url: `https://github.com/acme/web/pull/${number}`, | |
| 44 | baseRefName: "main", | |
| 45 | headRefName, | |
| 46 | state: "OPEN", | |
| 47 | isCrossRepository: owner !== "acme", | |
| 48 | updatedAt: "2026-01-02T00:00:00Z", | |
| 49 | headRepository: { name: "web", nameWithOwner: `${owner}/web` }, | |
| 50 | headRepositoryOwner: { login: owner }, | |
| 51 | }); | |
| 52 | ||
| 53 | /** | |
| 54 | * A GitHubCli over a mocked GitHubApi, git driver and process. `remotes` is what | |
| 55 | * `git remote -v` prints; `git` records every driver call. | |
| 56 | */ | |
| 57 | function harness(input: { | |
| 58 | readonly remotes: string; | |
| 59 | readonly api: Partial<GitHubApi.GitHubApi["Service"]>; | |
| 60 | readonly localBranches?: ReadonlyArray<string>; | |
| 61 | }) { | |
| 62 | const git: Array<readonly [string, unknown]> = []; | |
| 63 | const record = | |
| 64 | <A>(name: string, value: A) => | |
| 65 | (args: unknown) => | |
| 66 | Effect.sync(() => { | |
| 67 | git.push([name, args]); | |
| 68 | return value; | |
| 69 | }); | |
| 70 | const driver = Layer.mock(GitVcsDriver.GitVcsDriver)({ | |
| 71 | execute: (args) => | |
| 72 | Effect.sync(() => { | |
| 73 | git.push(["execute", args.args]); | |
| 74 | return processOutput(""); | |
| 75 | }), | |
| 76 | resolvePrimaryRemoteName: () => Effect.succeed("origin"), | |
| 77 | readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), | |
| 78 | ensureRemote: (args) => | |
| 79 | Effect.sync(() => { | |
| 80 | git.push(["ensureRemote", args]); | |
| 81 | return args.preferredName; | |
| 82 | }), | |
| 83 | fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args), | |
| 84 | setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args), | |
| 85 | switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never, | |
| 86 | listLocalBranchNames: () => Effect.succeed([...(input.localBranches ?? [])]), | |
| 87 | resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), | |
| 88 | }); | |
| 89 | const process = Layer.mock(VcsProcess.VcsProcess)({ | |
| 90 | run: (args) => | |
| 91 | Effect.succeed( | |
| 92 | args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), | |
| 93 | ), | |
| 94 | }); | |
| 95 | const layer = Layer.effect(GitHubCli.GitHubCli, GitHubCli.make).pipe( | |
| 96 | Layer.provide( | |
| 97 | Layer.mergeAll( | |
| 98 | driver, | |
| 99 | process, | |
| 100 | Layer.mock(GitHubApi.GitHubApi)(input.api), | |
| 101 | NodeServices.layer, | |
| 102 | ), | |
| 103 | ), | |
| 104 | ); | |
| 105 | return { layer, git }; | |
| 106 | } | |
| 107 | ||
| 108 | describe("selectGitHubBaseRepository", () => { | |
| 109 | const remotes = (...entries: ReadonlyArray<readonly [string, string]>) => | |
| 110 | entries | |
| 111 | .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) | |
| 112 | .join("\n"); | |
| 113 | const select = (input: { remotes: string; resolved?: string }) => | |
| 114 | GitHubCli.selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); | |
| 115 | ||
| 116 | it("picks the repository gh reads without a prompt", () => { | |
| 117 | assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { | |
| 118 | owner: "me", | |
| 119 | name: "web", | |
| 120 | }); | |
| 121 | const fork = remotes( | |
| 122 | ["origin", "git@github.com:me/web.git"], | |
| 123 | ["upstream", "https://github.com/Acme/Web.git"], | |
| 124 | ); | |
| 125 | assert.deepStrictEqual(select({ remotes: fork }), { owner: "acme", name: "web" }); | |
| 126 | assert.deepStrictEqual(select({ remotes: fork, resolved: "remote.origin.gh-resolved base" }), { | |
| 127 | owner: "me", | |
| 128 | name: "web", | |
| 129 | }); | |
| 130 | assert.deepStrictEqual( | |
| 131 | select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), | |
| 132 | { owner: "acme", name: "other" }, | |
| 133 | ); | |
| 134 | // gh ranks remote names in any case. | |
| 135 | assert.deepStrictEqual( | |
| 136 | select({ | |
| 137 | remotes: remotes( | |
| 138 | ["origin", "git@github.com:me/web.git"], | |
| 139 | ["Upstream", "git@github.com:acme/web.git"], | |
| 140 | ), | |
| 141 | }), | |
| 142 | { owner: "acme", name: "web" }, | |
| 143 | ); | |
| 144 | }); | |
| 145 | ||
| 146 | it("leaves gh to choose when it might weigh the remotes differently", () => { | |
| 147 | for (const input of [ | |
| 148 | { remotes: "" }, | |
| 149 | { | |
| 150 | remotes: remotes(["a", "git@github.com:me/web.git"], ["b", "git@github.com:acme/web.git"]), | |
| 151 | }, | |
| 152 | { | |
| 153 | remotes: remotes( | |
| 154 | ["origin", "git@github.com:me/web.git"], | |
| 155 | ["mirror", "git@gitlab.com:me/web.git"], | |
| 156 | ), | |
| 157 | }, | |
| 158 | { remotes: remotes(["origin", "git@github-work:me/web.git"]) }, | |
| 159 | { | |
| 160 | remotes: remotes( | |
| 161 | ["origin", "git@github.com:me/web.git"], | |
| 162 | ["Origin", "git@github.com:acme/web.git"], | |
| 163 | ), | |
| 164 | }, | |
| 165 | { | |
| 166 | remotes: remotes( | |
| 167 | ["origin", "git@github.com:me/web.git"], | |
| 168 | ["upstream", "git@github.com:acme/web.git"], | |
| 169 | ), | |
| 170 | resolved: "remote.origin.gh-resolved base\nremote.upstream.gh-resolved base", | |
| 171 | }, | |
| 172 | ]) { | |
| 173 | assert.strictEqual(select(input), null); | |
| 174 | } | |
| 175 | }); | |
| 176 | }); | |
| 177 | ||
| 178 | describe("GitHubCli repository resolution", () => { | |
| 179 | it.effect("reads the repository gh would pick from the remotes", () => { | |
| 180 | const paths: string[] = []; | |
| 181 | const { layer } = harness({ | |
| 182 | remotes: remotesOutput( | |
| 183 | ["origin", "git@github.com:me/web.git"], | |
| 184 | ["upstream", "https://github.com/acme/web.git"], | |
| 185 | ), | |
| 186 | api: { | |
| 187 | rest: (input) => | |
| 188 | Effect.sync(() => { | |
| 189 | paths.push(`${input.host} ${input.path}`); | |
| 190 | return restResponse({ | |
| 191 | full_name: "acme/web", | |
| 192 | html_url: "https://github.com/acme/web", | |
| 193 | ssh_url: "git@github.com:acme/web.git", | |
| 194 | default_branch: "trunk", | |
| 195 | }); | |
| 196 | }), | |
| 197 | }, | |
| 198 | }); | |
| 199 | return Effect.gen(function* () { | |
| 200 | const gh = yield* GitHubCli.GitHubCli; | |
| 201 | assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); | |
| 202 | assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); | |
| 203 | }).pipe(Effect.provide(layer)); | |
| 204 | }); | |
| 205 | ||
| 206 | it.effect("reads an SSH alias remote through github.com", () => { | |
| 207 | const hosts: string[] = []; | |
| 208 | const { layer } = harness({ | |
| 209 | remotes: remotesOutput(["origin", "git@github:acme/web.git"]), | |
| 210 | api: { | |
| 211 | rest: (input) => | |
| 212 | Effect.sync(() => { | |
| 213 | hosts.push(`${input.host} ${input.path}`); | |
| 214 | return restResponse({ | |
| 215 | full_name: "acme/web", | |
| 216 | html_url: "https://github.com/acme/web", | |
| 217 | ssh_url: "git@github.com:acme/web.git", | |
| 218 | default_branch: "main", | |
| 219 | }); | |
| 220 | }), | |
| 221 | }, | |
| 222 | }); | |
| 223 | return Effect.gen(function* () { | |
| 224 | const gh = yield* GitHubCli.GitHubCli; | |
| 225 | yield* gh.getDefaultBranch({ cwd: "/repo" }); | |
| 226 | // A provider's host hint for the same alias (`github` here) resolves the same way. | |
| 227 | yield* gh.getDefaultBranch({ cwd: "/repo", rateLimitHost: "github" }); | |
| 228 | assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); | |
| 229 | assert.strictEqual( | |
| 230 | GitHubCli.gitHubApiHostForRemote("git@github.example.com:a/b.git"), | |
| 231 | "github.example.com", | |
| 232 | ); | |
| 233 | assert.strictEqual(GitHubCli.gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null); | |
| 234 | }).pipe(Effect.provide(layer)); | |
| 235 | }); | |
| 236 | ||
| 237 | it.effect("fails clearly when no remote is on GitHub", () => { | |
| 238 | const { layer } = harness({ | |
| 239 | remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]), | |
| 240 | api: {}, | |
| 241 | }); | |
| 242 | return Effect.gen(function* () { | |
| 243 | const gh = yield* GitHubCli.GitHubCli; | |
| 244 | const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); | |
| 245 | assert.strictEqual(error._tag, "GitHubCliCommandError"); | |
| 246 | assert.include(String((error.cause as Error).message), "No GitHub repository"); | |
| 247 | }).pipe(Effect.provide(layer)); | |
| 248 | }); | |
| 249 | }); | |
| 250 | ||
| 251 | describe("GitHubCli.listPullRequestsByHead", () => { | |
| 252 | const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); | |
| 253 | ||
| 254 | it.effect("reads a background sweep's staggered heads in one GraphQL document", () => { | |
| 255 | const documents: Array<GitHubApi.GitHubGraphQlInput> = []; | |
| 256 | const { layer } = harness({ | |
| 257 | remotes, | |
| 258 | api: { | |
| 259 | graphql: (input) => | |
| 260 | Effect.sync(() => { | |
| 261 | documents.push(input); | |
| 262 | return encodeJson({ | |
| 263 | data: { | |
| 264 | repository: { | |
| 265 | h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] }, | |
| 266 | h1: { nodes: [] }, | |
| 267 | }, | |
| 268 | }, | |
| 269 | }); | |
| 270 | }), | |
| 271 | }, | |
| 272 | }); | |
| 273 | return Effect.gen(function* () { | |
| 274 | const gh = yield* GitHubCli.GitHubCli; | |
| 275 | const lookup = (headSelector: string) => | |
| 276 | gh | |
| 277 | .listPullRequestsByHead({ | |
| 278 | cwd: "/repo", | |
| 279 | headSelector, | |
| 280 | state: "all", | |
| 281 | limit: 100, | |
| 282 | rateLimitHost: "github.com", | |
| 283 | }) | |
| 284 | .pipe(Effect.forkChild); | |
| 285 | // Each branch's own git reads come first, so a sweep's lookups arrive spread out. | |
| 286 | const firstLookup = yield* lookup("feature/a"); | |
| 287 | yield* TestClock.adjust("200 millis"); | |
| 288 | const secondLookup = yield* lookup("feature/b"); | |
| 289 | yield* TestClock.adjust("300 millis"); | |
| 290 | const first = yield* Fiber.join(firstLookup); | |
| 291 | const second = yield* Fiber.join(secondLookup); | |
| 292 | assert.deepStrictEqual( | |
| 293 | first?.map((pr) => pr.number), | |
| 294 | [7], | |
| 295 | ); | |
| 296 | assert.strictEqual(first?.[0]?.headSha, "a".repeat(40)); | |
| 297 | assert.deepStrictEqual(second, []); | |
| 298 | assert.strictEqual(documents.length, 1); | |
| 299 | assert.include(documents[0]!.query, "headRefOid"); | |
| 300 | assert.deepStrictEqual(documents[0]!.variables, { | |
| 301 | owner: "acme", | |
| 302 | name: "web", | |
| 303 | h0: "feature/a", | |
| 304 | s0: ["OPEN", "CLOSED", "MERGED"], | |
| 305 | h1: "feature/b", | |
| 306 | s1: ["OPEN", "CLOSED", "MERGED"], | |
| 307 | }); | |
| 308 | }).pipe(Effect.provide(layer)); | |
| 309 | }); | |
| 310 | ||
| 311 | it.effect("caps a background document at twenty-five heads", () => { | |
| 312 | const headCounts: Array<number> = []; | |
| 313 | const { layer } = harness({ | |
| 314 | remotes, | |
| 315 | api: { | |
| 316 | graphql: (input) => | |
| 317 | Effect.sync(() => { | |
| 318 | const heads = Object.keys(input.variables ?? {}).filter((key) => /^h\d+$/.test(key)); | |
| 319 | headCounts.push(heads.length); | |
| 320 | return encodeJson({ | |
| 321 | data: { repository: Object.fromEntries(heads.map((key) => [key, { nodes: [] }])) }, | |
| 322 | }); | |
| 323 | }), | |
| 324 | }, | |
| 325 | }); | |
| 326 | return Effect.gen(function* () { | |
| 327 | const gh = yield* GitHubCli.GitHubCli; | |
| 328 | const lookups = yield* Effect.all( | |
| 329 | Array.from({ length: 26 }, (_, index) => | |
| 330 | gh.listPullRequestsByHead({ | |
| 331 | cwd: "/repo", | |
| 332 | headSelector: `feature/${index}`, | |
| 333 | state: "all", | |
| 334 | limit: 100, | |
| 335 | rateLimitHost: "github.com", | |
| 336 | }), | |
| 337 | ), | |
| 338 | { concurrency: "unbounded" }, | |
| 339 | ).pipe(Effect.forkChild); | |
| 340 | yield* TestClock.adjust("500 millis"); | |
| 341 | yield* Fiber.join(lookups); | |
| 342 | assert.deepStrictEqual( | |
| 343 | headCounts.toSorted((a, b) => a - b), | |
| 344 | [1, 25], | |
| 345 | ); | |
| 346 | }).pipe(Effect.provide(layer)); | |
| 347 | }); | |
| 348 | ||
| 349 | it.effect("matches an owner:branch selector on the head owner", () => { | |
| 350 | const { layer } = harness({ | |
| 351 | remotes, | |
| 352 | api: { | |
| 353 | graphql: (input) => | |
| 354 | Effect.succeed( | |
| 355 | encodeJson({ | |
| 356 | data: { | |
| 357 | repository: { | |
| 358 | h0: { | |
| 359 | nodes: | |
| 360 | input.variables?.h0 === "main" | |
| 361 | ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")] | |
| 362 | : [], | |
| 363 | }, | |
| 364 | }, | |
| 365 | }, | |
| 366 | }), | |
| 367 | ), | |
| 368 | }, | |
| 369 | }); | |
| 370 | return Effect.gen(function* () { | |
| 371 | const gh = yield* GitHubCli.GitHubCli; | |
| 372 | const open = yield* gh | |
| 373 | .listOpenPullRequests({ cwd: "/repo", headSelector: "me:main", limit: 1 }) | |
| 374 | .pipe(Effect.forkChild); | |
| 375 | yield* TestClock.adjust("50 millis"); | |
| 376 | assert.deepStrictEqual( | |
| 377 | (yield* Fiber.join(open)).map((pr) => pr.number), | |
| 378 | [8], | |
| 379 | ); | |
| 380 | }).pipe(Effect.provide(layer)); | |
| 381 | }); | |
| 382 | ||
| 383 | it.effect("maps API failures onto the errors callers handle", () => { | |
| 384 | const { layer } = harness({ | |
| 385 | remotes, | |
| 386 | api: { | |
| 387 | graphql: (input) => | |
| 388 | Effect.fail( | |
| 389 | input.variables?.h0 === "missing" | |
| 390 | ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" }) | |
| 391 | : new GitHubApi.GitHubApiRateLimitError({ | |
| 392 | host: "github.com", | |
| 393 | operation: "x", | |
| 394 | retryAt: 123, | |
| 395 | }), | |
| 396 | ), | |
| 397 | }, | |
| 398 | }); | |
| 399 | return Effect.gen(function* () { | |
| 400 | const gh = yield* GitHubCli.GitHubCli; | |
| 401 | const read = (headSelector: string) => | |
| 402 | gh | |
| 403 | .listPullRequestsByHead({ cwd: "/repo", headSelector, state: "open", limit: 1 }) | |
| 404 | .pipe(Effect.flip, Effect.forkChild); | |
| 405 | const missing = yield* read("missing"); | |
| 406 | yield* TestClock.adjust("500 millis"); | |
| 407 | assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubCliUnavailableError"); | |
| 408 | const limited = yield* read("limited"); | |
| 409 | yield* TestClock.adjust("500 millis"); | |
| 410 | const error = yield* Fiber.join(limited); | |
| 411 | assert.strictEqual(error._tag, "GitHubCliRateLimitError"); | |
| 412 | assert.propertyVal(error, "retryAt", 123); | |
| 413 | }).pipe(Effect.provide(layer)); | |
| 414 | }); | |
| 415 | }); | |
| 416 | ||
| 417 | describe("GitHubCli.getPullRequest", () => { | |
| 418 | it.effect("reads a pull request by number, and by URL on its own repository", () => { | |
| 419 | const variables: Array<unknown> = []; | |
| 420 | const { layer } = harness({ | |
| 421 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 422 | api: { | |
| 423 | graphql: (input) => | |
| 424 | Effect.sync(() => { | |
| 425 | variables.push(input.variables); | |
| 426 | return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } }); | |
| 427 | }), | |
| 428 | }, | |
| 429 | }); | |
| 430 | return Effect.gen(function* () { | |
| 431 | const gh = yield* GitHubCli.GitHubCli; | |
| 432 | assert.strictEqual((yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" })).number, 42); | |
| 433 | yield* gh.getPullRequest({ | |
| 434 | cwd: "/repo", | |
| 435 | reference: "https://github.com/other/thing/pull/42", | |
| 436 | }); | |
| 437 | assert.deepStrictEqual(variables, [ | |
| 438 | { owner: "acme", name: "web", number: 42 }, | |
| 439 | { owner: "other", name: "thing", number: 42 }, | |
| 440 | ]); | |
| 441 | }).pipe(Effect.provide(layer)); | |
| 442 | }); | |
| 443 | ||
| 444 | it.effect("fails a missing pull request as not found", () => { | |
| 445 | const { layer } = harness({ | |
| 446 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 447 | api: { | |
| 448 | graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })), | |
| 449 | }, | |
| 450 | }); | |
| 451 | return Effect.gen(function* () { | |
| 452 | const gh = yield* GitHubCli.GitHubCli; | |
| 453 | const error = yield* gh.getPullRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); | |
| 454 | assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); | |
| 455 | }).pipe(Effect.provide(layer)); | |
| 456 | }); | |
| 457 | }); | |
| 458 | ||
| 459 | describe("GitHubCli writes", () => { | |
| 460 | it.effect("creates a cross-repository pull request with an owner:branch head", () => { | |
| 461 | const requests: Array<GitHubApi.GitHubRestInput> = []; | |
| 462 | const { layer } = harness({ | |
| 463 | remotes: remotesOutput( | |
| 464 | ["origin", "git@github.com:me/web.git"], | |
| 465 | ["upstream", "git@github.com:acme/web.git"], | |
| 466 | ), | |
| 467 | api: { | |
| 468 | rest: (input) => | |
| 469 | Effect.sync(() => { | |
| 470 | requests.push(input); | |
| 471 | return restResponse({ number: 1 }, 201); | |
| 472 | }), | |
| 473 | }, | |
| 474 | }); | |
| 475 | return Effect.gen(function* () { | |
| 476 | const fs = yield* FileSystem.FileSystem; | |
| 477 | const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); | |
| 478 | yield* fs.writeFileString(bodyFile, "Body"); | |
| 479 | const gh = yield* GitHubCli.GitHubCli; | |
| 480 | yield* gh.createPullRequest({ | |
| 481 | cwd: "/repo", | |
| 482 | baseBranch: "main", | |
| 483 | headSelector: "me:feature", | |
| 484 | title: "Title", | |
| 485 | bodyFile, | |
| 486 | }); | |
| 487 | assert.strictEqual(requests[0]!.method, "POST"); | |
| 488 | assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); | |
| 489 | assert.deepStrictEqual(requests[0]!.body, { | |
| 490 | base: "main", | |
| 491 | head: "me:feature", | |
| 492 | title: "Title", | |
| 493 | body: "Body", | |
| 494 | maintainer_can_modify: true, | |
| 495 | }); | |
| 496 | }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped); | |
| 497 | }); | |
| 498 | ||
| 499 | it.effect("creates a repository under an organization the viewer is not", () => { | |
| 500 | const requests: Array<string> = []; | |
| 501 | const { layer } = harness({ | |
| 502 | remotes: "", | |
| 503 | api: { | |
| 504 | rest: (input) => | |
| 505 | Effect.sync(() => { | |
| 506 | requests.push(`${input.method ?? "GET"} ${input.path}`); | |
| 507 | return input.path === "user" | |
| 508 | ? restResponse({ login: "me" }) | |
| 509 | : restResponse({ | |
| 510 | full_name: "acme/new", | |
| 511 | html_url: "https://github.com/acme/new", | |
| 512 | ssh_url: "git@github.com:acme/new.git", | |
| 513 | }); | |
| 514 | }), | |
| 515 | }, | |
| 516 | }); | |
| 517 | return Effect.gen(function* () { | |
| 518 | const gh = yield* GitHubCli.GitHubCli; | |
| 519 | const urls = yield* gh.createRepository({ | |
| 520 | cwd: "/repo", | |
| 521 | repository: "acme/new", | |
| 522 | visibility: "private", | |
| 523 | }); | |
| 524 | assert.deepStrictEqual(urls, { | |
| 525 | nameWithOwner: "acme/new", | |
| 526 | url: "https://github.com/acme/new", | |
| 527 | sshUrl: "git@github.com:acme/new.git", | |
| 528 | }); | |
| 529 | assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]); | |
| 530 | }).pipe(Effect.provide(layer)); | |
| 531 | }); | |
| 532 | }); | |
| 533 | ||
| 534 | describe("GitHubCli.checkoutPullRequest", () => { | |
| 535 | const repository = (fullName: string, defaultBranch = "main") => | |
| 536 | restResponse({ | |
| 537 | full_name: fullName, | |
| 538 | html_url: `https://github.com/${fullName}`, | |
| 539 | ssh_url: `git@github.com:${fullName}.git`, | |
| 540 | default_branch: defaultBranch, | |
| 541 | }); | |
| 542 | ||
| 543 | it.effect("checks a same-repository pull request out from its head branch", () => { | |
| 544 | const { layer, git } = harness({ | |
| 545 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 546 | api: { | |
| 547 | graphql: () => | |
| 548 | Effect.succeed( | |
| 549 | encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), | |
| 550 | ), | |
| 551 | }, | |
| 552 | }); | |
| 553 | return Effect.gen(function* () { | |
| 554 | const gh = yield* GitHubCli.GitHubCli; | |
| 555 | yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "5" }); | |
| 556 | assert.deepStrictEqual(git, [ | |
| 557 | [ | |
| 558 | "fetchRemoteTrackingBranch", | |
| 559 | { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" }, | |
| 560 | ], | |
| 561 | ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]], | |
| 562 | ["switchRef", { cwd: "/repo", refName: "feature/x" }], | |
| 563 | [ | |
| 564 | "setBranchUpstream", | |
| 565 | { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" }, | |
| 566 | ], | |
| 567 | ]); | |
| 568 | }).pipe(Effect.provide(layer)); | |
| 569 | }); | |
| 570 | ||
| 571 | it.effect("refuses a fork checkout when the base's default branch cannot be read", () => { | |
| 572 | const { layer, git } = harness({ | |
| 573 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 574 | localBranches: ["main"], | |
| 575 | api: { | |
| 576 | graphql: () => | |
| 577 | Effect.succeed( | |
| 578 | encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), | |
| 579 | ), | |
| 580 | rest: () => | |
| 581 | Effect.fail( | |
| 582 | new GitHubApi.GitHubApiRequestError({ | |
| 583 | host: "github.com", | |
| 584 | operation: "x", | |
| 585 | cause: "offline", | |
| 586 | }), | |
| 587 | ), | |
| 588 | }, | |
| 589 | }); | |
| 590 | return Effect.gen(function* () { | |
| 591 | const gh = yield* GitHubCli.GitHubCli; | |
| 592 | yield* Effect.flip(gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true })); | |
| 593 | // Nothing touched the local branches: `main` must not be reset to the fork's commit. | |
| 594 | assert.deepStrictEqual(git, []); | |
| 595 | }).pipe(Effect.provide(layer)); | |
| 596 | }); | |
| 597 | ||
| 598 | it.effect("adds a remote for a fork and names a default-branch head after its owner", () => { | |
| 599 | const { layer, git } = harness({ | |
| 600 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 601 | localBranches: ["someone/main"], | |
| 602 | api: { | |
| 603 | graphql: () => | |
| 604 | Effect.succeed( | |
| 605 | encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), | |
| 606 | ), | |
| 607 | rest: (input) => | |
| 608 | Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")), | |
| 609 | }, | |
| 610 | }); | |
| 611 | return Effect.gen(function* () { | |
| 612 | const gh = yield* GitHubCli.GitHubCli; | |
| 613 | yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true }); | |
| 614 | assert.deepStrictEqual(git, [ | |
| 615 | [ | |
| 616 | "ensureRemote", | |
| 617 | { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" }, | |
| 618 | ], | |
| 619 | [ | |
| 620 | "fetchRemoteTrackingBranch", | |
| 621 | { cwd: "/repo", remoteName: "someone", remoteBranch: "main" }, | |
| 622 | ], | |
| 623 | ["switchRef", { cwd: "/repo", refName: "someone/main" }], | |
| 624 | ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]], | |
| 625 | [ | |
| 626 | "setBranchUpstream", | |
| 627 | { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" }, | |
| 628 | ], | |
| 629 | ]); | |
| 630 | }).pipe(Effect.provide(layer)); | |
| 631 | }); | |
| 632 | ||
| 633 | it("names the local branch the way gh pr checkout does", () => { | |
| 634 | const name = (headRefName: string, isCrossRepository: boolean) => | |
| 635 | GitHubCli.pullRequestCheckoutBranchName({ | |
| 636 | headRefName, | |
| 637 | headOwner: "someone", | |
| 638 | isCrossRepository, | |
| 639 | defaultBranch: "main", | |
| 640 | }); | |
| 641 | assert.strictEqual(name("main", true), "someone/main"); | |
| 642 | assert.strictEqual(name("feature", true), "feature"); | |
| 643 | assert.strictEqual(name("main", false), "main"); | |
| 644 | }); | |
| 645 | }); |
apps/server/src/sourceControl/GitHubCli.ts deleted-1091| ... | ... | @@ -1,1091 +0,0 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as DateTime from "effect/DateTime"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Exit from "effect/Exit"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import * as Request from "effect/Request"; | |
| 9 | import * as RequestResolver from "effect/RequestResolver"; | |
| 10 | import * as Result from "effect/Result"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | ||
| 13 | import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; | |
| 14 | import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; | |
| 15 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 16 | import { | |
| 17 | detectSourceControlProviderFromRemoteUrl, | |
| 18 | isSshRemoteUrl, | |
| 19 | } from "@t3tools/shared/sourceControl"; | |
| 20 | ||
| 21 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 22 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 23 | ||
| 24 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 25 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 26 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 27 | import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; | |
| 28 | import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; | |
| 29 | import { | |
| 30 | decodeGitHubPullRequestEntries, | |
| 31 | type NormalizedGitHubPullRequestRecord, | |
| 32 | } from "./gitHubPullRequests.ts"; | |
| 33 | ||
| 34 | export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve; | |
| 35 | ||
| 36 | const gitHubCliFailureFields = { | |
| 37 | command: Schema.Literal("gh"), | |
| 38 | cwd: Schema.String, | |
| 39 | cause: Schema.Defect(), | |
| 40 | } as const; | |
| 41 | ||
| 42 | export class GitHubCliUnavailableError extends Schema.TaggedError<GitHubCliUnavailableError>()( | |
| 43 | "GitHubCliUnavailableError", | |
| 44 | gitHubCliFailureFields, | |
| 45 | ) { | |
| 46 | override get message(): string { | |
| 47 | return "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`."; | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | export class GitHubCliAuthenticationError extends Schema.TaggedError<GitHubCliAuthenticationError>()( | |
| 52 | "GitHubCliAuthenticationError", | |
| 53 | gitHubCliFailureFields, | |
| 54 | ) { | |
| 55 | override get message(): string { | |
| 56 | // A missing or turned-off credential already says what to do about it. | |
| 57 | return GitHubCredentials.isGitHubCredentialUnavailableError(this.cause) | |
| 58 | ? this.cause.message | |
| 59 | : "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry."; | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | export class GitHubCliRateLimitError extends Schema.TaggedError<GitHubCliRateLimitError>()( | |
| 64 | "GitHubCliRateLimitError", | |
| 65 | { ...gitHubCliFailureFields, retryAt: Schema.optionalKey(Schema.Finite) }, | |
| 66 | ) { | |
| 67 | override get message(): string { | |
| 68 | return "GitHub API rate limit exceeded. Requests resume when the limit resets."; | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | export class GitHubPullRequestNotFoundError extends Schema.TaggedError<GitHubPullRequestNotFoundError>()( | |
| 73 | "GitHubPullRequestNotFoundError", | |
| 74 | gitHubCliFailureFields, | |
| 75 | ) { | |
| 76 | override get message(): string { | |
| 77 | return "Pull request not found. Check the PR number or URL and try again."; | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | export class GitHubCliCommandError extends Schema.TaggedError<GitHubCliCommandError>()( | |
| 82 | "GitHubCliCommandError", | |
| 83 | { ...gitHubCliFailureFields, httpStatus: Schema.optional(Schema.Int) }, | |
| 84 | ) { | |
| 85 | override get message(): string { | |
| 86 | // GitHub's own reason ("A pull request already exists…") or the failed step's, when known. | |
| 87 | const reason = | |
| 88 | this.cause instanceof Error && this.cause.message.trim() !== "" | |
| 89 | ? this.cause.message.trim() | |
| 90 | : null; | |
| 91 | return reason === null ? "GitHub request failed." : reason; | |
| 92 | } | |
| 93 | } | |
| 94 | ||
| 95 | const gitHubCliDecodeFields = { | |
| 96 | command: Schema.Literal("gh"), | |
| 97 | cwd: Schema.String, | |
| 98 | cause: Schema.Defect(), | |
| 99 | } as const; | |
| 100 | ||
| 101 | export class GitHubPullRequestListDecodeError extends Schema.TaggedError<GitHubPullRequestListDecodeError>()( | |
| 102 | "GitHubPullRequestListDecodeError", | |
| 103 | gitHubCliDecodeFields, | |
| 104 | ) { | |
| 105 | override get message(): string { | |
| 106 | return "GitHub returned an invalid pull request list."; | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | export class GitHubChangeRequestListDecodeError extends Schema.TaggedError<GitHubChangeRequestListDecodeError>()( | |
| 111 | "GitHubChangeRequestListDecodeError", | |
| 112 | gitHubCliDecodeFields, | |
| 113 | ) { | |
| 114 | override get message(): string { | |
| 115 | return "GitHub returned an invalid change request list."; | |
| 116 | } | |
| 117 | } | |
| 118 | ||
| 119 | export class GitHubPullRequestDecodeError extends Schema.TaggedError<GitHubPullRequestDecodeError>()( | |
| 120 | "GitHubPullRequestDecodeError", | |
| 121 | gitHubCliDecodeFields, | |
| 122 | ) { | |
| 123 | override get message(): string { | |
| 124 | return "GitHub returned an invalid pull request."; | |
| 125 | } | |
| 126 | } | |
| 127 | ||
| 128 | export class GitHubRepositoryDecodeError extends Schema.TaggedError<GitHubRepositoryDecodeError>()( | |
| 129 | "GitHubRepositoryDecodeError", | |
| 130 | gitHubCliDecodeFields, | |
| 131 | ) { | |
| 132 | override get message(): string { | |
| 133 | return "GitHub returned an invalid repository."; | |
| 134 | } | |
| 135 | } | |
| 136 | ||
| 137 | export const GitHubCliError = Schema.Union([ | |
| 138 | GitHubCliUnavailableError, | |
| 139 | GitHubCliAuthenticationError, | |
| 140 | GitHubCliRateLimitError, | |
| 141 | GitHubPullRequestNotFoundError, | |
| 142 | GitHubCliCommandError, | |
| 143 | GitHubPullRequestListDecodeError, | |
| 144 | GitHubChangeRequestListDecodeError, | |
| 145 | GitHubPullRequestDecodeError, | |
| 146 | GitHubRepositoryDecodeError, | |
| 147 | ]); | |
| 148 | export type GitHubCliError = typeof GitHubCliError.Type; | |
| 149 | ||
| 150 | export const isGitHubCliError = Schema.is(GitHubCliError); | |
| 151 | ||
| 152 | /** Maps a GitHub API failure onto the errors callers of this service already handle. */ | |
| 153 | function fromGitHubApiError(cwd: string, error: GitHubApi.GitHubApiError): GitHubCliError { | |
| 154 | const context = { command: "gh" as const, cwd, cause: error }; | |
| 155 | switch (error._tag) { | |
| 156 | case "GitHubCliMissingError": | |
| 157 | return new GitHubCliUnavailableError(context); | |
| 158 | case "GitHubNotSignedInError": | |
| 159 | case "GitHubHostDisabledError": | |
| 160 | case "GitHubApiAuthenticationError": | |
| 161 | return new GitHubCliAuthenticationError(context); | |
| 162 | case "GitHubCliFailedError": | |
| 163 | return new GitHubCliCommandError(context); | |
| 164 | case "GitHubApiRateLimitError": | |
| 165 | return new GitHubCliRateLimitError({ | |
| 166 | ...context, | |
| 167 | ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), | |
| 168 | }); | |
| 169 | case "SourceControlRateLimitPausedError": | |
| 170 | return new GitHubCliRateLimitError({ ...context, retryAt: error.retryAt }); | |
| 171 | case "GitHubApiNotFoundError": | |
| 172 | return new GitHubPullRequestNotFoundError(context); | |
| 173 | case "GitHubApiResponseError": | |
| 174 | return new GitHubCliCommandError({ ...context, httpStatus: error.status }); | |
| 175 | case "GitHubApiRequestError": | |
| 176 | return new GitHubCliCommandError(context); | |
| 177 | } | |
| 178 | } | |
| 179 | ||
| 180 | export interface GitHubPullRequestSummary { | |
| 181 | readonly number: number; | |
| 182 | readonly title: string; | |
| 183 | readonly url: string; | |
| 184 | readonly baseRefName: string; | |
| 185 | readonly headRefName: string; | |
| 186 | readonly headSha?: string; | |
| 187 | readonly state?: "open" | "closed" | "merged"; | |
| 188 | readonly isDraft?: boolean; | |
| 189 | readonly closedAt?: string | null; | |
| 190 | readonly mergedAt?: string | null; | |
| 191 | readonly updatedAt?: string; | |
| 192 | readonly isCrossRepository?: boolean; | |
| 193 | readonly headRepositoryNameWithOwner?: string | null; | |
| 194 | readonly headRepositoryOwnerLogin?: string | null; | |
| 195 | } | |
| 196 | ||
| 197 | function pullRequestSummary(input: NormalizedGitHubPullRequestRecord): GitHubPullRequestSummary { | |
| 198 | const { updatedAt, ...summary } = input; | |
| 199 | return { | |
| 200 | ...summary, | |
| 201 | ...(Option.isSome(updatedAt) ? { updatedAt: DateTime.formatIso(updatedAt.value) } : {}), | |
| 202 | }; | |
| 203 | } | |
| 204 | ||
| 205 | export interface GitHubRepositoryCloneUrls { | |
| 206 | readonly nameWithOwner: string; | |
| 207 | readonly url: string; | |
| 208 | readonly sshUrl: string; | |
| 209 | } | |
| 210 | ||
| 211 | export class GitHubCli extends Context.Service< | |
| 212 | GitHubCli, | |
| 213 | { | |
| 214 | readonly listOpenPullRequests: (input: { | |
| 215 | readonly cwd: string; | |
| 216 | readonly headSelector: string; | |
| 217 | readonly limit?: number; | |
| 218 | readonly rateLimitHost?: string; | |
| 219 | }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>; | |
| 220 | ||
| 221 | /** | |
| 222 | * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the | |
| 223 | * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive | |
| 224 | * together share one GraphQL document. | |
| 225 | */ | |
| 226 | readonly listPullRequestsByHead: (input: { | |
| 227 | readonly cwd: string; | |
| 228 | readonly headSelector: string; | |
| 229 | readonly state: "open" | "closed" | "merged" | "all"; | |
| 230 | readonly limit: number; | |
| 231 | /** The checkout's GitHub API host. Without it, the host comes from the git remotes. */ | |
| 232 | readonly rateLimitHost?: string; | |
| 233 | }) => Effect.Effect<ReadonlyArray<NormalizedGitHubPullRequestRecord>, GitHubCliError>; | |
| 234 | ||
| 235 | readonly getPullRequest: (input: { | |
| 236 | readonly cwd: string; | |
| 237 | readonly reference: string; | |
| 238 | readonly rateLimitHost?: string; | |
| 239 | }) => Effect.Effect<GitHubPullRequestSummary, GitHubCliError>; | |
| 240 | ||
| 241 | readonly getRepositoryCloneUrls: (input: { | |
| 242 | readonly cwd: string; | |
| 243 | readonly repository: string; | |
| 244 | }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>; | |
| 245 | ||
| 246 | readonly createRepository: (input: { | |
| 247 | readonly cwd: string; | |
| 248 | readonly repository: string; | |
| 249 | readonly visibility: SourceControlRepositoryVisibility; | |
| 250 | }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>; | |
| 251 | ||
| 252 | readonly createPullRequest: (input: { | |
| 253 | readonly cwd: string; | |
| 254 | readonly baseBranch: string; | |
| 255 | readonly headSelector: string; | |
| 256 | readonly title: string; | |
| 257 | readonly bodyFile: string; | |
| 258 | }) => Effect.Effect<void, GitHubCliError>; | |
| 259 | ||
| 260 | readonly getDefaultBranch: (input: { | |
| 261 | readonly cwd: string; | |
| 262 | readonly rateLimitHost?: string; | |
| 263 | }) => Effect.Effect<string | null, GitHubCliError>; | |
| 264 | ||
| 265 | readonly checkoutPullRequest: (input: { | |
| 266 | readonly cwd: string; | |
| 267 | readonly reference: string; | |
| 268 | readonly force?: boolean; | |
| 269 | }) => Effect.Effect<void, GitHubCliError>; | |
| 270 | } | |
| 271 | >()("t3/sourceControl/GitHubCli") {} | |
| 272 | ||
| 273 | /** | |
| 274 | * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a | |
| 275 | * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin | |
| 276 | * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of | |
| 277 | * `git config --get-regexp '^remote\..*\.gh-resolved$'`. | |
| 278 | * | |
| 279 | * Null whenever gh might weigh the remotes differently: a remote on another host or under an | |
| 280 | * SSH alias, more than one mark, or several remotes with none of those names. Callers then | |
| 281 | * fall back to the provider's remote, then to the best-ranked remote on the host. | |
| 282 | */ | |
| 283 | export function selectGitHubBaseRepository(input: { | |
| 284 | readonly remotes: string; | |
| 285 | readonly resolved: string; | |
| 286 | readonly host: string; | |
| 287 | }): { readonly owner: string; readonly name: string } | null { | |
| 288 | const host = input.host.toLowerCase(); | |
| 289 | const repositories = new Map<string, { readonly owner: string; readonly name: string }>(); | |
| 290 | for (const line of input.remotes.split("\n")) { | |
| 291 | const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()); | |
| 292 | if (!match) continue; | |
| 293 | const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/"); | |
| 294 | if (remoteHost !== host || !owner || !name || rest.length > 0) return null; | |
| 295 | repositories.set(match[1]!, { owner, name }); | |
| 296 | } | |
| 297 | const marks = input.resolved | |
| 298 | .split("\n") | |
| 299 | .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) | |
| 300 | .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); | |
| 301 | if (marks.length > 1) return null; | |
| 302 | const [mark] = marks; | |
| 303 | if (mark) { | |
| 304 | if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; | |
| 305 | const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); | |
| 306 | return owner && name && rest.length === 0 ? { owner, name } : null; | |
| 307 | } | |
| 308 | // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the | |
| 309 | // top place has no defined winner. | |
| 310 | const score = (remoteName: string) => | |
| 311 | ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; | |
| 312 | const ranked = [...repositories.entries()].toSorted( | |
| 313 | ([left], [right]) => score(right) - score(left), | |
| 314 | ); | |
| 315 | const [top, next] = ranked; | |
| 316 | return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) | |
| 317 | ? top[1] | |
| 318 | : null; | |
| 319 | } | |
| 320 | ||
| 321 | const RawRepositorySchema = Schema.Struct({ | |
| 322 | full_name: TrimmedNonEmptyString, | |
| 323 | html_url: TrimmedNonEmptyString, | |
| 324 | ssh_url: TrimmedNonEmptyString, | |
| 325 | default_branch: Schema.optional(Schema.NullOr(Schema.String)), | |
| 326 | }); | |
| 327 | const decodeRawRepository = decodeJsonResult(RawRepositorySchema); | |
| 328 | ||
| 329 | function repositoryCloneUrls( | |
| 330 | raw: Schema.Schema.Type<typeof RawRepositorySchema>, | |
| 331 | ): GitHubRepositoryCloneUrls { | |
| 332 | return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url }; | |
| 333 | } | |
| 334 | ||
| 335 | const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString })); | |
| 336 | ||
| 337 | type PullRequestListState = "open" | "closed" | "merged" | "all"; | |
| 338 | ||
| 339 | /** The pull request fields every read selects, in GraphQL. */ | |
| 340 | const PULL_REQUEST_NODE_SELECTION = | |
| 341 | "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; | |
| 342 | const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = { | |
| 343 | open: ["OPEN"], | |
| 344 | closed: ["CLOSED"], | |
| 345 | merged: ["MERGED"], | |
| 346 | all: ["OPEN", "CLOSED", "MERGED"], | |
| 347 | }; | |
| 348 | /** | |
| 349 | * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one | |
| 350 | * single lookup, but half that keeps each answer near half a second. | |
| 351 | */ | |
| 352 | const HEAD_LOOKUPS_PER_DOCUMENT = 50; | |
| 353 | /** | |
| 354 | * How long a head lookup waits for company. Branch discovery reaches GitHub only after each | |
| 355 | * branch's own git reads, so lookups started together arrive tens of milliseconds apart. | |
| 356 | * A background sweep's lookups spread over up to ~300ms, and every document costs a point no | |
| 357 | * matter how few heads it holds, so reads without reserve wait longer. | |
| 358 | */ | |
| 359 | const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; | |
| 360 | const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; | |
| 361 | /** | |
| 362 | * Background documents fill up under the longer window, and a failed document fails every head | |
| 363 | * in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own | |
| 364 | * processing limit; twenty-five took ~7s. | |
| 365 | */ | |
| 366 | const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25; | |
| 367 | /** A full document is 5,000 rows of well under 2 KB each. */ | |
| 368 | const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; | |
| 369 | /** | |
| 370 | * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch | |
| 371 | * named like a busy default (`main`) is the case this bounds; the owner's own row is near the | |
| 372 | * top because the newest come first. | |
| 373 | */ | |
| 374 | const OWNER_HEAD_SCAN_LIMIT = 100; | |
| 375 | ||
| 376 | class PullRequestsByHeadRead extends Request.Class< | |
| 377 | { | |
| 378 | readonly cwd: string; | |
| 379 | readonly host: string; | |
| 380 | readonly owner: string; | |
| 381 | readonly name: string; | |
| 382 | readonly headRefName: string; | |
| 383 | readonly state: PullRequestListState; | |
| 384 | readonly limit: number; | |
| 385 | readonly allowReserve: boolean; | |
| 386 | }, | |
| 387 | ReadonlyArray<NormalizedGitHubPullRequestRecord>, | |
| 388 | GitHubCliError | |
| 389 | > {} | |
| 390 | ||
| 391 | /** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ | |
| 392 | function buildPullRequestsByHeadQuery( | |
| 393 | lookups: ReadonlyArray<Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit">>, | |
| 394 | ): { readonly document: string; readonly variables: Record<string, unknown> } { | |
| 395 | const variables: Record<string, unknown> = {}; | |
| 396 | const declarations: string[] = ["$owner: String!", "$name: String!"]; | |
| 397 | const selections: string[] = []; | |
| 398 | for (const [index, lookup] of lookups.entries()) { | |
| 399 | variables[`h${index}`] = lookup.headRefName; | |
| 400 | variables[`s${index}`] = GRAPHQL_STATES[lookup.state]; | |
| 401 | declarations.push(`$h${index}: String!`, `$s${index}: [PullRequestState!]`); | |
| 402 | // `gh pr list` orders the same way, so a head with more matches than the limit keeps the | |
| 403 | // same rows. | |
| 404 | selections.push( | |
| 405 | ` h${index}: pullRequests(headRefName: $h${index}, states: $s${index}, first: ${lookup.limit}, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${PULL_REQUEST_NODE_SELECTION} } }`, | |
| 406 | ); | |
| 407 | } | |
| 408 | return { | |
| 409 | document: `query PullRequestsByHead(${declarations.join(", ")}) {\n repository(owner: $owner, name: $name) {\n${selections.join("\n")}\n }\n}`, | |
| 410 | variables, | |
| 411 | }; | |
| 412 | } | |
| 413 | ||
| 414 | const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { | |
| 415 | repository(owner: $owner, name: $name) { | |
| 416 | pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} } | |
| 417 | } | |
| 418 | }`; | |
| 419 | ||
| 420 | const decodePullRequestsByHead = decodeJsonResult( | |
| 421 | Schema.Struct({ | |
| 422 | data: Schema.Struct({ | |
| 423 | repository: Schema.NullOr( | |
| 424 | Schema.Record( | |
| 425 | Schema.String, | |
| 426 | Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), | |
| 427 | ), | |
| 428 | ), | |
| 429 | }), | |
| 430 | }), | |
| 431 | ); | |
| 432 | ||
| 433 | const decodePullRequestByNumber = decodeJsonResult( | |
| 434 | Schema.Struct({ | |
| 435 | data: Schema.Struct({ | |
| 436 | repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })), | |
| 437 | }), | |
| 438 | }), | |
| 439 | ); | |
| 440 | ||
| 441 | /** A repository on a GitHub host: the API it is read through, and its owner and name. */ | |
| 442 | export interface GitHubRepositoryLocator { | |
| 443 | readonly host: string; | |
| 444 | readonly owner: string; | |
| 445 | readonly name: string; | |
| 446 | } | |
| 447 | ||
| 448 | /** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ | |
| 449 | function parseGitHubRepositorySelector( | |
| 450 | selector: string, | |
| 451 | defaultHost: string, | |
| 452 | ): GitHubRepositoryLocator | null { | |
| 453 | const trimmed = selector.trim().replace(/\.git$/i, ""); | |
| 454 | if (/^https?:\/\//i.test(trimmed)) { | |
| 455 | try { | |
| 456 | const url = new URL(trimmed); | |
| 457 | const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean); | |
| 458 | return owner && name && rest.length === 0 | |
| 459 | ? { host: url.host.toLowerCase(), owner, name } | |
| 460 | : null; | |
| 461 | } catch { | |
| 462 | return null; | |
| 463 | } | |
| 464 | } | |
| 465 | const parts = trimmed.split("/").filter(Boolean); | |
| 466 | if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; | |
| 467 | if (parts.length === 3) | |
| 468 | return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! }; | |
| 469 | return null; | |
| 470 | } | |
| 471 | ||
| 472 | /** | |
| 473 | * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request | |
| 474 | * URL, or a branch name. | |
| 475 | */ | |
| 476 | function parsePullRequestReference( | |
| 477 | reference: string, | |
| 478 | ): | |
| 479 | | { readonly kind: "number"; readonly number: number } | |
| 480 | | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number } | |
| 481 | | { readonly kind: "branch"; readonly headSelector: string } { | |
| 482 | const trimmed = reference.trim(); | |
| 483 | const numbered = /^#?([1-9]\d*)$/.exec(trimmed); | |
| 484 | if (numbered) return { kind: "number", number: Number(numbered[1]) }; | |
| 485 | if (/^https?:\/\//i.test(trimmed)) { | |
| 486 | try { | |
| 487 | const url = new URL(trimmed); | |
| 488 | const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname); | |
| 489 | if (match) { | |
| 490 | return { | |
| 491 | kind: "url", | |
| 492 | locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! }, | |
| 493 | number: Number(match[3]), | |
| 494 | }; | |
| 495 | } | |
| 496 | } catch { | |
| 497 | // Not a URL after all; read it as a branch. | |
| 498 | } | |
| 499 | } | |
| 500 | return { kind: "branch", headSelector: trimmed }; | |
| 501 | } | |
| 502 | ||
| 503 | /** | |
| 504 | * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH | |
| 505 | * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through | |
| 506 | * `github.com`, which is what such an alias almost always stands for (issue #6198). | |
| 507 | */ | |
| 508 | export function gitHubApiHostForRemote(remoteUrl: string): string | null { | |
| 509 | const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); | |
| 510 | if (provider === null) return null; | |
| 511 | const host = new URL(provider.baseUrl).host.toLowerCase(); | |
| 512 | // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. | |
| 513 | if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { | |
| 514 | return host.includes("github") ? "github.com" : null; | |
| 515 | } | |
| 516 | return provider.kind === "github" ? host : null; | |
| 517 | } | |
| 518 | ||
| 519 | /** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ | |
| 520 | function apiHostForHint(host: string): string { | |
| 521 | const normalized = host.toLowerCase(); | |
| 522 | return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; | |
| 523 | } | |
| 524 | ||
| 525 | /** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ | |
| 526 | export function pullRequestCheckoutBranchName(input: { | |
| 527 | readonly headRefName: string; | |
| 528 | readonly headOwner: string | null; | |
| 529 | readonly isCrossRepository: boolean; | |
| 530 | readonly defaultBranch: string | null; | |
| 531 | }): string { | |
| 532 | // gh prefixes the owner only where the fork's branch would take over the default branch's | |
| 533 | // name, which is the one collision every fork pull request from `main` would hit. | |
| 534 | return input.isCrossRepository && | |
| 535 | input.headOwner !== null && | |
| 536 | input.defaultBranch !== null && | |
| 537 | input.headRefName === input.defaultBranch | |
| 538 | ? `${input.headOwner}/${input.headRefName}` | |
| 539 | : input.headRefName; | |
| 540 | } | |
| 541 | ||
| 542 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 543 | export const make = Effect.gen(function* () { | |
| 544 | const process = yield* VcsProcess.VcsProcess; | |
| 545 | const environment = yield* HostProcessEnvironment; | |
| 546 | const api = yield* GitHubApi.GitHubApi; | |
| 547 | const git = yield* GitVcsDriver.GitVcsDriver; | |
| 548 | const fileSystem = yield* FileSystem.FileSystem; | |
| 549 | ||
| 550 | const gitRead = (cwd: string, args: ReadonlyArray<string>) => | |
| 551 | process.run({ | |
| 552 | operation: "GitHubCli.resolveRepository", | |
| 553 | command: "git", | |
| 554 | args, | |
| 555 | cwd, | |
| 556 | allowNonZeroExit: true, | |
| 557 | timeoutMs: 5_000, | |
| 558 | }); | |
| 559 | ||
| 560 | const commandFailure = (cwd: string, detail: string) => | |
| 561 | new GitHubCliCommandError({ command: "gh", cwd, cause: new Error(detail) }); | |
| 562 | ||
| 563 | /** | |
| 564 | * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else | |
| 565 | * the remote the caller resolved the provider from, else the best-ranked GitHub remote. | |
| 566 | */ | |
| 567 | const resolveRepository = Effect.fn("GitHubCli.resolveRepository")(function* (input: { | |
| 568 | readonly cwd: string; | |
| 569 | readonly host?: string | undefined; | |
| 570 | }) { | |
| 571 | const envRepository = environment.GH_REPO?.trim(); | |
| 572 | const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase(); | |
| 573 | if (envRepository) { | |
| 574 | const locator = parseGitHubRepositorySelector(envRepository, defaultHost); | |
| 575 | if (locator !== null) return locator; | |
| 576 | } | |
| 577 | const [remotes, resolved] = yield* Effect.all([ | |
| 578 | gitRead(input.cwd, ["remote", "-v"]), | |
| 579 | gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), | |
| 580 | ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); | |
| 581 | const remoteOutput = remotes?.exitCode === 0 ? remotes.stdout : ""; | |
| 582 | const fetchRemotes = remoteOutput | |
| 583 | .split("\n") | |
| 584 | .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) | |
| 585 | .filter((match): match is RegExpExecArray => match !== null) | |
| 586 | .map((match) => ({ | |
| 587 | name: match[1]!, | |
| 588 | url: match[2]!, | |
| 589 | host: gitHubApiHostForRemote(match[2]!), | |
| 590 | })); | |
| 591 | const host = | |
| 592 | (input.host === undefined ? undefined : apiHostForHint(input.host)) ?? | |
| 593 | fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? | |
| 594 | fetchRemotes.find((remote) => remote.host !== null)?.host ?? | |
| 595 | defaultHost; | |
| 596 | const selected = selectGitHubBaseRepository({ | |
| 597 | remotes: remoteOutput, | |
| 598 | resolved: resolved?.exitCode === 0 ? resolved.stdout : "", | |
| 599 | host, | |
| 600 | }); | |
| 601 | if (selected !== null) return { host, ...selected }; | |
| 602 | // gh's own order without its prompt: upstream, github, origin, then the first remote, among | |
| 603 | // the ones on this host (an SSH alias counts as its API host). | |
| 604 | const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); | |
| 605 | const candidates = fetchRemotes | |
| 606 | .filter((remote) => remote.host === host) | |
| 607 | .toSorted((left, right) => { | |
| 608 | const l = rank(left.name); | |
| 609 | const r = rank(right.name); | |
| 610 | return (l === -1 ? 99 : l) - (r === -1 ? 99 : r); | |
| 611 | }); | |
| 612 | for (const remote of candidates) { | |
| 613 | const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1); | |
| 614 | if (owner && name && rest.length === 0) return { host, owner, name }; | |
| 615 | } | |
| 616 | return yield* commandFailure( | |
| 617 | input.cwd, | |
| 618 | `No GitHub repository on ${host} was found among this checkout's git remotes.`, | |
| 619 | ); | |
| 620 | }); | |
| 621 | ||
| 622 | const graphqlJson = <A>( | |
| 623 | cwd: string, | |
| 624 | input: GitHubApi.GitHubGraphQlInput, | |
| 625 | decode: (raw: string) => Result.Result<A, unknown>, | |
| 626 | onDecodeFailure: (cause: unknown) => GitHubCliError, | |
| 627 | ) => | |
| 628 | api.graphql(input).pipe( | |
| 629 | Effect.mapError((error) => fromGitHubApiError(cwd, error)), | |
| 630 | Effect.flatMap((raw) => { | |
| 631 | const decoded = decode(raw); | |
| 632 | return Result.isSuccess(decoded) | |
| 633 | ? Effect.succeed(decoded.success) | |
| 634 | : Effect.fail(onDecodeFailure(decoded.failure)); | |
| 635 | }), | |
| 636 | ); | |
| 637 | ||
| 638 | const rest = (cwd: string, input: GitHubApi.GitHubRestInput) => | |
| 639 | api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(cwd, error))); | |
| 640 | ||
| 641 | /** Pull requests whose head branch is `headRefName` on one repository. */ | |
| 642 | const readPullRequestsByHead = (input: { | |
| 643 | readonly cwd: string; | |
| 644 | readonly locator: GitHubRepositoryLocator; | |
| 645 | readonly lookups: ReadonlyArray< | |
| 646 | Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit"> | |
| 647 | >; | |
| 648 | readonly allowReserve: boolean; | |
| 649 | readonly onDecodeFailure: (cause: unknown) => GitHubCliError; | |
| 650 | }) => { | |
| 651 | const query = buildPullRequestsByHeadQuery(input.lookups); | |
| 652 | return graphqlJson( | |
| 653 | input.cwd, | |
| 654 | { | |
| 655 | host: input.locator.host, | |
| 656 | operation: "listPullRequestsByHead", | |
| 657 | query: query.document, | |
| 658 | variables: { owner: input.locator.owner, name: input.locator.name, ...query.variables }, | |
| 659 | allowReserve: input.allowReserve, | |
| 660 | // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred | |
| 661 | // fork pull requests, so the usual cap would cut the answer short. | |
| 662 | maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, | |
| 663 | }, | |
| 664 | decodePullRequestsByHead, | |
| 665 | input.onDecodeFailure, | |
| 666 | ).pipe( | |
| 667 | Effect.flatMap((decoded) => | |
| 668 | decoded.data.repository === null | |
| 669 | ? Effect.fail( | |
| 670 | new GitHubPullRequestNotFoundError({ | |
| 671 | command: "gh", | |
| 672 | cwd: input.cwd, | |
| 673 | cause: new Error("The repository could not be read."), | |
| 674 | }), | |
| 675 | ) | |
| 676 | : Effect.succeed(decoded.data.repository), | |
| 677 | ), | |
| 678 | ); | |
| 679 | }; | |
| 680 | ||
| 681 | const headResolver = RequestResolver.makeGrouped<PullRequestsByHeadRead, string>({ | |
| 682 | key: ({ request, context }) => | |
| 683 | [ | |
| 684 | request.host, | |
| 685 | request.owner, | |
| 686 | request.name, | |
| 687 | String(request.allowReserve), | |
| 688 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) | |
| 689 | ?.credentialFingerprint ?? "", | |
| 690 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), | |
| 691 | ].join("\0"), | |
| 692 | resolver: (entries) => { | |
| 693 | const [first] = entries; | |
| 694 | const { cwd, host, owner, name, allowReserve } = first.request; | |
| 695 | return readPullRequestsByHead({ | |
| 696 | cwd, | |
| 697 | locator: { host, owner, name }, | |
| 698 | lookups: entries.map((entry) => entry.request), | |
| 699 | allowReserve, | |
| 700 | onDecodeFailure: (cause) => | |
| 701 | new GitHubChangeRequestListDecodeError({ command: "gh", cwd, cause }), | |
| 702 | }).pipe( | |
| 703 | Effect.map((aliases) => { | |
| 704 | for (const [index, entry] of entries.entries()) { | |
| 705 | const alias = aliases[`h${index}`]; | |
| 706 | entry.completeUnsafe( | |
| 707 | Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), | |
| 708 | ); | |
| 709 | } | |
| 710 | }), | |
| 711 | Effect.catchCause((cause) => | |
| 712 | Effect.sync(() => { | |
| 713 | for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); | |
| 714 | }), | |
| 715 | ), | |
| 716 | ); | |
| 717 | }, | |
| 718 | }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT)); | |
| 719 | const interactiveHeadResolver = headResolver.pipe( | |
| 720 | RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW), | |
| 721 | ); | |
| 722 | const backgroundHeadResolver = headResolver.pipe( | |
| 723 | RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT), | |
| 724 | RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), | |
| 725 | ); | |
| 726 | ||
| 727 | const listByHead = Effect.fn("GitHubCli.listByHead")(function* (input: { | |
| 728 | readonly cwd: string; | |
| 729 | readonly headSelector: string; | |
| 730 | readonly state: PullRequestListState; | |
| 731 | readonly limit: number; | |
| 732 | readonly rateLimitHost?: string | undefined; | |
| 733 | readonly allowReserve: boolean; | |
| 734 | }) { | |
| 735 | const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); | |
| 736 | const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); | |
| 737 | // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the | |
| 738 | // owner is matched on the rows it returns. | |
| 739 | const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); | |
| 740 | const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); | |
| 741 | const rows = yield* Effect.request( | |
| 742 | new PullRequestsByHeadRead({ | |
| 743 | cwd: input.cwd, | |
| 744 | ...locator, | |
| 745 | headRefName, | |
| 746 | state: input.state, | |
| 747 | limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, | |
| 748 | allowReserve: input.allowReserve, | |
| 749 | }), | |
| 750 | input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver, | |
| 751 | ); | |
| 752 | if (!ownerMatch) return rows; | |
| 753 | const headOwner = ownerMatch[1]!.toLowerCase(); | |
| 754 | return rows | |
| 755 | .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner) | |
| 756 | .slice(0, limit); | |
| 757 | }); | |
| 758 | ||
| 759 | const toSummaries = (rows: ReadonlyArray<NormalizedGitHubPullRequestRecord>) => | |
| 760 | rows.map(pullRequestSummary); | |
| 761 | ||
| 762 | const readPullRequest = Effect.fn("GitHubCli.readPullRequest")(function* (input: { | |
| 763 | readonly cwd: string; | |
| 764 | readonly reference: string; | |
| 765 | readonly rateLimitHost?: string | undefined; | |
| 766 | }) { | |
| 767 | const parsed = parsePullRequestReference(input.reference); | |
| 768 | if (parsed.kind === "branch") { | |
| 769 | // `gh pr view <branch>` prefers an open pull request, then the newest of any state. | |
| 770 | const [open] = yield* listByHead({ | |
| 771 | cwd: input.cwd, | |
| 772 | headSelector: parsed.headSelector, | |
| 773 | state: "open", | |
| 774 | limit: 1, | |
| 775 | rateLimitHost: input.rateLimitHost, | |
| 776 | allowReserve: true, | |
| 777 | }); | |
| 778 | const found = | |
| 779 | open ?? | |
| 780 | (yield* listByHead({ | |
| 781 | cwd: input.cwd, | |
| 782 | headSelector: parsed.headSelector, | |
| 783 | state: "all", | |
| 784 | limit: 1, | |
| 785 | rateLimitHost: input.rateLimitHost, | |
| 786 | allowReserve: true, | |
| 787 | }))[0]; | |
| 788 | if (found === undefined) { | |
| 789 | return yield* new GitHubPullRequestNotFoundError({ | |
| 790 | command: "gh", | |
| 791 | cwd: input.cwd, | |
| 792 | cause: new Error("No pull request has this head branch."), | |
| 793 | }); | |
| 794 | } | |
| 795 | return found; | |
| 796 | } | |
| 797 | const locator = | |
| 798 | parsed.kind === "url" | |
| 799 | ? parsed.locator | |
| 800 | : yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); | |
| 801 | const decodeFailure = (cause: unknown) => | |
| 802 | new GitHubPullRequestDecodeError({ command: "gh", cwd: input.cwd, cause }); | |
| 803 | const decoded = yield* graphqlJson( | |
| 804 | input.cwd, | |
| 805 | { | |
| 806 | host: locator.host, | |
| 807 | operation: "getPullRequest", | |
| 808 | query: PULL_REQUEST_BY_NUMBER_QUERY, | |
| 809 | variables: { owner: locator.owner, name: locator.name, number: parsed.number }, | |
| 810 | allowReserve: true, | |
| 811 | }, | |
| 812 | decodePullRequestByNumber, | |
| 813 | decodeFailure, | |
| 814 | ); | |
| 815 | const node = decoded.data.repository?.pullRequest; | |
| 816 | if (node == null) { | |
| 817 | return yield* new GitHubPullRequestNotFoundError({ | |
| 818 | command: "gh", | |
| 819 | cwd: input.cwd, | |
| 820 | cause: new Error("The pull request does not exist."), | |
| 821 | }); | |
| 822 | } | |
| 823 | const [record] = decodeGitHubPullRequestEntries([node]); | |
| 824 | if (record === undefined) return yield* decodeFailure(new Error("Malformed pull request.")); | |
| 825 | return record; | |
| 826 | }); | |
| 827 | ||
| 828 | const readRepository = Effect.fn("GitHubCli.readRepository")(function* ( | |
| 829 | cwd: string, | |
| 830 | locator: GitHubRepositoryLocator, | |
| 831 | ) { | |
| 832 | const response = yield* rest(cwd, { | |
| 833 | host: locator.host, | |
| 834 | operation: "getRepository", | |
| 835 | path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, | |
| 836 | allowReserve: true, | |
| 837 | }); | |
| 838 | const decoded = decodeRawRepository(response.body); | |
| 839 | if (Result.isFailure(decoded)) { | |
| 840 | return yield* new GitHubRepositoryDecodeError({ | |
| 841 | command: "gh", | |
| 842 | cwd, | |
| 843 | cause: decoded.failure, | |
| 844 | }); | |
| 845 | } | |
| 846 | return decoded.success; | |
| 847 | }); | |
| 848 | ||
| 849 | const readViewerLogin = Effect.fn("GitHubCli.readViewerLogin")(function* ( | |
| 850 | cwd: string, | |
| 851 | host: string, | |
| 852 | ) { | |
| 853 | const response = yield* rest(cwd, { host, operation: "getViewer", path: "user" }); | |
| 854 | const decoded = decodeViewerLogin(response.body); | |
| 855 | if (Result.isFailure(decoded)) { | |
| 856 | return yield* new GitHubCliCommandError({ command: "gh", cwd, cause: decoded.failure }); | |
| 857 | } | |
| 858 | return decoded.success.login; | |
| 859 | }); | |
| 860 | ||
| 861 | const gitFailure = (cwd: string) => (cause: unknown) => | |
| 862 | new GitHubCliCommandError({ command: "gh", cwd, cause }); | |
| 863 | ||
| 864 | const runGit = (cwd: string, operation: string, args: ReadonlyArray<string>) => | |
| 865 | git.execute({ operation: `GitHubCli.checkoutPullRequest.${operation}`, cwd, args }); | |
| 866 | ||
| 867 | /** | |
| 868 | * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a | |
| 869 | * fork gets a remote of its own), checked out under the name gh would give it, and set to | |
| 870 | * track the head. A head branch that is gone is read from the base's `refs/pull/<n>/head`. | |
| 871 | * An existing branch fast-forwards, or with `force` is reset to the pull request. | |
| 872 | */ | |
| 873 | const checkoutPullRequest: GitHubCli["Service"]["checkoutPullRequest"] = Effect.fn( | |
| 874 | "GitHubCli.checkoutPullRequest", | |
| 875 | )(function* (input) { | |
| 876 | const reference = parsePullRequestReference(input.reference); | |
| 877 | const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); | |
| 878 | const base = | |
| 879 | reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd }); | |
| 880 | const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); | |
| 881 | const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; | |
| 882 | const isCrossRepository = | |
| 883 | pullRequest.isCrossRepository ?? | |
| 884 | (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); | |
| 885 | const headOwner = | |
| 886 | pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; | |
| 887 | ||
| 888 | const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( | |
| 889 | Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), | |
| 890 | Effect.mapError(gitFailure(input.cwd)), | |
| 891 | ); | |
| 892 | const remoteFor = (nameWithOwner: string) => | |
| 893 | remotes | |
| 894 | .split("\n") | |
| 895 | .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) | |
| 896 | .find( | |
| 897 | (match) => | |
| 898 | match !== null && | |
| 899 | normalizeGitRemoteUrl(match[2]!).split("/").slice(1).join("/") === | |
| 900 | nameWithOwner.toLowerCase(), | |
| 901 | )?.[1] ?? null; | |
| 902 | const baseRemote = Effect.suspend(() => { | |
| 903 | const known = remoteFor(baseNameWithOwner); | |
| 904 | return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); | |
| 905 | }); | |
| 906 | // A fork's branch named like the base's default branch is checked out under the owner's | |
| 907 | // prefix. Without the default branch that collision cannot be ruled out, and the checkout | |
| 908 | // would reset the local default branch to the fork's commit, so it fails instead. | |
| 909 | const defaultBranch = isCrossRepository | |
| 910 | ? yield* readRepository(input.cwd, base).pipe( | |
| 911 | Effect.map((repository) => repository.default_branch ?? null), | |
| 912 | ) | |
| 913 | : null; | |
| 914 | const localBranch = pullRequestCheckoutBranchName({ | |
| 915 | headRefName: pullRequest.headRefName, | |
| 916 | headOwner, | |
| 917 | isCrossRepository, | |
| 918 | defaultBranch, | |
| 919 | }); | |
| 920 | ||
| 921 | /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ | |
| 922 | const headRemote = Effect.gen(function* () { | |
| 923 | if (!isCrossRepository) return yield* baseRemote; | |
| 924 | if (headNameWithOwner === null) return yield* commandFailure(input.cwd, "The fork is gone."); | |
| 925 | const known = remoteFor(headNameWithOwner); | |
| 926 | if (known !== null) return known; | |
| 927 | const [owner, name] = headNameWithOwner.split("/"); | |
| 928 | const fork = yield* readRepository(input.cwd, { | |
| 929 | host: base.host, | |
| 930 | owner: owner!, | |
| 931 | name: name!, | |
| 932 | }); | |
| 933 | const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); | |
| 934 | return yield* git.ensureRemote({ | |
| 935 | cwd: input.cwd, | |
| 936 | preferredName: headOwner ?? "fork", | |
| 937 | url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url, | |
| 938 | }); | |
| 939 | }); | |
| 940 | ||
| 941 | const exists = (yield* git | |
| 942 | .listLocalBranchNames(input.cwd) | |
| 943 | .pipe(Effect.mapError(gitFailure(input.cwd)))).includes(localBranch); | |
| 944 | ||
| 945 | // The commit to check out, fetched from the head branch where it still exists. | |
| 946 | const target = yield* Effect.gen(function* () { | |
| 947 | const remoteName = yield* headRemote; | |
| 948 | yield* git.fetchRemoteTrackingBranch({ | |
| 949 | cwd: input.cwd, | |
| 950 | remoteName, | |
| 951 | remoteBranch: pullRequest.headRefName, | |
| 952 | }); | |
| 953 | return { | |
| 954 | ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`, | |
| 955 | upstream: { remoteName, remoteBranch: pullRequest.headRefName }, | |
| 956 | }; | |
| 957 | }).pipe( | |
| 958 | Effect.catch(() => | |
| 959 | Effect.gen(function* () { | |
| 960 | yield* runGit(input.cwd, "fetchPullRef", [ | |
| 961 | "fetch", | |
| 962 | "--quiet", | |
| 963 | "--no-tags", | |
| 964 | yield* baseRemote, | |
| 965 | `refs/pull/${pullRequest.number}/head`, | |
| 966 | ]); | |
| 967 | const { commitSha } = yield* git.resolveCommit({ | |
| 968 | cwd: input.cwd, | |
| 969 | revision: "FETCH_HEAD", | |
| 970 | }); | |
| 971 | return { ref: commitSha, upstream: null }; | |
| 972 | }), | |
| 973 | ), | |
| 974 | Effect.mapError(gitFailure(input.cwd)), | |
| 975 | ); | |
| 976 | ||
| 977 | yield* Effect.gen(function* () { | |
| 978 | if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]); | |
| 979 | yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); | |
| 980 | if (exists) { | |
| 981 | yield* runGit( | |
| 982 | input.cwd, | |
| 983 | "sync", | |
| 984 | input.force === true | |
| 985 | ? ["reset", "--hard", "--quiet", target.ref] | |
| 986 | : ["merge", "--ff-only", "--quiet", target.ref], | |
| 987 | ); | |
| 988 | } | |
| 989 | // Tracking is set once the branch is the pull request's, so a sync that fails leaves an | |
| 990 | // existing branch's upstream as it was. | |
| 991 | if (target.upstream !== null) { | |
| 992 | yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); | |
| 993 | } | |
| 994 | }).pipe(Effect.mapError(gitFailure(input.cwd))); | |
| 995 | }); | |
| 996 | ||
| 997 | return GitHubCli.of({ | |
| 998 | listPullRequestsByHead: (input) => | |
| 999 | AllowGitHubReserve.pipe( | |
| 1000 | Effect.flatMap((allowReserve) => listByHead({ ...input, allowReserve })), | |
| 1001 | ), | |
| 1002 | listOpenPullRequests: (input) => | |
| 1003 | listByHead({ | |
| 1004 | cwd: input.cwd, | |
| 1005 | headSelector: input.headSelector, | |
| 1006 | state: "open", | |
| 1007 | limit: input.limit ?? 1, | |
| 1008 | rateLimitHost: input.rateLimitHost, | |
| 1009 | allowReserve: true, | |
| 1010 | }).pipe(Effect.map(toSummaries)), | |
| 1011 | getPullRequest: (input) => readPullRequest(input).pipe(Effect.map(pullRequestSummary)), | |
| 1012 | getRepositoryCloneUrls: (input) => | |
| 1013 | Effect.gen(function* () { | |
| 1014 | const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe( | |
| 1015 | Effect.map((locator) => locator.host), | |
| 1016 | Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), | |
| 1017 | )).toLowerCase(); | |
| 1018 | const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); | |
| 1019 | if (locator === null) { | |
| 1020 | return yield* commandFailure(input.cwd, "Repositories are named owner/name."); | |
| 1021 | } | |
| 1022 | return repositoryCloneUrls(yield* readRepository(input.cwd, locator)); | |
| 1023 | }), | |
| 1024 | createRepository: (input) => | |
| 1025 | Effect.gen(function* () { | |
| 1026 | const locator = parseGitHubRepositorySelector( | |
| 1027 | input.repository, | |
| 1028 | (environment.GH_HOST ?? "github.com").toLowerCase(), | |
| 1029 | ); | |
| 1030 | const viewer = locator === null ? null : yield* readViewerLogin(input.cwd, locator.host); | |
| 1031 | const owner = locator?.owner ?? viewer; | |
| 1032 | const name = locator?.name ?? input.repository.trim(); | |
| 1033 | const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); | |
| 1034 | const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); | |
| 1035 | const response = yield* rest(input.cwd, { | |
| 1036 | host, | |
| 1037 | operation: "createRepository", | |
| 1038 | method: "POST", | |
| 1039 | path: | |
| 1040 | isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, | |
| 1041 | body: { name, private: input.visibility === "private" }, | |
| 1042 | }); | |
| 1043 | const decoded = decodeRawRepository(response.body); | |
| 1044 | if (Result.isFailure(decoded)) { | |
| 1045 | return yield* new GitHubRepositoryDecodeError({ | |
| 1046 | command: "gh", | |
| 1047 | cwd: input.cwd, | |
| 1048 | cause: decoded.failure, | |
| 1049 | }); | |
| 1050 | } | |
| 1051 | return repositoryCloneUrls(decoded.success); | |
| 1052 | }), | |
| 1053 | createPullRequest: (input) => | |
| 1054 | Effect.gen(function* () { | |
| 1055 | const locator = yield* resolveRepository({ cwd: input.cwd }); | |
| 1056 | const body = yield* fileSystem | |
| 1057 | .readFileString(input.bodyFile) | |
| 1058 | .pipe(Effect.mapError(gitFailure(input.cwd))); | |
| 1059 | yield* rest(input.cwd, { | |
| 1060 | host: locator.host, | |
| 1061 | operation: "createPullRequest", | |
| 1062 | method: "POST", | |
| 1063 | path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, | |
| 1064 | // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. | |
| 1065 | // gh allows maintainer edits unless told otherwise; the API's default is not documented. | |
| 1066 | body: { | |
| 1067 | base: input.baseBranch, | |
| 1068 | head: input.headSelector, | |
| 1069 | title: input.title, | |
| 1070 | body, | |
| 1071 | maintainer_can_modify: true, | |
| 1072 | }, | |
| 1073 | }); | |
| 1074 | }), | |
| 1075 | getDefaultBranch: (input) => | |
| 1076 | Effect.gen(function* () { | |
| 1077 | const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); | |
| 1078 | const repository = yield* readRepository(input.cwd, locator); | |
| 1079 | const branch = repository.default_branch?.trim() ?? ""; | |
| 1080 | return branch.length > 0 ? branch : null; | |
| 1081 | }), | |
| 1082 | checkoutPullRequest, | |
| 1083 | }); | |
| 1084 | }); | |
| 1085 | ||
| 1086 | export const layer = Layer.effect(GitHubCli, make).pipe( | |
| 1087 | Layer.provideMerge(GitHubApi.layer), | |
| 1088 | Layer.provideMerge(GitHubCredentials.layer), | |
| 1089 | Layer.provideMerge(GitHubGraphQlBudget.layer), | |
| 1090 | Layer.provideMerge(SourceControlRateLimit.layer), | |
| 1091 | ); |
apps/server/src/sourceControl/GitHubRepositoryApi.test.ts created+651| ... | ... | @@ -0,0 +1,651 @@ |
| 1 | import { assert, it, describe } from "@effect/vitest"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import * as TestClock from "effect/testing/TestClock"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as FileSystem from "effect/FileSystem"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import { ChildProcessSpawner } from "effect/process"; | |
| 10 | ||
| 11 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 12 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 13 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 14 | import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; | |
| 15 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 16 | ||
| 17 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 18 | ||
| 19 | const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({ | |
| 20 | exitCode: ChildProcessSpawner.ExitCode(exitCode), | |
| 21 | stdout, | |
| 22 | stderr: "", | |
| 23 | stdoutTruncated: false, | |
| 24 | stderrTruncated: false, | |
| 25 | }); | |
| 26 | ||
| 27 | const remotesOutput = (...entries: ReadonlyArray<readonly [string, string]>) => | |
| 28 | entries | |
| 29 | .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) | |
| 30 | .join("\n"); | |
| 31 | ||
| 32 | const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({ | |
| 33 | status, | |
| 34 | headers: {}, | |
| 35 | body: body === undefined ? "" : encodeJson(body), | |
| 36 | truncated: false, | |
| 37 | invalidUtf8: false, | |
| 38 | }); | |
| 39 | ||
| 40 | const node = (number: number, headRefName: string, owner = "acme") => ({ | |
| 41 | number, | |
| 42 | title: `PR ${number}`, | |
| 43 | url: `https://github.com/acme/web/pull/${number}`, | |
| 44 | baseRefName: "main", | |
| 45 | headRefName, | |
| 46 | state: "OPEN", | |
| 47 | isCrossRepository: owner !== "acme", | |
| 48 | updatedAt: "2026-01-02T00:00:00Z", | |
| 49 | headRepository: { name: "web", nameWithOwner: `${owner}/web` }, | |
| 50 | headRepositoryOwner: { login: owner }, | |
| 51 | }); | |
| 52 | ||
| 53 | /** | |
| 54 | * A GitHubRepositoryApi over a mocked GitHubApi, git driver and process. `remotes` is what | |
| 55 | * `git remote -v` prints; `git` records every driver call. | |
| 56 | */ | |
| 57 | function harness(input: { | |
| 58 | readonly remotes: string; | |
| 59 | readonly api: Partial<GitHubApi.GitHubApi["Service"]>; | |
| 60 | readonly localBranches?: ReadonlyArray<string>; | |
| 61 | }) { | |
| 62 | const git: Array<readonly [string, unknown]> = []; | |
| 63 | const record = | |
| 64 | <A>(name: string, value: A) => | |
| 65 | (args: unknown) => | |
| 66 | Effect.sync(() => { | |
| 67 | git.push([name, args]); | |
| 68 | return value; | |
| 69 | }); | |
| 70 | const driver = Layer.mock(GitVcsDriver.GitVcsDriver)({ | |
| 71 | execute: (args) => | |
| 72 | Effect.sync(() => { | |
| 73 | git.push(["execute", args.args]); | |
| 74 | return processOutput(""); | |
| 75 | }), | |
| 76 | resolvePrimaryRemoteName: () => Effect.succeed("origin"), | |
| 77 | readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), | |
| 78 | ensureRemote: (args) => | |
| 79 | Effect.sync(() => { | |
| 80 | git.push(["ensureRemote", args]); | |
| 81 | return args.preferredName; | |
| 82 | }), | |
| 83 | fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args), | |
| 84 | setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args), | |
| 85 | switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never, | |
| 86 | listLocalBranchNames: () => Effect.succeed([...(input.localBranches ?? [])]), | |
| 87 | resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), | |
| 88 | }); | |
| 89 | const process = Layer.mock(VcsProcess.VcsProcess)({ | |
| 90 | run: (args) => | |
| 91 | Effect.succeed( | |
| 92 | args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), | |
| 93 | ), | |
| 94 | }); | |
| 95 | const layer = Layer.effect( | |
| 96 | GitHubRepositoryApi.GitHubRepositoryApi, | |
| 97 | GitHubRepositoryApi.make, | |
| 98 | ).pipe( | |
| 99 | Layer.provide( | |
| 100 | Layer.mergeAll( | |
| 101 | driver, | |
| 102 | process, | |
| 103 | Layer.mock(GitHubApi.GitHubApi)(input.api), | |
| 104 | NodeServices.layer, | |
| 105 | ), | |
| 106 | ), | |
| 107 | ); | |
| 108 | return { layer, git }; | |
| 109 | } | |
| 110 | ||
| 111 | describe("selectGitHubBaseRepository", () => { | |
| 112 | const remotes = (...entries: ReadonlyArray<readonly [string, string]>) => | |
| 113 | entries | |
| 114 | .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) | |
| 115 | .join("\n"); | |
| 116 | const select = (input: { remotes: string; resolved?: string }) => | |
| 117 | GitHubRepositoryApi.selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); | |
| 118 | ||
| 119 | it("picks the repository gh reads without a prompt", () => { | |
| 120 | assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { | |
| 121 | owner: "me", | |
| 122 | name: "web", | |
| 123 | }); | |
| 124 | const fork = remotes( | |
| 125 | ["origin", "git@github.com:me/web.git"], | |
| 126 | ["upstream", "https://github.com/Acme/Web.git"], | |
| 127 | ); | |
| 128 | assert.deepStrictEqual(select({ remotes: fork }), { owner: "acme", name: "web" }); | |
| 129 | assert.deepStrictEqual(select({ remotes: fork, resolved: "remote.origin.gh-resolved base" }), { | |
| 130 | owner: "me", | |
| 131 | name: "web", | |
| 132 | }); | |
| 133 | assert.deepStrictEqual( | |
| 134 | select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), | |
| 135 | { owner: "acme", name: "other" }, | |
| 136 | ); | |
| 137 | // gh ranks remote names in any case. | |
| 138 | assert.deepStrictEqual( | |
| 139 | select({ | |
| 140 | remotes: remotes( | |
| 141 | ["origin", "git@github.com:me/web.git"], | |
| 142 | ["Upstream", "git@github.com:acme/web.git"], | |
| 143 | ), | |
| 144 | }), | |
| 145 | { owner: "acme", name: "web" }, | |
| 146 | ); | |
| 147 | }); | |
| 148 | ||
| 149 | it("leaves gh to choose when it might weigh the remotes differently", () => { | |
| 150 | for (const input of [ | |
| 151 | { remotes: "" }, | |
| 152 | { | |
| 153 | remotes: remotes(["a", "git@github.com:me/web.git"], ["b", "git@github.com:acme/web.git"]), | |
| 154 | }, | |
| 155 | { | |
| 156 | remotes: remotes( | |
| 157 | ["origin", "git@github.com:me/web.git"], | |
| 158 | ["mirror", "git@gitlab.com:me/web.git"], | |
| 159 | ), | |
| 160 | }, | |
| 161 | { remotes: remotes(["origin", "git@github-work:me/web.git"]) }, | |
| 162 | { | |
| 163 | remotes: remotes( | |
| 164 | ["origin", "git@github.com:me/web.git"], | |
| 165 | ["Origin", "git@github.com:acme/web.git"], | |
| 166 | ), | |
| 167 | }, | |
| 168 | { | |
| 169 | remotes: remotes( | |
| 170 | ["origin", "git@github.com:me/web.git"], | |
| 171 | ["upstream", "git@github.com:acme/web.git"], | |
| 172 | ), | |
| 173 | resolved: "remote.origin.gh-resolved base\nremote.upstream.gh-resolved base", | |
| 174 | }, | |
| 175 | ]) { | |
| 176 | assert.strictEqual(select(input), null); | |
| 177 | } | |
| 178 | }); | |
| 179 | }); | |
| 180 | ||
| 181 | describe("GitHubRepositoryApi repository resolution", () => { | |
| 182 | it.effect("reads the repository gh would pick from the remotes", () => { | |
| 183 | const paths: string[] = []; | |
| 184 | const { layer } = harness({ | |
| 185 | remotes: remotesOutput( | |
| 186 | ["origin", "git@github.com:me/web.git"], | |
| 187 | ["upstream", "https://github.com/acme/web.git"], | |
| 188 | ), | |
| 189 | api: { | |
| 190 | rest: (input) => | |
| 191 | Effect.sync(() => { | |
| 192 | paths.push(`${input.host} ${input.path}`); | |
| 193 | return restResponse({ | |
| 194 | full_name: "acme/web", | |
| 195 | html_url: "https://github.com/acme/web", | |
| 196 | ssh_url: "git@github.com:acme/web.git", | |
| 197 | default_branch: "trunk", | |
| 198 | }); | |
| 199 | }), | |
| 200 | }, | |
| 201 | }); | |
| 202 | return Effect.gen(function* () { | |
| 203 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 204 | assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); | |
| 205 | assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); | |
| 206 | }).pipe(Effect.provide(layer)); | |
| 207 | }); | |
| 208 | ||
| 209 | it.effect("reads an SSH alias remote through github.com", () => { | |
| 210 | const hosts: string[] = []; | |
| 211 | const { layer } = harness({ | |
| 212 | remotes: remotesOutput(["origin", "git@github:acme/web.git"]), | |
| 213 | api: { | |
| 214 | rest: (input) => | |
| 215 | Effect.sync(() => { | |
| 216 | hosts.push(`${input.host} ${input.path}`); | |
| 217 | return restResponse({ | |
| 218 | full_name: "acme/web", | |
| 219 | html_url: "https://github.com/acme/web", | |
| 220 | ssh_url: "git@github.com:acme/web.git", | |
| 221 | default_branch: "main", | |
| 222 | }); | |
| 223 | }), | |
| 224 | }, | |
| 225 | }); | |
| 226 | return Effect.gen(function* () { | |
| 227 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 228 | yield* gh.getDefaultBranch({ cwd: "/repo" }); | |
| 229 | // A provider's host hint for the same alias (`github` here) resolves the same way. | |
| 230 | yield* gh.getDefaultBranch({ cwd: "/repo", rateLimitHost: "github" }); | |
| 231 | assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); | |
| 232 | assert.strictEqual( | |
| 233 | GitHubRepositoryApi.gitHubApiHostForRemote("git@github.example.com:a/b.git"), | |
| 234 | "github.example.com", | |
| 235 | ); | |
| 236 | assert.strictEqual( | |
| 237 | GitHubRepositoryApi.gitHubApiHostForRemote("git@gitlab.com:a/b.git"), | |
| 238 | null, | |
| 239 | ); | |
| 240 | }).pipe(Effect.provide(layer)); | |
| 241 | }); | |
| 242 | ||
| 243 | it.effect("fails clearly when no remote is on GitHub", () => { | |
| 244 | const { layer } = harness({ | |
| 245 | remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]), | |
| 246 | api: {}, | |
| 247 | }); | |
| 248 | return Effect.gen(function* () { | |
| 249 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 250 | const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); | |
| 251 | assert.strictEqual(error._tag, "GitHubRepositoryCommandError"); | |
| 252 | assert.include(String((error.cause as Error).message), "No GitHub repository"); | |
| 253 | }).pipe(Effect.provide(layer)); | |
| 254 | }); | |
| 255 | }); | |
| 256 | ||
| 257 | describe("GitHubRepositoryApi.listPullRequestsByHead", () => { | |
| 258 | const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); | |
| 259 | ||
| 260 | it.effect("reads a background sweep's staggered heads in one GraphQL document", () => { | |
| 261 | const documents: Array<GitHubApi.GitHubGraphQlInput> = []; | |
| 262 | const { layer } = harness({ | |
| 263 | remotes, | |
| 264 | api: { | |
| 265 | graphql: (input) => | |
| 266 | Effect.sync(() => { | |
| 267 | documents.push(input); | |
| 268 | return encodeJson({ | |
| 269 | data: { | |
| 270 | repository: { | |
| 271 | h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] }, | |
| 272 | h1: { nodes: [] }, | |
| 273 | }, | |
| 274 | }, | |
| 275 | }); | |
| 276 | }), | |
| 277 | }, | |
| 278 | }); | |
| 279 | return Effect.gen(function* () { | |
| 280 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 281 | const lookup = (headSelector: string) => | |
| 282 | gh | |
| 283 | .listPullRequestsByHead({ | |
| 284 | cwd: "/repo", | |
| 285 | headSelector, | |
| 286 | state: "all", | |
| 287 | limit: 100, | |
| 288 | rateLimitHost: "github.com", | |
| 289 | }) | |
| 290 | .pipe(Effect.forkChild); | |
| 291 | // Each branch's own git reads come first, so a sweep's lookups arrive spread out. | |
| 292 | const firstLookup = yield* lookup("feature/a"); | |
| 293 | yield* TestClock.adjust("200 millis"); | |
| 294 | const secondLookup = yield* lookup("feature/b"); | |
| 295 | yield* TestClock.adjust("300 millis"); | |
| 296 | const first = yield* Fiber.join(firstLookup); | |
| 297 | const second = yield* Fiber.join(secondLookup); | |
| 298 | assert.deepStrictEqual( | |
| 299 | first?.map((pr) => pr.number), | |
| 300 | [7], | |
| 301 | ); | |
| 302 | assert.strictEqual(first?.[0]?.headSha, "a".repeat(40)); | |
| 303 | assert.deepStrictEqual(second, []); | |
| 304 | assert.strictEqual(documents.length, 1); | |
| 305 | assert.include(documents[0]!.query, "headRefOid"); | |
| 306 | assert.deepStrictEqual(documents[0]!.variables, { | |
| 307 | owner: "acme", | |
| 308 | name: "web", | |
| 309 | h0: "feature/a", | |
| 310 | s0: ["OPEN", "CLOSED", "MERGED"], | |
| 311 | h1: "feature/b", | |
| 312 | s1: ["OPEN", "CLOSED", "MERGED"], | |
| 313 | }); | |
| 314 | }).pipe(Effect.provide(layer)); | |
| 315 | }); | |
| 316 | ||
| 317 | it.effect("caps a background document at twenty-five heads", () => { | |
| 318 | const headCounts: Array<number> = []; | |
| 319 | const { layer } = harness({ | |
| 320 | remotes, | |
| 321 | api: { | |
| 322 | graphql: (input) => | |
| 323 | Effect.sync(() => { | |
| 324 | const heads = Object.keys(input.variables ?? {}).filter((key) => /^h\d+$/.test(key)); | |
| 325 | headCounts.push(heads.length); | |
| 326 | return encodeJson({ | |
| 327 | data: { repository: Object.fromEntries(heads.map((key) => [key, { nodes: [] }])) }, | |
| 328 | }); | |
| 329 | }), | |
| 330 | }, | |
| 331 | }); | |
| 332 | return Effect.gen(function* () { | |
| 333 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 334 | const lookups = yield* Effect.all( | |
| 335 | Array.from({ length: 26 }, (_, index) => | |
| 336 | gh.listPullRequestsByHead({ | |
| 337 | cwd: "/repo", | |
| 338 | headSelector: `feature/${index}`, | |
| 339 | state: "all", | |
| 340 | limit: 100, | |
| 341 | rateLimitHost: "github.com", | |
| 342 | }), | |
| 343 | ), | |
| 344 | { concurrency: "unbounded" }, | |
| 345 | ).pipe(Effect.forkChild); | |
| 346 | yield* TestClock.adjust("500 millis"); | |
| 347 | yield* Fiber.join(lookups); | |
| 348 | assert.deepStrictEqual( | |
| 349 | headCounts.toSorted((a, b) => a - b), | |
| 350 | [1, 25], | |
| 351 | ); | |
| 352 | }).pipe(Effect.provide(layer)); | |
| 353 | }); | |
| 354 | ||
| 355 | it.effect("matches an owner:branch selector on the head owner", () => { | |
| 356 | const { layer } = harness({ | |
| 357 | remotes, | |
| 358 | api: { | |
| 359 | graphql: (input) => | |
| 360 | Effect.succeed( | |
| 361 | encodeJson({ | |
| 362 | data: { | |
| 363 | repository: { | |
| 364 | h0: { | |
| 365 | nodes: | |
| 366 | input.variables?.h0 === "main" | |
| 367 | ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")] | |
| 368 | : [], | |
| 369 | }, | |
| 370 | }, | |
| 371 | }, | |
| 372 | }), | |
| 373 | ), | |
| 374 | }, | |
| 375 | }); | |
| 376 | return Effect.gen(function* () { | |
| 377 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 378 | const open = yield* gh | |
| 379 | .listOpenPullRequests({ cwd: "/repo", headSelector: "me:main", limit: 1 }) | |
| 380 | .pipe(Effect.forkChild); | |
| 381 | yield* TestClock.adjust("50 millis"); | |
| 382 | assert.deepStrictEqual( | |
| 383 | (yield* Fiber.join(open)).map((pr) => pr.number), | |
| 384 | [8], | |
| 385 | ); | |
| 386 | }).pipe(Effect.provide(layer)); | |
| 387 | }); | |
| 388 | ||
| 389 | it.effect("maps API failures onto the errors callers handle", () => { | |
| 390 | const { layer } = harness({ | |
| 391 | remotes, | |
| 392 | api: { | |
| 393 | graphql: (input) => | |
| 394 | Effect.fail( | |
| 395 | input.variables?.h0 === "missing" | |
| 396 | ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" }) | |
| 397 | : new GitHubApi.GitHubApiRateLimitError({ | |
| 398 | host: "github.com", | |
| 399 | operation: "x", | |
| 400 | retryAt: 123, | |
| 401 | }), | |
| 402 | ), | |
| 403 | }, | |
| 404 | }); | |
| 405 | return Effect.gen(function* () { | |
| 406 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 407 | const read = (headSelector: string) => | |
| 408 | gh | |
| 409 | .listPullRequestsByHead({ cwd: "/repo", headSelector, state: "open", limit: 1 }) | |
| 410 | .pipe(Effect.flip, Effect.forkChild); | |
| 411 | const missing = yield* read("missing"); | |
| 412 | yield* TestClock.adjust("500 millis"); | |
| 413 | assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubRepositoryUnavailableError"); | |
| 414 | const limited = yield* read("limited"); | |
| 415 | yield* TestClock.adjust("500 millis"); | |
| 416 | const error = yield* Fiber.join(limited); | |
| 417 | assert.strictEqual(error._tag, "GitHubRepositoryRateLimitError"); | |
| 418 | assert.propertyVal(error, "retryAt", 123); | |
| 419 | }).pipe(Effect.provide(layer)); | |
| 420 | }); | |
| 421 | }); | |
| 422 | ||
| 423 | describe("GitHubRepositoryApi.getPullRequest", () => { | |
| 424 | it.effect("reads a pull request by number, and by URL on its own repository", () => { | |
| 425 | const variables: Array<unknown> = []; | |
| 426 | const { layer } = harness({ | |
| 427 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 428 | api: { | |
| 429 | graphql: (input) => | |
| 430 | Effect.sync(() => { | |
| 431 | variables.push(input.variables); | |
| 432 | return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } }); | |
| 433 | }), | |
| 434 | }, | |
| 435 | }); | |
| 436 | return Effect.gen(function* () { | |
| 437 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 438 | assert.strictEqual((yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" })).number, 42); | |
| 439 | yield* gh.getPullRequest({ | |
| 440 | cwd: "/repo", | |
| 441 | reference: "https://github.com/other/thing/pull/42", | |
| 442 | }); | |
| 443 | assert.deepStrictEqual(variables, [ | |
| 444 | { owner: "acme", name: "web", number: 42 }, | |
| 445 | { owner: "other", name: "thing", number: 42 }, | |
| 446 | ]); | |
| 447 | }).pipe(Effect.provide(layer)); | |
| 448 | }); | |
| 449 | ||
| 450 | it.effect("fails a missing pull request as not found", () => { | |
| 451 | const { layer } = harness({ | |
| 452 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 453 | api: { | |
| 454 | graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })), | |
| 455 | }, | |
| 456 | }); | |
| 457 | return Effect.gen(function* () { | |
| 458 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 459 | const error = yield* gh.getPullRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); | |
| 460 | assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); | |
| 461 | }).pipe(Effect.provide(layer)); | |
| 462 | }); | |
| 463 | }); | |
| 464 | ||
| 465 | describe("GitHubRepositoryApi writes", () => { | |
| 466 | it.effect("creates a cross-repository pull request with an owner:branch head", () => { | |
| 467 | const requests: Array<GitHubApi.GitHubRestInput> = []; | |
| 468 | const { layer } = harness({ | |
| 469 | remotes: remotesOutput( | |
| 470 | ["origin", "git@github.com:me/web.git"], | |
| 471 | ["upstream", "git@github.com:acme/web.git"], | |
| 472 | ), | |
| 473 | api: { | |
| 474 | rest: (input) => | |
| 475 | Effect.sync(() => { | |
| 476 | requests.push(input); | |
| 477 | return restResponse({ number: 1 }, 201); | |
| 478 | }), | |
| 479 | }, | |
| 480 | }); | |
| 481 | return Effect.gen(function* () { | |
| 482 | const fs = yield* FileSystem.FileSystem; | |
| 483 | const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); | |
| 484 | yield* fs.writeFileString(bodyFile, "Body"); | |
| 485 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 486 | yield* gh.createPullRequest({ | |
| 487 | cwd: "/repo", | |
| 488 | baseBranch: "main", | |
| 489 | headSelector: "me:feature", | |
| 490 | title: "Title", | |
| 491 | bodyFile, | |
| 492 | }); | |
| 493 | assert.strictEqual(requests[0]!.method, "POST"); | |
| 494 | assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); | |
| 495 | assert.deepStrictEqual(requests[0]!.body, { | |
| 496 | base: "main", | |
| 497 | head: "me:feature", | |
| 498 | title: "Title", | |
| 499 | body: "Body", | |
| 500 | maintainer_can_modify: true, | |
| 501 | }); | |
| 502 | }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped); | |
| 503 | }); | |
| 504 | ||
| 505 | it.effect("creates a repository under an organization the viewer is not", () => { | |
| 506 | const requests: Array<string> = []; | |
| 507 | const { layer } = harness({ | |
| 508 | remotes: "", | |
| 509 | api: { | |
| 510 | rest: (input) => | |
| 511 | Effect.sync(() => { | |
| 512 | requests.push(`${input.method ?? "GET"} ${input.path}`); | |
| 513 | return input.path === "user" | |
| 514 | ? restResponse({ login: "me" }) | |
| 515 | : restResponse({ | |
| 516 | full_name: "acme/new", | |
| 517 | html_url: "https://github.com/acme/new", | |
| 518 | ssh_url: "git@github.com:acme/new.git", | |
| 519 | }); | |
| 520 | }), | |
| 521 | }, | |
| 522 | }); | |
| 523 | return Effect.gen(function* () { | |
| 524 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 525 | const urls = yield* gh.createRepository({ | |
| 526 | cwd: "/repo", | |
| 527 | repository: "acme/new", | |
| 528 | visibility: "private", | |
| 529 | }); | |
| 530 | assert.deepStrictEqual(urls, { | |
| 531 | nameWithOwner: "acme/new", | |
| 532 | url: "https://github.com/acme/new", | |
| 533 | sshUrl: "git@github.com:acme/new.git", | |
| 534 | }); | |
| 535 | assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]); | |
| 536 | }).pipe(Effect.provide(layer)); | |
| 537 | }); | |
| 538 | }); | |
| 539 | ||
| 540 | describe("GitHubRepositoryApi.checkoutPullRequest", () => { | |
| 541 | const repository = (fullName: string, defaultBranch = "main") => | |
| 542 | restResponse({ | |
| 543 | full_name: fullName, | |
| 544 | html_url: `https://github.com/${fullName}`, | |
| 545 | ssh_url: `git@github.com:${fullName}.git`, | |
| 546 | default_branch: defaultBranch, | |
| 547 | }); | |
| 548 | ||
| 549 | it.effect("checks a same-repository pull request out from its head branch", () => { | |
| 550 | const { layer, git } = harness({ | |
| 551 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 552 | api: { | |
| 553 | graphql: () => | |
| 554 | Effect.succeed( | |
| 555 | encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), | |
| 556 | ), | |
| 557 | }, | |
| 558 | }); | |
| 559 | return Effect.gen(function* () { | |
| 560 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 561 | yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "5" }); | |
| 562 | assert.deepStrictEqual(git, [ | |
| 563 | [ | |
| 564 | "fetchRemoteTrackingBranch", | |
| 565 | { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" }, | |
| 566 | ], | |
| 567 | ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]], | |
| 568 | ["switchRef", { cwd: "/repo", refName: "feature/x" }], | |
| 569 | [ | |
| 570 | "setBranchUpstream", | |
| 571 | { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" }, | |
| 572 | ], | |
| 573 | ]); | |
| 574 | }).pipe(Effect.provide(layer)); | |
| 575 | }); | |
| 576 | ||
| 577 | it.effect("refuses a fork checkout when the base's default branch cannot be read", () => { | |
| 578 | const { layer, git } = harness({ | |
| 579 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 580 | localBranches: ["main"], | |
| 581 | api: { | |
| 582 | graphql: () => | |
| 583 | Effect.succeed( | |
| 584 | encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), | |
| 585 | ), | |
| 586 | rest: () => | |
| 587 | Effect.fail( | |
| 588 | new GitHubApi.GitHubApiRequestError({ | |
| 589 | host: "github.com", | |
| 590 | operation: "x", | |
| 591 | cause: "offline", | |
| 592 | }), | |
| 593 | ), | |
| 594 | }, | |
| 595 | }); | |
| 596 | return Effect.gen(function* () { | |
| 597 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 598 | yield* Effect.flip(gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true })); | |
| 599 | // Nothing touched the local branches: `main` must not be reset to the fork's commit. | |
| 600 | assert.deepStrictEqual(git, []); | |
| 601 | }).pipe(Effect.provide(layer)); | |
| 602 | }); | |
| 603 | ||
| 604 | it.effect("adds a remote for a fork and names a default-branch head after its owner", () => { | |
| 605 | const { layer, git } = harness({ | |
| 606 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 607 | localBranches: ["someone/main"], | |
| 608 | api: { | |
| 609 | graphql: () => | |
| 610 | Effect.succeed( | |
| 611 | encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), | |
| 612 | ), | |
| 613 | rest: (input) => | |
| 614 | Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")), | |
| 615 | }, | |
| 616 | }); | |
| 617 | return Effect.gen(function* () { | |
| 618 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 619 | yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true }); | |
| 620 | assert.deepStrictEqual(git, [ | |
| 621 | [ | |
| 622 | "ensureRemote", | |
| 623 | { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" }, | |
| 624 | ], | |
| 625 | [ | |
| 626 | "fetchRemoteTrackingBranch", | |
| 627 | { cwd: "/repo", remoteName: "someone", remoteBranch: "main" }, | |
| 628 | ], | |
| 629 | ["switchRef", { cwd: "/repo", refName: "someone/main" }], | |
| 630 | ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]], | |
| 631 | [ | |
| 632 | "setBranchUpstream", | |
| 633 | { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" }, | |
| 634 | ], | |
| 635 | ]); | |
| 636 | }).pipe(Effect.provide(layer)); | |
| 637 | }); | |
| 638 | ||
| 639 | it("names the local branch the way gh pr checkout does", () => { | |
| 640 | const name = (headRefName: string, isCrossRepository: boolean) => | |
| 641 | GitHubRepositoryApi.pullRequestCheckoutBranchName({ | |
| 642 | headRefName, | |
| 643 | headOwner: "someone", | |
| 644 | isCrossRepository, | |
| 645 | defaultBranch: "main", | |
| 646 | }); | |
| 647 | assert.strictEqual(name("main", true), "someone/main"); | |
| 648 | assert.strictEqual(name("feature", true), "feature"); | |
| 649 | assert.strictEqual(name("main", false), "main"); | |
| 650 | }); | |
| 651 | }); |
apps/server/src/sourceControl/GitHubRepositoryApi.ts created+1086| ... | ... | @@ -0,0 +1,1086 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as DateTime from "effect/DateTime"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Exit from "effect/Exit"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import * as Request from "effect/Request"; | |
| 9 | import * as RequestResolver from "effect/RequestResolver"; | |
| 10 | import * as Result from "effect/Result"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | ||
| 13 | import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; | |
| 14 | import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; | |
| 15 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 16 | import { | |
| 17 | detectSourceControlProviderFromRemoteUrl, | |
| 18 | isSshRemoteUrl, | |
| 19 | } from "@t3tools/shared/sourceControl"; | |
| 20 | ||
| 21 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 22 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 23 | ||
| 24 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 25 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 26 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 27 | import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; | |
| 28 | import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; | |
| 29 | import { | |
| 30 | decodeGitHubPullRequestEntries, | |
| 31 | type NormalizedGitHubPullRequestRecord, | |
| 32 | } from "./gitHubPullRequests.ts"; | |
| 33 | ||
| 34 | export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve; | |
| 35 | ||
| 36 | const gitHubFailureFields = { | |
| 37 | cwd: Schema.String, | |
| 38 | cause: Schema.Defect(), | |
| 39 | } as const; | |
| 40 | ||
| 41 | export class GitHubRepositoryUnavailableError extends Schema.TaggedError<GitHubRepositoryUnavailableError>()( | |
| 42 | "GitHubRepositoryUnavailableError", | |
| 43 | gitHubFailureFields, | |
| 44 | ) { | |
| 45 | override get message(): string { | |
| 46 | return "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`."; | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | export class GitHubRepositoryAuthenticationError extends Schema.TaggedError<GitHubRepositoryAuthenticationError>()( | |
| 51 | "GitHubRepositoryAuthenticationError", | |
| 52 | gitHubFailureFields, | |
| 53 | ) { | |
| 54 | override get message(): string { | |
| 55 | // A missing or turned-off credential already says what to do about it. | |
| 56 | return GitHubCredentials.isGitHubCredentialUnavailableError(this.cause) | |
| 57 | ? this.cause.message | |
| 58 | : "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry."; | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | export class GitHubRepositoryRateLimitError extends Schema.TaggedError<GitHubRepositoryRateLimitError>()( | |
| 63 | "GitHubRepositoryRateLimitError", | |
| 64 | { ...gitHubFailureFields, retryAt: Schema.optionalKey(Schema.Finite) }, | |
| 65 | ) { | |
| 66 | override get message(): string { | |
| 67 | return "GitHub API rate limit exceeded. Requests resume when the limit resets."; | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | export class GitHubPullRequestNotFoundError extends Schema.TaggedError<GitHubPullRequestNotFoundError>()( | |
| 72 | "GitHubPullRequestNotFoundError", | |
| 73 | gitHubFailureFields, | |
| 74 | ) { | |
| 75 | override get message(): string { | |
| 76 | return "Pull request not found. Check the PR number or URL and try again."; | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | export class GitHubRepositoryCommandError extends Schema.TaggedError<GitHubRepositoryCommandError>()( | |
| 81 | "GitHubRepositoryCommandError", | |
| 82 | { ...gitHubFailureFields, httpStatus: Schema.optional(Schema.Int) }, | |
| 83 | ) { | |
| 84 | override get message(): string { | |
| 85 | // GitHub's own reason ("A pull request already exists…") or the failed step's, when known. | |
| 86 | const reason = | |
| 87 | this.cause instanceof Error && this.cause.message.trim() !== "" | |
| 88 | ? this.cause.message.trim() | |
| 89 | : null; | |
| 90 | return reason === null ? "GitHub request failed." : reason; | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | const gitHubDecodeFields = { | |
| 95 | cwd: Schema.String, | |
| 96 | cause: Schema.Defect(), | |
| 97 | } as const; | |
| 98 | ||
| 99 | export class GitHubPullRequestListDecodeError extends Schema.TaggedError<GitHubPullRequestListDecodeError>()( | |
| 100 | "GitHubPullRequestListDecodeError", | |
| 101 | gitHubDecodeFields, | |
| 102 | ) { | |
| 103 | override get message(): string { | |
| 104 | return "GitHub returned an invalid pull request list."; | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | export class GitHubChangeRequestListDecodeError extends Schema.TaggedError<GitHubChangeRequestListDecodeError>()( | |
| 109 | "GitHubChangeRequestListDecodeError", | |
| 110 | gitHubDecodeFields, | |
| 111 | ) { | |
| 112 | override get message(): string { | |
| 113 | return "GitHub returned an invalid change request list."; | |
| 114 | } | |
| 115 | } | |
| 116 | ||
| 117 | export class GitHubPullRequestDecodeError extends Schema.TaggedError<GitHubPullRequestDecodeError>()( | |
| 118 | "GitHubPullRequestDecodeError", | |
| 119 | gitHubDecodeFields, | |
| 120 | ) { | |
| 121 | override get message(): string { | |
| 122 | return "GitHub returned an invalid pull request."; | |
| 123 | } | |
| 124 | } | |
| 125 | ||
| 126 | export class GitHubRepositoryDecodeError extends Schema.TaggedError<GitHubRepositoryDecodeError>()( | |
| 127 | "GitHubRepositoryDecodeError", | |
| 128 | gitHubDecodeFields, | |
| 129 | ) { | |
| 130 | override get message(): string { | |
| 131 | return "GitHub returned an invalid repository."; | |
| 132 | } | |
| 133 | } | |
| 134 | ||
| 135 | export const GitHubRepositoryApiError = Schema.Union([ | |
| 136 | GitHubRepositoryUnavailableError, | |
| 137 | GitHubRepositoryAuthenticationError, | |
| 138 | GitHubRepositoryRateLimitError, | |
| 139 | GitHubPullRequestNotFoundError, | |
| 140 | GitHubRepositoryCommandError, | |
| 141 | GitHubPullRequestListDecodeError, | |
| 142 | GitHubChangeRequestListDecodeError, | |
| 143 | GitHubPullRequestDecodeError, | |
| 144 | GitHubRepositoryDecodeError, | |
| 145 | ]); | |
| 146 | export type GitHubRepositoryApiError = typeof GitHubRepositoryApiError.Type; | |
| 147 | ||
| 148 | export const isGitHubRepositoryApiError = Schema.is(GitHubRepositoryApiError); | |
| 149 | ||
| 150 | /** Maps a GitHub API failure onto the errors callers of this service already handle. */ | |
| 151 | function fromGitHubApiError( | |
| 152 | cwd: string, | |
| 153 | error: GitHubApi.GitHubApiError, | |
| 154 | ): GitHubRepositoryApiError { | |
| 155 | const context = { cwd, cause: error }; | |
| 156 | switch (error._tag) { | |
| 157 | case "GitHubCliMissingError": | |
| 158 | return new GitHubRepositoryUnavailableError(context); | |
| 159 | case "GitHubNotSignedInError": | |
| 160 | case "GitHubHostDisabledError": | |
| 161 | case "GitHubApiAuthenticationError": | |
| 162 | return new GitHubRepositoryAuthenticationError(context); | |
| 163 | case "GitHubCliFailedError": | |
| 164 | return new GitHubRepositoryCommandError(context); | |
| 165 | case "GitHubApiRateLimitError": | |
| 166 | return new GitHubRepositoryRateLimitError({ | |
| 167 | ...context, | |
| 168 | ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), | |
| 169 | }); | |
| 170 | case "SourceControlRateLimitPausedError": | |
| 171 | return new GitHubRepositoryRateLimitError({ ...context, retryAt: error.retryAt }); | |
| 172 | case "GitHubApiNotFoundError": | |
| 173 | return new GitHubPullRequestNotFoundError(context); | |
| 174 | case "GitHubApiResponseError": | |
| 175 | return new GitHubRepositoryCommandError({ ...context, httpStatus: error.status }); | |
| 176 | case "GitHubApiRequestError": | |
| 177 | return new GitHubRepositoryCommandError(context); | |
| 178 | } | |
| 179 | } | |
| 180 | ||
| 181 | export interface GitHubPullRequestSummary { | |
| 182 | readonly number: number; | |
| 183 | readonly title: string; | |
| 184 | readonly url: string; | |
| 185 | readonly baseRefName: string; | |
| 186 | readonly headRefName: string; | |
| 187 | readonly headSha?: string; | |
| 188 | readonly state?: "open" | "closed" | "merged"; | |
| 189 | readonly isDraft?: boolean; | |
| 190 | readonly closedAt?: string | null; | |
| 191 | readonly mergedAt?: string | null; | |
| 192 | readonly updatedAt?: string; | |
| 193 | readonly isCrossRepository?: boolean; | |
| 194 | readonly headRepositoryNameWithOwner?: string | null; | |
| 195 | readonly headRepositoryOwnerLogin?: string | null; | |
| 196 | } | |
| 197 | ||
| 198 | function pullRequestSummary(input: NormalizedGitHubPullRequestRecord): GitHubPullRequestSummary { | |
| 199 | const { updatedAt, ...summary } = input; | |
| 200 | return { | |
| 201 | ...summary, | |
| 202 | ...(Option.isSome(updatedAt) ? { updatedAt: DateTime.formatIso(updatedAt.value) } : {}), | |
| 203 | }; | |
| 204 | } | |
| 205 | ||
| 206 | export interface GitHubRepositoryCloneUrls { | |
| 207 | readonly nameWithOwner: string; | |
| 208 | readonly url: string; | |
| 209 | readonly sshUrl: string; | |
| 210 | } | |
| 211 | ||
| 212 | export class GitHubRepositoryApi extends Context.Service< | |
| 213 | GitHubRepositoryApi, | |
| 214 | { | |
| 215 | readonly listOpenPullRequests: (input: { | |
| 216 | readonly cwd: string; | |
| 217 | readonly headSelector: string; | |
| 218 | readonly limit?: number; | |
| 219 | readonly rateLimitHost?: string; | |
| 220 | }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubRepositoryApiError>; | |
| 221 | ||
| 222 | /** | |
| 223 | * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the | |
| 224 | * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive | |
| 225 | * together share one GraphQL document. | |
| 226 | */ | |
| 227 | readonly listPullRequestsByHead: (input: { | |
| 228 | readonly cwd: string; | |
| 229 | readonly headSelector: string; | |
| 230 | readonly state: "open" | "closed" | "merged" | "all"; | |
| 231 | readonly limit: number; | |
| 232 | /** The checkout's GitHub API host. Without it, the host comes from the git remotes. */ | |
| 233 | readonly rateLimitHost?: string; | |
| 234 | }) => Effect.Effect<ReadonlyArray<NormalizedGitHubPullRequestRecord>, GitHubRepositoryApiError>; | |
| 235 | ||
| 236 | readonly getPullRequest: (input: { | |
| 237 | readonly cwd: string; | |
| 238 | readonly reference: string; | |
| 239 | readonly rateLimitHost?: string; | |
| 240 | }) => Effect.Effect<GitHubPullRequestSummary, GitHubRepositoryApiError>; | |
| 241 | ||
| 242 | readonly getRepositoryCloneUrls: (input: { | |
| 243 | readonly cwd: string; | |
| 244 | readonly repository: string; | |
| 245 | }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubRepositoryApiError>; | |
| 246 | ||
| 247 | readonly createRepository: (input: { | |
| 248 | readonly cwd: string; | |
| 249 | readonly repository: string; | |
| 250 | readonly visibility: SourceControlRepositoryVisibility; | |
| 251 | }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubRepositoryApiError>; | |
| 252 | ||
| 253 | readonly createPullRequest: (input: { | |
| 254 | readonly cwd: string; | |
| 255 | readonly baseBranch: string; | |
| 256 | readonly headSelector: string; | |
| 257 | readonly title: string; | |
| 258 | readonly bodyFile: string; | |
| 259 | }) => Effect.Effect<void, GitHubRepositoryApiError>; | |
| 260 | ||
| 261 | readonly getDefaultBranch: (input: { | |
| 262 | readonly cwd: string; | |
| 263 | readonly rateLimitHost?: string; | |
| 264 | }) => Effect.Effect<string | null, GitHubRepositoryApiError>; | |
| 265 | ||
| 266 | readonly checkoutPullRequest: (input: { | |
| 267 | readonly cwd: string; | |
| 268 | readonly reference: string; | |
| 269 | readonly force?: boolean; | |
| 270 | }) => Effect.Effect<void, GitHubRepositoryApiError>; | |
| 271 | } | |
| 272 | >()("t3/sourceControl/GitHubRepositoryApi") {} | |
| 273 | ||
| 274 | /** | |
| 275 | * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a | |
| 276 | * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin | |
| 277 | * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of | |
| 278 | * `git config --get-regexp '^remote\..*\.gh-resolved$'`. | |
| 279 | * | |
| 280 | * Null whenever gh might weigh the remotes differently: a remote on another host or under an | |
| 281 | * SSH alias, more than one mark, or several remotes with none of those names. Callers then | |
| 282 | * fall back to the provider's remote, then to the best-ranked remote on the host. | |
| 283 | */ | |
| 284 | export function selectGitHubBaseRepository(input: { | |
| 285 | readonly remotes: string; | |
| 286 | readonly resolved: string; | |
| 287 | readonly host: string; | |
| 288 | }): { readonly owner: string; readonly name: string } | null { | |
| 289 | const host = input.host.toLowerCase(); | |
| 290 | const repositories = new Map<string, { readonly owner: string; readonly name: string }>(); | |
| 291 | for (const line of input.remotes.split("\n")) { | |
| 292 | const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()); | |
| 293 | if (!match) continue; | |
| 294 | const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/"); | |
| 295 | if (remoteHost !== host || !owner || !name || rest.length > 0) return null; | |
| 296 | repositories.set(match[1]!, { owner, name }); | |
| 297 | } | |
| 298 | const marks = input.resolved | |
| 299 | .split("\n") | |
| 300 | .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) | |
| 301 | .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); | |
| 302 | if (marks.length > 1) return null; | |
| 303 | const [mark] = marks; | |
| 304 | if (mark) { | |
| 305 | if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; | |
| 306 | const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); | |
| 307 | return owner && name && rest.length === 0 ? { owner, name } : null; | |
| 308 | } | |
| 309 | // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the | |
| 310 | // top place has no defined winner. | |
| 311 | const score = (remoteName: string) => | |
| 312 | ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; | |
| 313 | const ranked = [...repositories.entries()].toSorted( | |
| 314 | ([left], [right]) => score(right) - score(left), | |
| 315 | ); | |
| 316 | const [top, next] = ranked; | |
| 317 | return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) | |
| 318 | ? top[1] | |
| 319 | : null; | |
| 320 | } | |
| 321 | ||
| 322 | const RawRepositorySchema = Schema.Struct({ | |
| 323 | full_name: TrimmedNonEmptyString, | |
| 324 | html_url: TrimmedNonEmptyString, | |
| 325 | ssh_url: TrimmedNonEmptyString, | |
| 326 | default_branch: Schema.optional(Schema.NullOr(Schema.String)), | |
| 327 | }); | |
| 328 | const decodeRawRepository = decodeJsonResult(RawRepositorySchema); | |
| 329 | ||
| 330 | function repositoryCloneUrls( | |
| 331 | raw: Schema.Schema.Type<typeof RawRepositorySchema>, | |
| 332 | ): GitHubRepositoryCloneUrls { | |
| 333 | return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url }; | |
| 334 | } | |
| 335 | ||
| 336 | const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString })); | |
| 337 | ||
| 338 | type PullRequestListState = "open" | "closed" | "merged" | "all"; | |
| 339 | ||
| 340 | /** The pull request fields every read selects, in GraphQL. */ | |
| 341 | const PULL_REQUEST_NODE_SELECTION = | |
| 342 | "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; | |
| 343 | const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = { | |
| 344 | open: ["OPEN"], | |
| 345 | closed: ["CLOSED"], | |
| 346 | merged: ["MERGED"], | |
| 347 | all: ["OPEN", "CLOSED", "MERGED"], | |
| 348 | }; | |
| 349 | /** | |
| 350 | * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one | |
| 351 | * single lookup, but half that keeps each answer near half a second. | |
| 352 | */ | |
| 353 | const HEAD_LOOKUPS_PER_DOCUMENT = 50; | |
| 354 | /** | |
| 355 | * How long a head lookup waits for company. Branch discovery reaches GitHub only after each | |
| 356 | * branch's own git reads, so lookups started together arrive tens of milliseconds apart. | |
| 357 | * A background sweep's lookups spread over up to ~300ms, and every document costs a point no | |
| 358 | * matter how few heads it holds, so reads without reserve wait longer. | |
| 359 | */ | |
| 360 | const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; | |
| 361 | const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; | |
| 362 | /** | |
| 363 | * Background documents fill up under the longer window, and a failed document fails every head | |
| 364 | * in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own | |
| 365 | * processing limit; twenty-five took ~7s. | |
| 366 | */ | |
| 367 | const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25; | |
| 368 | /** A full document is 5,000 rows of well under 2 KB each. */ | |
| 369 | const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; | |
| 370 | /** | |
| 371 | * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch | |
| 372 | * named like a busy default (`main`) is the case this bounds; the owner's own row is near the | |
| 373 | * top because the newest come first. | |
| 374 | */ | |
| 375 | const OWNER_HEAD_SCAN_LIMIT = 100; | |
| 376 | ||
| 377 | class PullRequestsByHeadRead extends Request.Class< | |
| 378 | { | |
| 379 | readonly cwd: string; | |
| 380 | readonly host: string; | |
| 381 | readonly owner: string; | |
| 382 | readonly name: string; | |
| 383 | readonly headRefName: string; | |
| 384 | readonly state: PullRequestListState; | |
| 385 | readonly limit: number; | |
| 386 | readonly allowReserve: boolean; | |
| 387 | }, | |
| 388 | ReadonlyArray<NormalizedGitHubPullRequestRecord>, | |
| 389 | GitHubRepositoryApiError | |
| 390 | > {} | |
| 391 | ||
| 392 | /** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ | |
| 393 | function buildPullRequestsByHeadQuery( | |
| 394 | lookups: ReadonlyArray<Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit">>, | |
| 395 | ): { readonly document: string; readonly variables: Record<string, unknown> } { | |
| 396 | const variables: Record<string, unknown> = {}; | |
| 397 | const declarations: string[] = ["$owner: String!", "$name: String!"]; | |
| 398 | const selections: string[] = []; | |
| 399 | for (const [index, lookup] of lookups.entries()) { | |
| 400 | variables[`h${index}`] = lookup.headRefName; | |
| 401 | variables[`s${index}`] = GRAPHQL_STATES[lookup.state]; | |
| 402 | declarations.push(`$h${index}: String!`, `$s${index}: [PullRequestState!]`); | |
| 403 | // `gh pr list` orders the same way, so a head with more matches than the limit keeps the | |
| 404 | // same rows. | |
| 405 | selections.push( | |
| 406 | ` h${index}: pullRequests(headRefName: $h${index}, states: $s${index}, first: ${lookup.limit}, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${PULL_REQUEST_NODE_SELECTION} } }`, | |
| 407 | ); | |
| 408 | } | |
| 409 | return { | |
| 410 | document: `query PullRequestsByHead(${declarations.join(", ")}) {\n repository(owner: $owner, name: $name) {\n${selections.join("\n")}\n }\n}`, | |
| 411 | variables, | |
| 412 | }; | |
| 413 | } | |
| 414 | ||
| 415 | const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { | |
| 416 | repository(owner: $owner, name: $name) { | |
| 417 | pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} } | |
| 418 | } | |
| 419 | }`; | |
| 420 | ||
| 421 | const decodePullRequestsByHead = decodeJsonResult( | |
| 422 | Schema.Struct({ | |
| 423 | data: Schema.Struct({ | |
| 424 | repository: Schema.NullOr( | |
| 425 | Schema.Record( | |
| 426 | Schema.String, | |
| 427 | Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), | |
| 428 | ), | |
| 429 | ), | |
| 430 | }), | |
| 431 | }), | |
| 432 | ); | |
| 433 | ||
| 434 | const decodePullRequestByNumber = decodeJsonResult( | |
| 435 | Schema.Struct({ | |
| 436 | data: Schema.Struct({ | |
| 437 | repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })), | |
| 438 | }), | |
| 439 | }), | |
| 440 | ); | |
| 441 | ||
| 442 | /** A repository on a GitHub host: the API it is read through, and its owner and name. */ | |
| 443 | export interface GitHubRepositoryLocator { | |
| 444 | readonly host: string; | |
| 445 | readonly owner: string; | |
| 446 | readonly name: string; | |
| 447 | } | |
| 448 | ||
| 449 | /** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ | |
| 450 | function parseGitHubRepositorySelector( | |
| 451 | selector: string, | |
| 452 | defaultHost: string, | |
| 453 | ): GitHubRepositoryLocator | null { | |
| 454 | const trimmed = selector.trim().replace(/\.git$/i, ""); | |
| 455 | if (/^https?:\/\//i.test(trimmed)) { | |
| 456 | try { | |
| 457 | const url = new URL(trimmed); | |
| 458 | const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean); | |
| 459 | return owner && name && rest.length === 0 | |
| 460 | ? { host: url.host.toLowerCase(), owner, name } | |
| 461 | : null; | |
| 462 | } catch { | |
| 463 | return null; | |
| 464 | } | |
| 465 | } | |
| 466 | const parts = trimmed.split("/").filter(Boolean); | |
| 467 | if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; | |
| 468 | if (parts.length === 3) | |
| 469 | return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! }; | |
| 470 | return null; | |
| 471 | } | |
| 472 | ||
| 473 | /** | |
| 474 | * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request | |
| 475 | * URL, or a branch name. | |
| 476 | */ | |
| 477 | function parsePullRequestReference( | |
| 478 | reference: string, | |
| 479 | ): | |
| 480 | | { readonly kind: "number"; readonly number: number } | |
| 481 | | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number } | |
| 482 | | { readonly kind: "branch"; readonly headSelector: string } { | |
| 483 | const trimmed = reference.trim(); | |
| 484 | const numbered = /^#?([1-9]\d*)$/.exec(trimmed); | |
| 485 | if (numbered) return { kind: "number", number: Number(numbered[1]) }; | |
| 486 | if (/^https?:\/\//i.test(trimmed)) { | |
| 487 | try { | |
| 488 | const url = new URL(trimmed); | |
| 489 | const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname); | |
| 490 | if (match) { | |
| 491 | return { | |
| 492 | kind: "url", | |
| 493 | locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! }, | |
| 494 | number: Number(match[3]), | |
| 495 | }; | |
| 496 | } | |
| 497 | } catch { | |
| 498 | // Not a URL after all; read it as a branch. | |
| 499 | } | |
| 500 | } | |
| 501 | return { kind: "branch", headSelector: trimmed }; | |
| 502 | } | |
| 503 | ||
| 504 | /** | |
| 505 | * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH | |
| 506 | * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through | |
| 507 | * `github.com`, which is what such an alias almost always stands for (issue #6198). | |
| 508 | */ | |
| 509 | export function gitHubApiHostForRemote(remoteUrl: string): string | null { | |
| 510 | const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); | |
| 511 | if (provider === null) return null; | |
| 512 | const host = new URL(provider.baseUrl).host.toLowerCase(); | |
| 513 | // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. | |
| 514 | if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { | |
| 515 | return host.includes("github") ? "github.com" : null; | |
| 516 | } | |
| 517 | return provider.kind === "github" ? host : null; | |
| 518 | } | |
| 519 | ||
| 520 | /** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ | |
| 521 | function apiHostForHint(host: string): string { | |
| 522 | const normalized = host.toLowerCase(); | |
| 523 | return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; | |
| 524 | } | |
| 525 | ||
| 526 | /** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ | |
| 527 | export function pullRequestCheckoutBranchName(input: { | |
| 528 | readonly headRefName: string; | |
| 529 | readonly headOwner: string | null; | |
| 530 | readonly isCrossRepository: boolean; | |
| 531 | readonly defaultBranch: string | null; | |
| 532 | }): string { | |
| 533 | // gh prefixes the owner only where the fork's branch would take over the default branch's | |
| 534 | // name, which is the one collision every fork pull request from `main` would hit. | |
| 535 | return input.isCrossRepository && | |
| 536 | input.headOwner !== null && | |
| 537 | input.defaultBranch !== null && | |
| 538 | input.headRefName === input.defaultBranch | |
| 539 | ? `${input.headOwner}/${input.headRefName}` | |
| 540 | : input.headRefName; | |
| 541 | } | |
| 542 | ||
| 543 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 544 | export const make = Effect.gen(function* () { | |
| 545 | const process = yield* VcsProcess.VcsProcess; | |
| 546 | const environment = yield* HostProcessEnvironment; | |
| 547 | const api = yield* GitHubApi.GitHubApi; | |
| 548 | const git = yield* GitVcsDriver.GitVcsDriver; | |
| 549 | const fileSystem = yield* FileSystem.FileSystem; | |
| 550 | ||
| 551 | const gitRead = (cwd: string, args: ReadonlyArray<string>) => | |
| 552 | process.run({ | |
| 553 | operation: "GitHubRepositoryApi.resolveRepository", | |
| 554 | command: "git", | |
| 555 | args, | |
| 556 | cwd, | |
| 557 | allowNonZeroExit: true, | |
| 558 | timeoutMs: 5_000, | |
| 559 | }); | |
| 560 | ||
| 561 | const commandFailure = (cwd: string, detail: string) => | |
| 562 | new GitHubRepositoryCommandError({ cwd, cause: new Error(detail) }); | |
| 563 | ||
| 564 | /** | |
| 565 | * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else | |
| 566 | * the remote the caller resolved the provider from, else the best-ranked GitHub remote. | |
| 567 | */ | |
| 568 | const resolveRepository = Effect.fn("GitHubRepositoryApi.resolveRepository")(function* (input: { | |
| 569 | readonly cwd: string; | |
| 570 | readonly host?: string | undefined; | |
| 571 | }) { | |
| 572 | const envRepository = environment.GH_REPO?.trim(); | |
| 573 | const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase(); | |
| 574 | if (envRepository) { | |
| 575 | const locator = parseGitHubRepositorySelector(envRepository, defaultHost); | |
| 576 | if (locator !== null) return locator; | |
| 577 | } | |
| 578 | const [remotes, resolved] = yield* Effect.all([ | |
| 579 | gitRead(input.cwd, ["remote", "-v"]), | |
| 580 | gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), | |
| 581 | ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); | |
| 582 | const remoteOutput = remotes?.exitCode === 0 ? remotes.stdout : ""; | |
| 583 | const fetchRemotes = remoteOutput | |
| 584 | .split("\n") | |
| 585 | .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) | |
| 586 | .filter((match): match is RegExpExecArray => match !== null) | |
| 587 | .map((match) => ({ | |
| 588 | name: match[1]!, | |
| 589 | url: match[2]!, | |
| 590 | host: gitHubApiHostForRemote(match[2]!), | |
| 591 | })); | |
| 592 | const host = | |
| 593 | (input.host === undefined ? undefined : apiHostForHint(input.host)) ?? | |
| 594 | fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? | |
| 595 | fetchRemotes.find((remote) => remote.host !== null)?.host ?? | |
| 596 | defaultHost; | |
| 597 | const selected = selectGitHubBaseRepository({ | |
| 598 | remotes: remoteOutput, | |
| 599 | resolved: resolved?.exitCode === 0 ? resolved.stdout : "", | |
| 600 | host, | |
| 601 | }); | |
| 602 | if (selected !== null) return { host, ...selected }; | |
| 603 | // gh's own order without its prompt: upstream, github, origin, then the first remote, among | |
| 604 | // the ones on this host (an SSH alias counts as its API host). | |
| 605 | const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); | |
| 606 | const candidates = fetchRemotes | |
| 607 | .filter((remote) => remote.host === host) | |
| 608 | .toSorted((left, right) => { | |
| 609 | const l = rank(left.name); | |
| 610 | const r = rank(right.name); | |
| 611 | return (l === -1 ? 99 : l) - (r === -1 ? 99 : r); | |
| 612 | }); | |
| 613 | for (const remote of candidates) { | |
| 614 | const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1); | |
| 615 | if (owner && name && rest.length === 0) return { host, owner, name }; | |
| 616 | } | |
| 617 | return yield* commandFailure( | |
| 618 | input.cwd, | |
| 619 | `No GitHub repository on ${host} was found among this checkout's git remotes.`, | |
| 620 | ); | |
| 621 | }); | |
| 622 | ||
| 623 | const graphqlJson = <A>( | |
| 624 | cwd: string, | |
| 625 | input: GitHubApi.GitHubGraphQlInput, | |
| 626 | decode: (raw: string) => Result.Result<A, unknown>, | |
| 627 | onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError, | |
| 628 | ) => | |
| 629 | api.graphql(input).pipe( | |
| 630 | Effect.mapError((error) => fromGitHubApiError(cwd, error)), | |
| 631 | Effect.flatMap((raw) => { | |
| 632 | const decoded = decode(raw); | |
| 633 | return Result.isSuccess(decoded) | |
| 634 | ? Effect.succeed(decoded.success) | |
| 635 | : Effect.fail(onDecodeFailure(decoded.failure)); | |
| 636 | }), | |
| 637 | ); | |
| 638 | ||
| 639 | const rest = (cwd: string, input: GitHubApi.GitHubRestInput) => | |
| 640 | api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(cwd, error))); | |
| 641 | ||
| 642 | /** Pull requests whose head branch is `headRefName` on one repository. */ | |
| 643 | const readPullRequestsByHead = (input: { | |
| 644 | readonly cwd: string; | |
| 645 | readonly locator: GitHubRepositoryLocator; | |
| 646 | readonly lookups: ReadonlyArray< | |
| 647 | Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit"> | |
| 648 | >; | |
| 649 | readonly allowReserve: boolean; | |
| 650 | readonly onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError; | |
| 651 | }) => { | |
| 652 | const query = buildPullRequestsByHeadQuery(input.lookups); | |
| 653 | return graphqlJson( | |
| 654 | input.cwd, | |
| 655 | { | |
| 656 | host: input.locator.host, | |
| 657 | operation: "listPullRequestsByHead", | |
| 658 | query: query.document, | |
| 659 | variables: { owner: input.locator.owner, name: input.locator.name, ...query.variables }, | |
| 660 | allowReserve: input.allowReserve, | |
| 661 | // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred | |
| 662 | // fork pull requests, so the usual cap would cut the answer short. | |
| 663 | maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, | |
| 664 | }, | |
| 665 | decodePullRequestsByHead, | |
| 666 | input.onDecodeFailure, | |
| 667 | ).pipe( | |
| 668 | Effect.flatMap((decoded) => | |
| 669 | decoded.data.repository === null | |
| 670 | ? Effect.fail( | |
| 671 | new GitHubPullRequestNotFoundError({ | |
| 672 | cwd: input.cwd, | |
| 673 | cause: new Error("The repository could not be read."), | |
| 674 | }), | |
| 675 | ) | |
| 676 | : Effect.succeed(decoded.data.repository), | |
| 677 | ), | |
| 678 | ); | |
| 679 | }; | |
| 680 | ||
| 681 | const headResolver = RequestResolver.makeGrouped<PullRequestsByHeadRead, string>({ | |
| 682 | key: ({ request, context }) => | |
| 683 | [ | |
| 684 | request.host, | |
| 685 | request.owner, | |
| 686 | request.name, | |
| 687 | String(request.allowReserve), | |
| 688 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) | |
| 689 | ?.credentialFingerprint ?? "", | |
| 690 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), | |
| 691 | ].join("\0"), | |
| 692 | resolver: (entries) => { | |
| 693 | const [first] = entries; | |
| 694 | const { cwd, host, owner, name, allowReserve } = first.request; | |
| 695 | return readPullRequestsByHead({ | |
| 696 | cwd, | |
| 697 | locator: { host, owner, name }, | |
| 698 | lookups: entries.map((entry) => entry.request), | |
| 699 | allowReserve, | |
| 700 | onDecodeFailure: (cause) => new GitHubChangeRequestListDecodeError({ cwd, cause }), | |
| 701 | }).pipe( | |
| 702 | Effect.map((aliases) => { | |
| 703 | for (const [index, entry] of entries.entries()) { | |
| 704 | const alias = aliases[`h${index}`]; | |
| 705 | entry.completeUnsafe( | |
| 706 | Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), | |
| 707 | ); | |
| 708 | } | |
| 709 | }), | |
| 710 | Effect.catchCause((cause) => | |
| 711 | Effect.sync(() => { | |
| 712 | for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); | |
| 713 | }), | |
| 714 | ), | |
| 715 | ); | |
| 716 | }, | |
| 717 | }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT)); | |
| 718 | const interactiveHeadResolver = headResolver.pipe( | |
| 719 | RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW), | |
| 720 | ); | |
| 721 | const backgroundHeadResolver = headResolver.pipe( | |
| 722 | RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT), | |
| 723 | RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), | |
| 724 | ); | |
| 725 | ||
| 726 | const listByHead = Effect.fn("GitHubRepositoryApi.listByHead")(function* (input: { | |
| 727 | readonly cwd: string; | |
| 728 | readonly headSelector: string; | |
| 729 | readonly state: PullRequestListState; | |
| 730 | readonly limit: number; | |
| 731 | readonly rateLimitHost?: string | undefined; | |
| 732 | readonly allowReserve: boolean; | |
| 733 | }) { | |
| 734 | const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); | |
| 735 | const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); | |
| 736 | // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the | |
| 737 | // owner is matched on the rows it returns. | |
| 738 | const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); | |
| 739 | const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); | |
| 740 | const rows = yield* Effect.request( | |
| 741 | new PullRequestsByHeadRead({ | |
| 742 | cwd: input.cwd, | |
| 743 | ...locator, | |
| 744 | headRefName, | |
| 745 | state: input.state, | |
| 746 | limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, | |
| 747 | allowReserve: input.allowReserve, | |
| 748 | }), | |
| 749 | input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver, | |
| 750 | ); | |
| 751 | if (!ownerMatch) return rows; | |
| 752 | const headOwner = ownerMatch[1]!.toLowerCase(); | |
| 753 | return rows | |
| 754 | .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner) | |
| 755 | .slice(0, limit); | |
| 756 | }); | |
| 757 | ||
| 758 | const toSummaries = (rows: ReadonlyArray<NormalizedGitHubPullRequestRecord>) => | |
| 759 | rows.map(pullRequestSummary); | |
| 760 | ||
| 761 | const readPullRequest = Effect.fn("GitHubRepositoryApi.readPullRequest")(function* (input: { | |
| 762 | readonly cwd: string; | |
| 763 | readonly reference: string; | |
| 764 | readonly rateLimitHost?: string | undefined; | |
| 765 | }) { | |
| 766 | const parsed = parsePullRequestReference(input.reference); | |
| 767 | if (parsed.kind === "branch") { | |
| 768 | // `gh pr view <branch>` prefers an open pull request, then the newest of any state. | |
| 769 | const [open] = yield* listByHead({ | |
| 770 | cwd: input.cwd, | |
| 771 | headSelector: parsed.headSelector, | |
| 772 | state: "open", | |
| 773 | limit: 1, | |
| 774 | rateLimitHost: input.rateLimitHost, | |
| 775 | allowReserve: true, | |
| 776 | }); | |
| 777 | const found = | |
| 778 | open ?? | |
| 779 | (yield* listByHead({ | |
| 780 | cwd: input.cwd, | |
| 781 | headSelector: parsed.headSelector, | |
| 782 | state: "all", | |
| 783 | limit: 1, | |
| 784 | rateLimitHost: input.rateLimitHost, | |
| 785 | allowReserve: true, | |
| 786 | }))[0]; | |
| 787 | if (found === undefined) { | |
| 788 | return yield* new GitHubPullRequestNotFoundError({ | |
| 789 | cwd: input.cwd, | |
| 790 | cause: new Error("No pull request has this head branch."), | |
| 791 | }); | |
| 792 | } | |
| 793 | return found; | |
| 794 | } | |
| 795 | const locator = | |
| 796 | parsed.kind === "url" | |
| 797 | ? parsed.locator | |
| 798 | : yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); | |
| 799 | const decodeFailure = (cause: unknown) => | |
| 800 | new GitHubPullRequestDecodeError({ cwd: input.cwd, cause }); | |
| 801 | const decoded = yield* graphqlJson( | |
| 802 | input.cwd, | |
| 803 | { | |
| 804 | host: locator.host, | |
| 805 | operation: "getPullRequest", | |
| 806 | query: PULL_REQUEST_BY_NUMBER_QUERY, | |
| 807 | variables: { owner: locator.owner, name: locator.name, number: parsed.number }, | |
| 808 | allowReserve: true, | |
| 809 | }, | |
| 810 | decodePullRequestByNumber, | |
| 811 | decodeFailure, | |
| 812 | ); | |
| 813 | const node = decoded.data.repository?.pullRequest; | |
| 814 | if (node == null) { | |
| 815 | return yield* new GitHubPullRequestNotFoundError({ | |
| 816 | cwd: input.cwd, | |
| 817 | cause: new Error("The pull request does not exist."), | |
| 818 | }); | |
| 819 | } | |
| 820 | const [record] = decodeGitHubPullRequestEntries([node]); | |
| 821 | if (record === undefined) return yield* decodeFailure(new Error("Malformed pull request.")); | |
| 822 | return record; | |
| 823 | }); | |
| 824 | ||
| 825 | const readRepository = Effect.fn("GitHubRepositoryApi.readRepository")(function* ( | |
| 826 | cwd: string, | |
| 827 | locator: GitHubRepositoryLocator, | |
| 828 | ) { | |
| 829 | const response = yield* rest(cwd, { | |
| 830 | host: locator.host, | |
| 831 | operation: "getRepository", | |
| 832 | path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, | |
| 833 | allowReserve: true, | |
| 834 | }); | |
| 835 | const decoded = decodeRawRepository(response.body); | |
| 836 | if (Result.isFailure(decoded)) { | |
| 837 | return yield* new GitHubRepositoryDecodeError({ | |
| 838 | cwd, | |
| 839 | cause: decoded.failure, | |
| 840 | }); | |
| 841 | } | |
| 842 | return decoded.success; | |
| 843 | }); | |
| 844 | ||
| 845 | const readViewerLogin = Effect.fn("GitHubRepositoryApi.readViewerLogin")(function* ( | |
| 846 | cwd: string, | |
| 847 | host: string, | |
| 848 | ) { | |
| 849 | const response = yield* rest(cwd, { host, operation: "getViewer", path: "user" }); | |
| 850 | const decoded = decodeViewerLogin(response.body); | |
| 851 | if (Result.isFailure(decoded)) { | |
| 852 | return yield* new GitHubRepositoryCommandError({ cwd, cause: decoded.failure }); | |
| 853 | } | |
| 854 | return decoded.success.login; | |
| 855 | }); | |
| 856 | ||
| 857 | const gitFailure = (cwd: string) => (cause: unknown) => | |
| 858 | new GitHubRepositoryCommandError({ cwd, cause }); | |
| 859 | ||
| 860 | const runGit = (cwd: string, operation: string, args: ReadonlyArray<string>) => | |
| 861 | git.execute({ operation: `GitHubRepositoryApi.checkoutPullRequest.${operation}`, cwd, args }); | |
| 862 | ||
| 863 | /** | |
| 864 | * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a | |
| 865 | * fork gets a remote of its own), checked out under the name gh would give it, and set to | |
| 866 | * track the head. A head branch that is gone is read from the base's `refs/pull/<n>/head`. | |
| 867 | * An existing branch fast-forwards, or with `force` is reset to the pull request. | |
| 868 | */ | |
| 869 | const checkoutPullRequest: GitHubRepositoryApi["Service"]["checkoutPullRequest"] = Effect.fn( | |
| 870 | "GitHubRepositoryApi.checkoutPullRequest", | |
| 871 | )(function* (input) { | |
| 872 | const reference = parsePullRequestReference(input.reference); | |
| 873 | const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); | |
| 874 | const base = | |
| 875 | reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd }); | |
| 876 | const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); | |
| 877 | const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; | |
| 878 | const isCrossRepository = | |
| 879 | pullRequest.isCrossRepository ?? | |
| 880 | (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); | |
| 881 | const headOwner = | |
| 882 | pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; | |
| 883 | ||
| 884 | const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( | |
| 885 | Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), | |
| 886 | Effect.mapError(gitFailure(input.cwd)), | |
| 887 | ); | |
| 888 | const remoteFor = (nameWithOwner: string) => | |
| 889 | remotes | |
| 890 | .split("\n") | |
| 891 | .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) | |
| 892 | .find( | |
| 893 | (match) => | |
| 894 | match !== null && | |
| 895 | normalizeGitRemoteUrl(match[2]!).split("/").slice(1).join("/") === | |
| 896 | nameWithOwner.toLowerCase(), | |
| 897 | )?.[1] ?? null; | |
| 898 | const baseRemote = Effect.suspend(() => { | |
| 899 | const known = remoteFor(baseNameWithOwner); | |
| 900 | return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); | |
| 901 | }); | |
| 902 | // A fork's branch named like the base's default branch is checked out under the owner's | |
| 903 | // prefix. Without the default branch that collision cannot be ruled out, and the checkout | |
| 904 | // would reset the local default branch to the fork's commit, so it fails instead. | |
| 905 | const defaultBranch = isCrossRepository | |
| 906 | ? yield* readRepository(input.cwd, base).pipe( | |
| 907 | Effect.map((repository) => repository.default_branch ?? null), | |
| 908 | ) | |
| 909 | : null; | |
| 910 | const localBranch = pullRequestCheckoutBranchName({ | |
| 911 | headRefName: pullRequest.headRefName, | |
| 912 | headOwner, | |
| 913 | isCrossRepository, | |
| 914 | defaultBranch, | |
| 915 | }); | |
| 916 | ||
| 917 | /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ | |
| 918 | const headRemote = Effect.gen(function* () { | |
| 919 | if (!isCrossRepository) return yield* baseRemote; | |
| 920 | if (headNameWithOwner === null) return yield* commandFailure(input.cwd, "The fork is gone."); | |
| 921 | const known = remoteFor(headNameWithOwner); | |
| 922 | if (known !== null) return known; | |
| 923 | const [owner, name] = headNameWithOwner.split("/"); | |
| 924 | const fork = yield* readRepository(input.cwd, { | |
| 925 | host: base.host, | |
| 926 | owner: owner!, | |
| 927 | name: name!, | |
| 928 | }); | |
| 929 | const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); | |
| 930 | return yield* git.ensureRemote({ | |
| 931 | cwd: input.cwd, | |
| 932 | preferredName: headOwner ?? "fork", | |
| 933 | url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url, | |
| 934 | }); | |
| 935 | }); | |
| 936 | ||
| 937 | const exists = (yield* git | |
| 938 | .listLocalBranchNames(input.cwd) | |
| 939 | .pipe(Effect.mapError(gitFailure(input.cwd)))).includes(localBranch); | |
| 940 | ||
| 941 | // The commit to check out, fetched from the head branch where it still exists. | |
| 942 | const target = yield* Effect.gen(function* () { | |
| 943 | const remoteName = yield* headRemote; | |
| 944 | yield* git.fetchRemoteTrackingBranch({ | |
| 945 | cwd: input.cwd, | |
| 946 | remoteName, | |
| 947 | remoteBranch: pullRequest.headRefName, | |
| 948 | }); | |
| 949 | return { | |
| 950 | ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`, | |
| 951 | upstream: { remoteName, remoteBranch: pullRequest.headRefName }, | |
| 952 | }; | |
| 953 | }).pipe( | |
| 954 | Effect.catch(() => | |
| 955 | Effect.gen(function* () { | |
| 956 | yield* runGit(input.cwd, "fetchPullRef", [ | |
| 957 | "fetch", | |
| 958 | "--quiet", | |
| 959 | "--no-tags", | |
| 960 | yield* baseRemote, | |
| 961 | `refs/pull/${pullRequest.number}/head`, | |
| 962 | ]); | |
| 963 | const { commitSha } = yield* git.resolveCommit({ | |
| 964 | cwd: input.cwd, | |
| 965 | revision: "FETCH_HEAD", | |
| 966 | }); | |
| 967 | return { ref: commitSha, upstream: null }; | |
| 968 | }), | |
| 969 | ), | |
| 970 | Effect.mapError(gitFailure(input.cwd)), | |
| 971 | ); | |
| 972 | ||
| 973 | yield* Effect.gen(function* () { | |
| 974 | if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]); | |
| 975 | yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); | |
| 976 | if (exists) { | |
| 977 | yield* runGit( | |
| 978 | input.cwd, | |
| 979 | "sync", | |
| 980 | input.force === true | |
| 981 | ? ["reset", "--hard", "--quiet", target.ref] | |
| 982 | : ["merge", "--ff-only", "--quiet", target.ref], | |
| 983 | ); | |
| 984 | } | |
| 985 | // Tracking is set once the branch is the pull request's, so a sync that fails leaves an | |
| 986 | // existing branch's upstream as it was. | |
| 987 | if (target.upstream !== null) { | |
| 988 | yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); | |
| 989 | } | |
| 990 | }).pipe(Effect.mapError(gitFailure(input.cwd))); | |
| 991 | }); | |
| 992 | ||
| 993 | return GitHubRepositoryApi.of({ | |
| 994 | listPullRequestsByHead: (input) => | |
| 995 | AllowGitHubReserve.pipe( | |
| 996 | Effect.flatMap((allowReserve) => listByHead({ ...input, allowReserve })), | |
| 997 | ), | |
| 998 | listOpenPullRequests: (input) => | |
| 999 | listByHead({ | |
| 1000 | cwd: input.cwd, | |
| 1001 | headSelector: input.headSelector, | |
| 1002 | state: "open", | |
| 1003 | limit: input.limit ?? 1, | |
| 1004 | rateLimitHost: input.rateLimitHost, | |
| 1005 | allowReserve: true, | |
| 1006 | }).pipe(Effect.map(toSummaries)), | |
| 1007 | getPullRequest: (input) => readPullRequest(input).pipe(Effect.map(pullRequestSummary)), | |
| 1008 | getRepositoryCloneUrls: (input) => | |
| 1009 | Effect.gen(function* () { | |
| 1010 | const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe( | |
| 1011 | Effect.map((locator) => locator.host), | |
| 1012 | Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), | |
| 1013 | )).toLowerCase(); | |
| 1014 | const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); | |
| 1015 | if (locator === null) { | |
| 1016 | return yield* commandFailure(input.cwd, "Repositories are named owner/name."); | |
| 1017 | } | |
| 1018 | return repositoryCloneUrls(yield* readRepository(input.cwd, locator)); | |
| 1019 | }), | |
| 1020 | createRepository: (input) => | |
| 1021 | Effect.gen(function* () { | |
| 1022 | const locator = parseGitHubRepositorySelector( | |
| 1023 | input.repository, | |
| 1024 | (environment.GH_HOST ?? "github.com").toLowerCase(), | |
| 1025 | ); | |
| 1026 | const viewer = locator === null ? null : yield* readViewerLogin(input.cwd, locator.host); | |
| 1027 | const owner = locator?.owner ?? viewer; | |
| 1028 | const name = locator?.name ?? input.repository.trim(); | |
| 1029 | const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); | |
| 1030 | const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); | |
| 1031 | const response = yield* rest(input.cwd, { | |
| 1032 | host, | |
| 1033 | operation: "createRepository", | |
| 1034 | method: "POST", | |
| 1035 | path: | |
| 1036 | isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, | |
| 1037 | body: { name, private: input.visibility === "private" }, | |
| 1038 | }); | |
| 1039 | const decoded = decodeRawRepository(response.body); | |
| 1040 | if (Result.isFailure(decoded)) { | |
| 1041 | return yield* new GitHubRepositoryDecodeError({ | |
| 1042 | cwd: input.cwd, | |
| 1043 | cause: decoded.failure, | |
| 1044 | }); | |
| 1045 | } | |
| 1046 | return repositoryCloneUrls(decoded.success); | |
| 1047 | }), | |
| 1048 | createPullRequest: (input) => | |
| 1049 | Effect.gen(function* () { | |
| 1050 | const locator = yield* resolveRepository({ cwd: input.cwd }); | |
| 1051 | const body = yield* fileSystem | |
| 1052 | .readFileString(input.bodyFile) | |
| 1053 | .pipe(Effect.mapError(gitFailure(input.cwd))); | |
| 1054 | yield* rest(input.cwd, { | |
| 1055 | host: locator.host, | |
| 1056 | operation: "createPullRequest", | |
| 1057 | method: "POST", | |
| 1058 | path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, | |
| 1059 | // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. | |
| 1060 | // gh allows maintainer edits unless told otherwise; the API's default is not documented. | |
| 1061 | body: { | |
| 1062 | base: input.baseBranch, | |
| 1063 | head: input.headSelector, | |
| 1064 | title: input.title, | |
| 1065 | body, | |
| 1066 | maintainer_can_modify: true, | |
| 1067 | }, | |
| 1068 | }); | |
| 1069 | }), | |
| 1070 | getDefaultBranch: (input) => | |
| 1071 | Effect.gen(function* () { | |
| 1072 | const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); | |
| 1073 | const repository = yield* readRepository(input.cwd, locator); | |
| 1074 | const branch = repository.default_branch?.trim() ?? ""; | |
| 1075 | return branch.length > 0 ? branch : null; | |
| 1076 | }), | |
| 1077 | checkoutPullRequest, | |
| 1078 | }); | |
| 1079 | }); | |
| 1080 | ||
| 1081 | export const layer = Layer.effect(GitHubRepositoryApi, make).pipe( | |
| 1082 | Layer.provideMerge(GitHubApi.layer), | |
| 1083 | Layer.provideMerge(GitHubCredentials.layer), | |
| 1084 | Layer.provideMerge(GitHubGraphQlBudget.layer), | |
| 1085 | Layer.provideMerge(SourceControlRateLimit.layer), | |
| 1086 | ); |
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+14-12| ... | ... | @@ -8,7 +8,7 @@ import { ChildProcessSpawner } from "effect/process"; |
| 8 | 8 | |
| 9 | 9 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 10 | 10 | import * as GitHubApi from "./GitHubApi.ts"; |
| 11 | import * as GitHubCli from "./GitHubCli.ts"; | |
| 11 | import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; | |
| 12 | 12 | import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; |
| 13 | 13 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 14 | 14 | |
| ... | ... | @@ -27,12 +27,15 @@ const processResult = ( |
| 27 | 27 | }); |
| 28 | 28 | |
| 29 | 29 | function makeProvider( |
| 30 | github: Partial<GitHubCli.GitHubCli["Service"]>, | |
| 30 | github: Partial<GitHubRepositoryApi.GitHubRepositoryApi["Service"]>, | |
| 31 | 31 | api: Partial<GitHubApi.GitHubApi["Service"]> = {}, |
| 32 | 32 | ) { |
| 33 | 33 | return GitHubSourceControlProvider.make.pipe( |
| 34 | 34 | Effect.provide( |
| 35 | Layer.merge(Layer.mock(GitHubCli.GitHubCli)(github), Layer.mock(GitHubApi.GitHubApi)(api)), | |
| 35 | Layer.merge( | |
| 36 | Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(github), | |
| 37 | Layer.mock(GitHubApi.GitHubApi)(api), | |
| 38 | ), | |
| 36 | 39 | ), |
| 37 | 40 | ); |
| 38 | 41 | } |
| ... | ... | @@ -87,10 +90,9 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = |
| 87 | 90 | }), |
| 88 | 91 | ); |
| 89 | 92 | |
| 90 | it.effect("adds safe request context while retaining GitHub CLI causes", () => | |
| 93 | it.effect("adds safe request context while retaining GitHub API causes", () => | |
| 91 | 94 | Effect.gen(function* () { |
| 92 | const cause = new GitHubCli.GitHubPullRequestNotFoundError({ | |
| 93 | command: "gh", | |
| 95 | const cause = new GitHubRepositoryApi.GitHubPullRequestNotFoundError({ | |
| 94 | 96 | cwd: "/repo", |
| 95 | 97 | cause: new Error("raw upstream detail that should remain in the cause"), |
| 96 | 98 | }); |
| ... | ... | @@ -109,7 +111,6 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () => |
| 109 | 111 | { |
| 110 | 112 | provider: error.provider, |
| 111 | 113 | operation: error.operation, |
| 112 | command: error.command, | |
| 113 | 114 | cwd: error.cwd, |
| 114 | 115 | reference: error.reference, |
| 115 | 116 | detail: error.detail, |
| ... | ... | @@ -117,7 +118,6 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () => |
| 117 | 118 | { |
| 118 | 119 | provider: "github", |
| 119 | 120 | operation: "getChangeRequest", |
| 120 | command: "gh", | |
| 121 | 121 | cwd: "/repo", |
| 122 | 122 | reference: "https://github.com/pingdotgg/t3code/pull/42", |
| 123 | 123 | detail: "Pull request not found. Check the PR number or URL and try again.", |
| ... | ... | @@ -130,8 +130,9 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () => |
| 130 | 130 | |
| 131 | 131 | it.effect("lists change request history through the batched head lookup", () => |
| 132 | 132 | Effect.gen(function* () { |
| 133 | let lookup: Parameters<GitHubCli.GitHubCli["Service"]["listPullRequestsByHead"]>[0] | null = | |
| 134 | null; | |
| 133 | let lookup: | |
| 134 | | Parameters<GitHubRepositoryApi.GitHubRepositoryApi["Service"]["listPullRequestsByHead"]>[0] | |
| 135 | | null = null; | |
| 135 | 136 | const provider = yield* makeProvider({ |
| 136 | 137 | listPullRequestsByHead: (input) => { |
| 137 | 138 | lookup = input; |
| ... | ... | @@ -183,8 +184,9 @@ it.effect("lists change request history through the batched head lookup", () => |
| 183 | 184 | |
| 184 | 185 | it.effect("creates GitHub PRs through provider-neutral input names", () => |
| 185 | 186 | Effect.gen(function* () { |
| 186 | let createInput: Parameters<GitHubCli.GitHubCli["Service"]["createPullRequest"]>[0] | null = | |
| 187 | null; | |
| 187 | let createInput: | |
| 188 | | Parameters<GitHubRepositoryApi.GitHubRepositoryApi["Service"]["createPullRequest"]>[0] | |
| 189 | | null = null; | |
| 188 | 190 | const provider = yield* makeProvider({ |
| 189 | 191 | createPullRequest: (input) => { |
| 190 | 192 | createInput = input; |
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+3-11| ... | ... | @@ -15,7 +15,7 @@ import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; |
| 15 | 15 | |
| 16 | 16 | import * as ServerSettings from "../serverSettings.ts"; |
| 17 | 17 | import * as GitHubApi from "./GitHubApi.ts"; |
| 18 | import * as GitHubCli from "./GitHubCli.ts"; | |
| 18 | import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; | |
| 19 | 19 | import { |
| 20 | 20 | effectiveGitHubAccount, |
| 21 | 21 | findAuthenticatedGitHubAccount, |
| ... | ... | @@ -40,7 +40,7 @@ const decodeLinkSubject = Schema.decodeUnknownEffect( |
| 40 | 40 | ), |
| 41 | 41 | ); |
| 42 | 42 | |
| 43 | function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { | |
| 43 | function toChangeRequest(summary: GitHubRepositoryApi.GitHubPullRequestSummary): ChangeRequest { | |
| 44 | 44 | return { |
| 45 | 45 | provider: "github", |
| 46 | 46 | number: summary.number, |
| ... | ... | @@ -264,7 +264,7 @@ export const makeDiscovery = Effect.gen(function* () { |
| 264 | 264 | }); |
| 265 | 265 | |
| 266 | 266 | export const make = Effect.gen(function* () { |
| 267 | const github = yield* GitHubCli.GitHubCli; | |
| 267 | const github = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 268 | 268 | const api = yield* GitHubApi.GitHubApi; |
| 269 | 269 | |
| 270 | 270 | const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = |
| ... | ... | @@ -286,7 +286,6 @@ export const make = Effect.gen(function* () { |
| 286 | 286 | new SourceControlProviderError({ |
| 287 | 287 | provider: "github", |
| 288 | 288 | operation: "listChangeRequests", |
| 289 | command: error.command, | |
| 290 | 289 | cwd: input.cwd, |
| 291 | 290 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 292 | 291 | input.headSelector, |
| ... | ... | @@ -325,7 +324,6 @@ export const make = Effect.gen(function* () { |
| 325 | 324 | new SourceControlProviderError({ |
| 326 | 325 | provider: "github", |
| 327 | 326 | operation: "listChangeRequests", |
| 328 | command: error.command, | |
| 329 | 327 | cwd: input.cwd, |
| 330 | 328 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 331 | 329 | input.headSelector, |
| ... | ... | @@ -403,7 +401,6 @@ export const make = Effect.gen(function* () { |
| 403 | 401 | new SourceControlProviderError({ |
| 404 | 402 | provider: "github", |
| 405 | 403 | operation: "getChangeRequest", |
| 406 | command: error.command, | |
| 407 | 404 | cwd: input.cwd, |
| 408 | 405 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 409 | 406 | input.reference, |
| ... | ... | @@ -428,7 +425,6 @@ export const make = Effect.gen(function* () { |
| 428 | 425 | new SourceControlProviderError({ |
| 429 | 426 | provider: "github", |
| 430 | 427 | operation: "createChangeRequest", |
| 431 | command: error.command, | |
| 432 | 428 | cwd: input.cwd, |
| 433 | 429 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 434 | 430 | input.headSelector, |
| ... | ... | @@ -445,7 +441,6 @@ export const make = Effect.gen(function* () { |
| 445 | 441 | new SourceControlProviderError({ |
| 446 | 442 | provider: "github", |
| 447 | 443 | operation: "getRepositoryCloneUrls", |
| 448 | command: error.command, | |
| 449 | 444 | cwd: input.cwd, |
| 450 | 445 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 451 | 446 | input.repository, |
| ... | ... | @@ -462,7 +457,6 @@ export const make = Effect.gen(function* () { |
| 462 | 457 | new SourceControlProviderError({ |
| 463 | 458 | provider: "github", |
| 464 | 459 | operation: "createRepository", |
| 465 | command: error.command, | |
| 466 | 460 | cwd: input.cwd, |
| 467 | 461 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 468 | 462 | input.repository, |
| ... | ... | @@ -486,7 +480,6 @@ export const make = Effect.gen(function* () { |
| 486 | 480 | new SourceControlProviderError({ |
| 487 | 481 | provider: "github", |
| 488 | 482 | operation: "getDefaultBranch", |
| 489 | command: error.command, | |
| 490 | 483 | cwd: input.cwd, |
| 491 | 484 | detail: error.message, |
| 492 | 485 | cause: error, |
| ... | ... | @@ -500,7 +493,6 @@ export const make = Effect.gen(function* () { |
| 500 | 493 | new SourceControlProviderError({ |
| 501 | 494 | provider: "github", |
| 502 | 495 | operation: "checkoutChangeRequest", |
| 503 | command: error.command, | |
| 504 | 496 | cwd: input.cwd, |
| 505 | 497 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 506 | 498 | input.reference, |
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+2-2| ... | ... | @@ -18,7 +18,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 18 | 18 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; |
| 19 | 19 | import * as BitbucketApi from "./BitbucketApi.ts"; |
| 20 | 20 | import * as GitHubApi from "./GitHubApi.ts"; |
| 21 | import * as GitHubCli from "./GitHubCli.ts"; | |
| 21 | import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; | |
| 22 | 22 | import * as GitLabCli from "./GitLabCli.ts"; |
| 23 | 23 | import * as ForgejoCli from "./ForgejoCli.ts"; |
| 24 | 24 | import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; |
| ... | ... | @@ -40,7 +40,7 @@ const layerSourceControlProviderRegistryTest = (input: { |
| 40 | 40 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), |
| 41 | 41 | Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), |
| 42 | 42 | ServerSettings.ServerSettingsService.layerTest(), |
| 43 | Layer.mock(GitHubCli.GitHubCli)({}), | |
| 43 | Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)({}), | |
| 44 | 44 | Layer.mock(GitHubApi.GitHubApi)({}), |
| 45 | 45 | Layer.mock(GitLabCli.GitLabCli)({}), |
| 46 | 46 | Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), |
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+3-3| ... | ... | @@ -15,7 +15,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 15 | 15 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; |
| 16 | 16 | import * as BitbucketApi from "./BitbucketApi.ts"; |
| 17 | 17 | import * as GitHubApi from "./GitHubApi.ts"; |
| 18 | import * as GitHubCli from "./GitHubCli.ts"; | |
| 18 | import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; | |
| 19 | 19 | import * as GitLabCli from "./GitLabCli.ts"; |
| 20 | 20 | import * as ForgejoCli from "./ForgejoCli.ts"; |
| 21 | 21 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| ... | ... | @@ -42,7 +42,7 @@ function makeRegistry(input: { |
| 42 | 42 | readonly url: string; |
| 43 | 43 | }>; |
| 44 | 44 | readonly process?: Partial<VcsProcess.VcsProcess["Service"]>; |
| 45 | readonly github?: Partial<GitHubCli.GitHubCli["Service"]>; | |
| 45 | readonly github?: Partial<GitHubRepositoryApi.GitHubRepositoryApi["Service"]>; | |
| 46 | 46 | readonly githubApi?: Partial<GitHubApi.GitHubApi["Service"]>; |
| 47 | 47 | readonly gitlab?: Partial<GitLabCli.GitLabCli["Service"]>; |
| 48 | 48 | readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; |
| ... | ... | @@ -98,7 +98,7 @@ function makeRegistry(input: { |
| 98 | 98 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), |
| 99 | 99 | Layer.mock(BitbucketApi.BitbucketApi)({}), |
| 100 | 100 | ServerSettings.ServerSettingsService.layerTest(), |
| 101 | Layer.mock(GitHubCli.GitHubCli)(input.github ?? {}), | |
| 101 | Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(input.github ?? {}), | |
| 102 | 102 | Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}), |
| 103 | 103 | Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), |
| 104 | 104 | Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), |
apps/server/src/ws.ts+2-2| ... | ... | @@ -229,7 +229,7 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery. |
| 229 | 229 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 230 | 230 | import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; |
| 231 | 231 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; |
| 232 | import * as GitHubCli from "./sourceControl/GitHubCli.ts"; | |
| 232 | import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; | |
| 233 | 233 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; |
| 234 | 234 | import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; |
| 235 | 235 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; |
| ... | ... | @@ -3190,7 +3190,7 @@ export const layer = Layer.unwrap( |
| 3190 | 3190 | Layer.mergeAll( |
| 3191 | 3191 | AzureDevOpsCli.layer, |
| 3192 | 3192 | BitbucketApi.layer, |
| 3193 | GitHubCli.layer, | |
| 3193 | GitHubRepositoryApi.layer, | |
| 3194 | 3194 | GitLabCli.layer, |
| 3195 | 3195 | ForgejoCli.layer, |
| 3196 | 3196 | ), |