authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 17:26:24-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 17:26:24-07:00
log08b1222d0bae670433c275ec70165c7b5048726f
treec5bfc14cdf3606c77a4b81370869d757ae406a63
parent14fe0158ede3d964f183e281f9fec225d30a6b32
signature Signed by PGP key B5690EEEBB952194

refactor(server): GitHub services are named for the API they call, not gh (#16967)

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,7 +23,7 @@ import {
23} from "../src/textGeneration/ThreadTitleContext.ts";23} from "../src/textGeneration/ThreadTitleContext.ts";
24import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts";24import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts";
25import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts";25import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts";
26import * as GitHubCli from "../src/sourceControl/GitHubCli.ts";26import * as GitHubRepositoryApi from "../src/sourceControl/GitHubRepositoryApi.ts";
27import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";27import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";
28import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts";28import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts";
29import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts";29import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts";
...@@ -152,7 +152,7 @@ await Effect.runPromise(...@@ -152,7 +152,7 @@ await Effect.runPromise(
152 SourceControlProviderRegistry.layer.pipe(152 SourceControlProviderRegistry.layer.pipe(
153 Layer.provide(153 Layer.provide(
154 Layer.mergeAll(154 Layer.mergeAll(
155 GitHubCli.layer,155 GitHubRepositoryApi.layer,
156 GitLabCli.layer,156 GitLabCli.layer,
157 ForgejoCli.layer,157 ForgejoCli.layer,
158 AzureDevOpsCli.layer,158 AzureDevOpsCli.layer,
apps/server/scripts/measure-pr-preview.ts+2-2
...@@ -10,7 +10,7 @@ import * as Option from "effect/Option";...@@ -10,7 +10,7 @@ import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";10import * as Schema from "effect/Schema";
11import { FetchHttpClient } from "effect/http";11import { FetchHttpClient } from "effect/http";
1212
13import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.ts";13import * as GitHubPullRequestApi from "../src/pullRequest/GitHubPullRequestApi.ts";
14import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts";14import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts";
15import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";15import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";
16import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts";16import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts";
...@@ -73,7 +73,7 @@ const measuredApi = Layer.effect(...@@ -73,7 +73,7 @@ const measuredApi = Layer.effect(
73 Layer.provide(NodeServices.layer),73 Layer.provide(NodeServices.layer),
74);74);
7575
76const services = GitHubPullRequestCli.layer.pipe(76const services = GitHubPullRequestApi.layer.pipe(
77 Layer.provideMerge(measuredApi),77 Layer.provideMerge(measuredApi),
78 Layer.provideMerge(VcsProcess.layer),78 Layer.provideMerge(VcsProcess.layer),
79 Layer.provideMerge(NodeServices.layer),79 Layer.provideMerge(NodeServices.layer),
apps/server/src/git/GitManager.test.ts+35-44
...@@ -42,7 +42,7 @@ import {...@@ -42,7 +42,7 @@ import {
42} from "@t3tools/contracts";42} from "@t3tools/contracts";
43import * as DateTime from "effect/DateTime";43import * as DateTime from "effect/DateTime";
44import * as GitHubApi from "../sourceControl/GitHubApi.ts";44import * as GitHubApi from "../sourceControl/GitHubApi.ts";
45import * as GitHubCli from "../sourceControl/GitHubCli.ts";45import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts";
46import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts";46import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts";
47import * as GitLabCli from "../sourceControl/GitLabCli.ts";47import * as GitLabCli from "../sourceControl/GitLabCli.ts";
48import * as TextGeneration from "../textGeneration/TextGeneration.ts";48import * as TextGeneration from "../textGeneration/TextGeneration.ts";
...@@ -92,7 +92,7 @@ interface FakeGhScenario {...@@ -92,7 +92,7 @@ interface FakeGhScenario {
92 headRepositoryOwnerLogin?: string | null;92 headRepositoryOwnerLogin?: string | null;
93 };93 };
94 repositoryCloneUrls?: Record<string, { url: string; sshUrl: string }>;94 repositoryCloneUrls?: Record<string, { url: string; sshUrl: string }>;
95 failWith?: GitHubCli.GitHubCliError;95 failWith?: GitHubRepositoryApi.GitHubRepositoryApiError;
96 /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */96 /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */
97 failAfterCalls?: number;97 failAfterCalls?: number;
98}98}
...@@ -111,7 +111,9 @@ type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"];...@@ -111,7 +111,9 @@ type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"];
111111
112type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;112type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;
113113
114function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequestSummary | null {114function normalizeFakePullRequestSummary(
115 raw: unknown,
116): GitHubRepositoryApi.GitHubPullRequestSummary | null {
115 if (!raw || typeof raw !== "object") {117 if (!raw || typeof raw !== "object") {
116 return null;118 return null;
117 }119 }
...@@ -381,8 +383,8 @@ function createTextGeneration(...@@ -381,8 +383,8 @@ function createTextGeneration(
381 };383 };
382}384}
383385
384function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {386function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {
385 service: GitHubCli.GitHubCli["Service"];387 service: GitHubRepositoryApi.GitHubRepositoryApi["Service"];
386 ghCalls: string[];388 ghCalls: string[];
387} {389} {
388 const prListQueue = [...(scenario.prListSequence ?? [])];390 const prListQueue = [...(scenario.prListSequence ?? [])];
...@@ -398,7 +400,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -398,7 +400,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
398 const execute = (input: {400 const execute = (input: {
399 readonly cwd: string;401 readonly cwd: string;
400 readonly args: ReadonlyArray<string>;402 readonly args: ReadonlyArray<string>;
401 }): Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCli.GitHubCliError> => {403 }): Effect.Effect<VcsProcess.VcsProcessOutput, GitHubRepositoryApi.GitHubRepositoryApiError> => {
402 const args = [...input.args];404 const args = [...input.args];
403 ghCalls.push(args.join(" "));405 ghCalls.push(args.join(" "));
404406
...@@ -486,10 +488,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -486,10 +488,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
486 return fakeGhOutput("");488 return fakeGhOutput("");
487 },489 },
488 catch: (error) =>490 catch: (error) =>
489 GitHubCli.isGitHubCliError(error)491 GitHubRepositoryApi.isGitHubRepositoryApiError(error)
490 ? error492 ? error
491 : new GitHubCli.GitHubCliCommandError({493 : new GitHubRepositoryApi.GitHubRepositoryCommandError({
492 command: "gh",
493 cwd: input.cwd,494 cwd: input.cwd,
494 cause: error,495 cause: error,
495 }),496 }),
...@@ -502,8 +503,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -502,8 +503,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
502 const cloneUrls = scenario.repositoryCloneUrls?.[repository];503 const cloneUrls = scenario.repositoryCloneUrls?.[repository];
503 if (!cloneUrls) {504 if (!cloneUrls) {
504 return Effect.fail(505 return Effect.fail(
505 new GitHubCli.GitHubCliCommandError({506 new GitHubRepositoryApi.GitHubRepositoryCommandError({
506 command: "gh",
507 cwd: input.cwd,507 cwd: input.cwd,
508 cause: new Error(`Unexpected repository lookup: ${repository}`),508 cause: new Error(`Unexpected repository lookup: ${repository}`),
509 }),509 }),
...@@ -523,8 +523,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -523,8 +523,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
523 }523 }
524524
525 return Effect.fail(525 return Effect.fail(
526 new GitHubCli.GitHubCliCommandError({526 new GitHubRepositoryApi.GitHubRepositoryCommandError({
527 command: "gh",
528 cwd: input.cwd,527 cwd: input.cwd,
529 cause: new Error(`Unexpected gh command: ${args.join(" ")}`),528 cause: new Error(`Unexpected gh command: ${args.join(" ")}`),
530 }),529 }),
...@@ -577,7 +576,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -577,7 +576,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
577 Effect.map((raw) =>576 Effect.map((raw) =>
578 raw577 raw
579 .map((entry) => normalizeFakePullRequestSummary(entry))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 createPullRequest: (input) =>584 createPullRequest: (input) =>
...@@ -617,7 +618,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -617,7 +618,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
617 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner",618 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner",
618 ],619 ],
619 }).pipe(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 getRepositoryCloneUrls: (input) =>625 getRepositoryCloneUrls: (input) =>
623 execute({626 execute({
...@@ -626,8 +629,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -626,8 +629,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
626 }).pipe(Effect.map((result) => JSON.parse(result.stdout))),629 }).pipe(Effect.map((result) => JSON.parse(result.stdout))),
627 createRepository: (input) =>630 createRepository: (input) =>
628 Effect.fail(631 Effect.fail(
629 new GitHubCli.GitHubCliCommandError({632 new GitHubRepositoryApi.GitHubRepositoryCommandError({
630 command: "gh",
631 cwd: input.cwd,633 cwd: input.cwd,
632 cause: new Error(`Unexpected repository create: ${input.repository}`),634 cause: new Error(`Unexpected repository create: ${input.repository}`),
633 }),635 }),
...@@ -691,7 +693,9 @@ function makeManager(input?: {...@@ -691,7 +693,9 @@ function makeManager(input?: {
691 ProjectionStore.ProjectionStoreV2 | ProjectStore.ProjectStoreV2693 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 const textGeneration = createTextGeneration(input?.textGeneration);699 const textGeneration = createTextGeneration(input?.textGeneration);
696 const layerServerConfig = ServerConfig.layerTest(process.cwd(), {700 const layerServerConfig = ServerConfig.layerTest(process.cwd(), {
697 prefix: "t3-git-manager-test-",701 prefix: "t3-git-manager-test-",
...@@ -740,7 +744,7 @@ function makeManager(input?: {...@@ -740,7 +744,7 @@ function makeManager(input?: {
740 ),744 ),
741 Effect.provide(745 Effect.provide(
742 Layer.merge(746 Layer.merge(
743 Layer.succeed(GitHubCli.GitHubCli, gitHubCli),747 Layer.succeed(GitHubRepositoryApi.GitHubRepositoryApi, gitHubRepositoryApi),
744 Layer.mock(GitHubApi.GitHubApi)({}),748 Layer.mock(GitHubApi.GitHubApi)({}),
745 ),749 ),
746 ),750 ),
...@@ -1296,8 +1300,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -1296,8 +1300,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
1296 yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]);1300 yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]);
1297 const { manager, ghCalls } = yield* makeManager({1301 const { manager, ghCalls } = yield* makeManager({
1298 ghScenario: {1302 ghScenario: {
1299 failWith: new GitHubCli.GitHubCliUnavailableError({1303 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({
1300 command: "gh",
1301 cwd: repoDir,1304 cwd: repoDir,
1302 cause: new Error("rate limited"),1305 cause: new Error("rate limited"),
1303 }),1306 }),
...@@ -1779,8 +1782,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -1779,8 +1782,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
17791782
1780 const { manager, ghCalls } = yield* makeManager({1783 const { manager, ghCalls } = yield* makeManager({
1781 ghScenario: {1784 ghScenario: {
1782 failWith: new GitHubCli.GitHubCliUnavailableError({1785 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({
1783 command: "gh",
1784 cwd: repoDir,1786 cwd: repoDir,
1785 cause: new Error("gh is not available on PATH"),1787 cause: new Error("gh is not available on PATH"),
1786 }),1788 }),
...@@ -2640,8 +2642,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2640,8 +2642,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2640 },2642 },
2641 ]),2643 ]),
2642 },2644 },
2643 failWith: new GitHubCli.GitHubCliUnavailableError({2645 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({
2644 command: "gh",
2645 cwd: repoDir,2646 cwd: repoDir,
2646 cause: new Error("rate limited"),2647 cause: new Error("rate limited"),
2647 }),2648 }),
...@@ -2717,8 +2718,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2717,8 +2718,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
27172718
2718 const { manager } = yield* makeManager({2719 const { manager } = yield* makeManager({
2719 ghScenario: {2720 ghScenario: {
2720 failWith: new GitHubCli.GitHubCliUnavailableError({2721 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({
2721 command: "gh",
2722 cwd: repoDir,2722 cwd: repoDir,
2723 cause: new Error("gh is not available on PATH"),2723 cause: new Error("gh is not available on PATH"),
2724 }),2724 }),
...@@ -2743,8 +2743,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2743,8 +2743,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2743 const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value";2743 const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value";
2744 const { manager } = yield* makeManager({2744 const { manager } = yield* makeManager({
2745 ghScenario: {2745 ghScenario: {
2746 failWith: new GitHubCli.GitHubCliRateLimitError({2746 failWith: new GitHubRepositoryApi.GitHubRepositoryRateLimitError({
2747 command: "gh",
2748 cwd: repoDir,2747 cwd: repoDir,
2749 cause: new Error(upstreamCause),2748 cause: new Error(upstreamCause),
2750 }),2749 }),
...@@ -2770,7 +2769,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2770,7 +2769,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2770 errorTag: "SourceControlProviderError",2769 errorTag: "SourceControlProviderError",
2771 provider: "github",2770 provider: "github",
2772 providerOperation: "listChangeRequests",2771 providerOperation: "listChangeRequests",
2773 providerCommand: "gh",
2774 errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.",2772 errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.",
2775 });2773 });
2776 const loggedText = [2774 const loggedText = [
...@@ -2801,8 +2799,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2801,8 +2799,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2801 const { manager } = yield* makeManager({2799 const { manager } = yield* makeManager({
2802 ghScenario: {2800 ghScenario: {
2803 prListSequence: [JSON.stringify([existingPr])],2801 prListSequence: [JSON.stringify([existingPr])],
2804 failWith: new GitHubCli.GitHubCliUnavailableError({2802 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({
2805 command: "gh",
2806 cwd: repoDir,2803 cwd: repoDir,
2807 cause: new Error("rate limited"),2804 cause: new Error("rate limited"),
2808 }),2805 }),
...@@ -2844,8 +2841,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2844,8 +2841,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2844 const { manager } = yield* makeManager({2841 const { manager } = yield* makeManager({
2845 ghScenario: {2842 ghScenario: {
2846 prListSequence: [JSON.stringify([existingPr])],2843 prListSequence: [JSON.stringify([existingPr])],
2847 failWith: new GitHubCli.GitHubCliUnavailableError({2844 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({
2848 command: "gh",
2849 cwd: repoDir,2845 cwd: repoDir,
2850 cause: new Error("rate limited"),2846 cause: new Error("rate limited"),
2851 }),2847 }),
...@@ -2892,8 +2888,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2892,8 +2888,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2892 const { manager } = yield* makeManager({2888 const { manager } = yield* makeManager({
2893 ghScenario: {2889 ghScenario: {
2894 prListSequence: [JSON.stringify([existingPr])],2890 prListSequence: [JSON.stringify([existingPr])],
2895 failWith: new GitHubCli.GitHubCliUnavailableError({2891 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({
2896 command: "gh",
2897 cwd: repoDir,2892 cwd: repoDir,
2898 cause: new Error("rate limited"),2893 cause: new Error("rate limited"),
2899 }),2894 }),
...@@ -2931,8 +2926,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2931,8 +2926,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2931 const { manager } = yield* makeManager({2926 const { manager } = yield* makeManager({
2932 ghScenario: {2927 ghScenario: {
2933 prListSequence: [JSON.stringify([existingPr])],2928 prListSequence: [JSON.stringify([existingPr])],
2934 failWith: new GitHubCli.GitHubCliUnavailableError({2929 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({
2935 command: "gh",
2936 cwd: repoDir,2930 cwd: repoDir,
2937 cause: new Error("rate limited"),2931 cause: new Error("rate limited"),
2938 }),2932 }),
...@@ -2973,8 +2967,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2973,8 +2967,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2973 const { manager } = yield* makeManager({2967 const { manager } = yield* makeManager({
2974 ghScenario: {2968 ghScenario: {
2975 prListSequence: [JSON.stringify([existingPr])],2969 prListSequence: [JSON.stringify([existingPr])],
2976 failWith: new GitHubCli.GitHubCliUnavailableError({2970 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({
2977 command: "gh",
2978 cwd: repoDir,2971 cwd: repoDir,
2979 cause: new Error("rate limited"),2972 cause: new Error("rate limited"),
2980 }),2973 }),
...@@ -4618,8 +4611,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -4618,8 +4611,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
46184611
4619 const { manager } = yield* makeManager({4612 const { manager } = yield* makeManager({
4620 ghScenario: {4613 ghScenario: {
4621 failWith: new GitHubCli.GitHubCliUnavailableError({4614 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({
4622 command: "gh",
4623 cwd: repoDir,4615 cwd: repoDir,
4624 cause: new Error("gh is not available on PATH"),4616 cause: new Error("gh is not available on PATH"),
4625 }),4617 }),
...@@ -4648,8 +4640,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -4648,8 +4640,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
46484640
4649 const { manager } = yield* makeManager({4641 const { manager } = yield* makeManager({
4650 ghScenario: {4642 ghScenario: {
4651 failWith: new GitHubCli.GitHubCliAuthenticationError({4643 failWith: new GitHubRepositoryApi.GitHubRepositoryAuthenticationError({
4652 command: "gh",
4653 cwd: repoDir,4644 cwd: repoDir,
4654 cause: new Error("gh is not authenticated"),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,7 +1286,7 @@ export const make = Effect.gen(function* () {
1286 ? {1286 ? {
1287 provider: error.provider,1287 provider: error.provider,
1288 providerOperation: error.operation,1288 providerOperation: error.operation,
1289 providerCommand: error.command ?? "unknown",1289 ...(error.command === undefined ? {} : { providerCommand: error.command }),
1290 errorDetail: error.detail,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,7 +345,7 @@ export const make = Effect.gen(function* () {
345 }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))),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 one348 // Wide enough that a sweep's GitHub branch lookups reach GitHubRepositoryApi together and share one
349 // GraphQL document, instead of one `gh pr list` per branch.349 // GraphQL document, instead of one `gh pr list` per branch.
350 { concurrency: 32, discard: true },350 { concurrency: 32, discard: true },
351 );351 );
apps/server/src/pullRequest/GitHubPullRequestApi.test.ts created+4545
...@@ -0,0 +1,4545 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import { afterEach, assert, expect, it, vi } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as Clock from "effect/Clock";
6import * as Deferred from "effect/Deferred";
7import * as Fiber from "effect/Fiber";
8import * as Layer from "effect/Layer";
9import * as Schema from "effect/Schema";
10import * as TestClock from "effect/testing/TestClock";
11import * as Redacted from "effect/Redacted";
12
13import { HttpClient, HttpClientResponse } from "effect/http";
14
15import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts";
16import * as GitHubApi from "../sourceControl/GitHubApi.ts";
17import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";
18import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
19import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
20import * as VcsProcess from "../vcs/VcsProcess.ts";
21import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
22import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts";
23
24const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
25
26const 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. */
57type ApiCall =
58 | ({ readonly kind: "graphql" } & GitHubApi.GitHubGraphQlInput)
59 | ({ readonly kind: "rest" } & GitHubApi.GitHubRestInput);
60
61type ApiAnswer = Effect.Effect<GitHubApi.GitHubRestResponse, GitHubApi.GitHubApiError>;
62
63const mockedExecute = vi.fn<(call: ApiCall) => ApiAnswer>();
64const defaultCredential = (host: string) =>
65 Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:token` });
66const mockedCredential = vi.fn(defaultCredential);
67const 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 */
75const 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
98const 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. */
108function 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 */
120function 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. */
156function 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. */
169function 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. */
187const emptySearch = () => output(pullRequests(0, 1));
188const emptyList = () => output(listedPullRequests(0, 1));
189
190function 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. */
201function 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
213function 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
225function 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
244function 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. */
262const diffRefused = new GitHubApi.GitHubApiResponseError({
263 host: "github.com",
264 operation: "getPullRequestDiff",
265 status: 406,
266});
267
268/** The nth request the CLI made. */
269function 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. */
276function 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. */
283function 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. */
289function 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. */
295function 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. */
301function 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
320function searchPage(nodes: ReadonlyArray<unknown>, hasNextPage = false) {
321 return output(encodeJson({ data: { search: { pageInfo: { hasNextPage }, nodes } } }));
322}
323
324const 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 */
330function 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. */
339function 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. */
346function 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 */
356const SUBJECT_SCOPE_QUERY = "node(id: $subjectId)";
357const 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. */
365const 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. */
392const 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. */
395const 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. */
404const 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
420const 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. */
429function 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. */
450const NODE_ID_QUERY = "pullRequest(number: $number) { id }";
451const nodeIdAnswer = (id: string) => ({ data: { repository: { pullRequest: { id } } } });
452
453afterEach(() => {
454 mockedExecute.mockReset();
455 mockedStackMemberships.mockReset();
456 mockedCredential.mockReset();
457 mockedCredential.mockImplementation(defaultCredential);
458});
459
460it.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
540layer("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 @@
1import { removeAgentCredits } from "./mergeMessage.ts";
2import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
3import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";
4import * as Cause from "effect/Cause";
5import * as Context from "effect/Context";
6import * as Clock from "effect/Clock";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Exit from "effect/Exit";
12import * as Option from "effect/Option";
13import * as Request from "effect/Request";
14import * as RequestResolver from "effect/RequestResolver";
15import * as Result from "effect/Result";
16import * as Schema from "effect/Schema";
17import * as Semaphore from "effect/Semaphore";
18import {
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
45import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts";
46import * as GitHubApi from "../sourceControl/GitHubApi.ts";
47import * as VcsProcess from "../vcs/VcsProcess.ts";
48import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
49import {
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";
132import 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 */
138export 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. */
152export 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. */
164export 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. */
178export 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. */
190export 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. */
202export 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. */
218export 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 */
239export 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. */
252export 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. */
265export 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. */
287export 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. */
300export 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
312export 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. */
330const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
331const DIFF_TIMEOUT = Duration.seconds(60);
332/** Pierre expansion is for source files, not blobs large enough to stall a review surface. */
333const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;
334
335/** What `gh pr list --state` asked a repository's own list for, without search. */
336const 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
345interface 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. */
352const 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. */
355const 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 */
361const 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 */
368export 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 */
375const REVIEW_THREAD_PAGES = 10;
376
377/** Pages of a hundred check contexts to walk for one head before the rollup says it was cut. */
378const CHECK_CONTEXT_PAGES = 10;
379
380export 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
387export 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 */
398const STAT_ALIASES_PER_REQUEST = 25;
399const 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 */
405const SUMMARY_BATCH_WINDOW = "10 millis";
406
407class 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
418class 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
429export 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
435export 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
445export 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
451export 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 */
801function 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 */
814function 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 */
823function 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 */
837function 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. */
842const 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 */
854function qualifierValue(value: string): string {
855 return `"${value.replaceAll('"', "").trim()}"`;
856}
857
858function 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 */
888function 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. */
912function 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. */
933const 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 */
947function 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
978const MERGE_MESSAGE_GRAPHQL_QUERY = `
979query 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. */
990const 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
1001const 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);
1016const 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. */
1020const ACTION_STATE_GRAPHQL_QUERY = `
1021query 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
1033const 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);
1054const decodeActionState = (raw: string) =>
1055 Result.map(decodeActionStateResponse(raw), (response) => response.data.repository.pullRequest);
1056
1057const MERGE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($input: MergePullRequestInput!) {
1058 mergePullRequest(input: $input) { clientMutationId }
1059}`;
1060const ENABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($input: EnablePullRequestAutoMergeInput!) {
1061 enablePullRequestAutoMerge(input: $input) { clientMutationId }
1062}`;
1063const DISABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1064 disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1065}`;
1066const UPDATE_BRANCH_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!, $updateMethod: PullRequestBranchUpdateMethod!) {
1067 updatePullRequestBranch(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid, updateMethod: $updateMethod }) { clientMutationId }
1068}`;
1069const READY_FOR_REVIEW_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1070 markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1071}`;
1072const CONVERT_TO_DRAFT_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1073 convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1074}`;
1075const CLOSE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1076 closePullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1077}`;
1078const REOPEN_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1079 reopenPullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1080}`;
1081const ADD_COMMENT_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $body: String!) {
1082 addComment(input: { subjectId: $subjectId, body: $body }) { clientMutationId }
1083}`;
1084
1085const 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. */
1092const IMMEDIATELY_MERGEABLE = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]);
1093
1094/** The mutations that only need the pull request's node id. */
1095const 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. */
1104export 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
2847export const layer = Layer.effect(GitHubPullRequestApi, make);
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts deleted-4545
...@@ -1,4545 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import { afterEach, assert, expect, it, vi } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as Clock from "effect/Clock";
6import * as Deferred from "effect/Deferred";
7import * as Fiber from "effect/Fiber";
8import * as Layer from "effect/Layer";
9import * as Schema from "effect/Schema";
10import * as TestClock from "effect/testing/TestClock";
11import * as Redacted from "effect/Redacted";
12
13import { HttpClient, HttpClientResponse } from "effect/http";
14
15import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts";
16import * as GitHubApi from "../sourceControl/GitHubApi.ts";
17import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";
18import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
19import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
20import * as VcsProcess from "../vcs/VcsProcess.ts";
21import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
22import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts";
23
24const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
25
26const 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. */
57type ApiCall =
58 | ({ readonly kind: "graphql" } & GitHubApi.GitHubGraphQlInput)
59 | ({ readonly kind: "rest" } & GitHubApi.GitHubRestInput);
60
61type ApiAnswer = Effect.Effect<GitHubApi.GitHubRestResponse, GitHubApi.GitHubApiError>;
62
63const mockedExecute = vi.fn<(call: ApiCall) => ApiAnswer>();
64const defaultCredential = (host: string) =>
65 Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:token` });
66const mockedCredential = vi.fn(defaultCredential);
67const 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 */
75const 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
98const 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. */
108function 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 */
120function 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. */
156function 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. */
169function 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. */
187const emptySearch = () => output(pullRequests(0, 1));
188const emptyList = () => output(listedPullRequests(0, 1));
189
190function 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. */
201function 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
213function 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
225function 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
244function 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. */
262const diffRefused = new GitHubApi.GitHubApiResponseError({
263 host: "github.com",
264 operation: "getPullRequestDiff",
265 status: 406,
266});
267
268/** The nth request the CLI made. */
269function 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. */
276function 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. */
283function 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. */
289function 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. */
295function 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. */
301function 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
320function searchPage(nodes: ReadonlyArray<unknown>, hasNextPage = false) {
321 return output(encodeJson({ data: { search: { pageInfo: { hasNextPage }, nodes } } }));
322}
323
324const 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 */
330function 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. */
339function 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. */
346function 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 */
356const SUBJECT_SCOPE_QUERY = "node(id: $subjectId)";
357const 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. */
365const 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. */
392const 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. */
395const 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. */
404const 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
420const 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. */
429function 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. */
450const NODE_ID_QUERY = "pullRequest(number: $number) { id }";
451const nodeIdAnswer = (id: string) => ({ data: { repository: { pullRequest: { id } } } });
452
453afterEach(() => {
454 mockedExecute.mockReset();
455 mockedStackMemberships.mockReset();
456 mockedCredential.mockReset();
457 mockedCredential.mockImplementation(defaultCredential);
458});
459
460it.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
540layer("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 @@
1import { removeAgentCredits } from "./mergeMessage.ts";
2import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
3import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";
4import * as Cause from "effect/Cause";
5import * as Context from "effect/Context";
6import * as Clock from "effect/Clock";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Exit from "effect/Exit";
12import * as Option from "effect/Option";
13import * as Request from "effect/Request";
14import * as RequestResolver from "effect/RequestResolver";
15import * as Result from "effect/Result";
16import * as Schema from "effect/Schema";
17import * as Semaphore from "effect/Semaphore";
18import {
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
45import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts";
46import * as GitHubApi from "../sourceControl/GitHubApi.ts";
47import * as VcsProcess from "../vcs/VcsProcess.ts";
48import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
49import {
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";
132import 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 */
138export 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. */
153export 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. */
166export 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. */
181export 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. */
194export 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. */
207export 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. */
224export 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 */
246export 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. */
260export 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. */
274export 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. */
297export 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. */
311export 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
324export 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. */
342const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
343const DIFF_TIMEOUT = Duration.seconds(60);
344/** Pierre expansion is for source files, not blobs large enough to stall a review surface. */
345const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;
346
347/** What `gh pr list --state` asked a repository's own list for, without search. */
348const 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
357interface 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. */
364const 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. */
367const 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 */
373const 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 */
380export 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 */
387const REVIEW_THREAD_PAGES = 10;
388
389/** Pages of a hundred check contexts to walk for one head before the rollup says it was cut. */
390const CHECK_CONTEXT_PAGES = 10;
391
392export 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
399export 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 */
410const STAT_ALIASES_PER_REQUEST = 25;
411const 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 */
417const SUMMARY_BATCH_WINDOW = "10 millis";
418
419class 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
430class 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
441export 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
447export 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
457export 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
463export 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 */
813function 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 */
826function 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 */
835function 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 */
849function 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. */
854const 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 */
866function qualifierValue(value: string): string {
867 return `"${value.replaceAll('"', "").trim()}"`;
868}
869
870function 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 */
900function 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. */
924function 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. */
945const 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 */
959function 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
990const MERGE_MESSAGE_GRAPHQL_QUERY = `
991query 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. */
1002const 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
1013const 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);
1028const 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. */
1032const ACTION_STATE_GRAPHQL_QUERY = `
1033query 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
1045const 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);
1066const decodeActionState = (raw: string) =>
1067 Result.map(decodeActionStateResponse(raw), (response) => response.data.repository.pullRequest);
1068
1069const MERGE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($input: MergePullRequestInput!) {
1070 mergePullRequest(input: $input) { clientMutationId }
1071}`;
1072const ENABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($input: EnablePullRequestAutoMergeInput!) {
1073 enablePullRequestAutoMerge(input: $input) { clientMutationId }
1074}`;
1075const DISABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1076 disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1077}`;
1078const UPDATE_BRANCH_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!, $updateMethod: PullRequestBranchUpdateMethod!) {
1079 updatePullRequestBranch(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid, updateMethod: $updateMethod }) { clientMutationId }
1080}`;
1081const READY_FOR_REVIEW_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1082 markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1083}`;
1084const CONVERT_TO_DRAFT_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1085 convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1086}`;
1087const CLOSE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1088 closePullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1089}`;
1090const REOPEN_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1091 reopenPullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1092}`;
1093const ADD_COMMENT_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $body: String!) {
1094 addComment(input: { subjectId: $subjectId, body: $body }) { clientMutationId }
1095}`;
1096
1097const 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. */
1104const IMMEDIATELY_MERGEABLE = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]);
1105
1106/** The mutations that only need the pull request's node id. */
1107const 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. */
1116export 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
2874export 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,8 +7,8 @@ import type { PullRequestReaction } from "@t3tools/contracts";
77
8import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts";8import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts";
9import * as GitHubApi from "../sourceControl/GitHubApi.ts";9import * as GitHubApi from "../sourceControl/GitHubApi.ts";
10import * as GitHubCli from "../sourceControl/GitHubCli.ts";10import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts";
11import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";11import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
12import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts";12import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts";
13import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";13import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
14import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";14import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";
...@@ -31,13 +31,12 @@ it.effect("maps credential verification failures without relabeling operation fa...@@ -31,13 +31,12 @@ it.effect("maps credential verification failures without relabeling operation fa
31 let operations = 0;31 let operations = 0;
32 const provider = yield* make.pipe(32 const provider = yield* make.pipe(
33 Effect.provide(33 Effect.provide(
34 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({34 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
35 revalidateChecks: (_input, read) => read,35 revalidateChecks: (_input, read) => read,
36 withVerifiedCredential: (_input, use) =>36 withVerifiedCredential: (_input, use) =>
37 verificationFails37 verificationFails
38 ? Effect.fail(38 ? Effect.fail(
39 new GitHubPullRequestCli.GitHubViewerLoginUnavailableError({39 new GitHubPullRequestApi.GitHubViewerLoginUnavailableError({
40 command: "gh",
41 cwd: "/w",40 cwd: "/w",
42 }),41 }),
43 )42 )
...@@ -76,7 +75,7 @@ it.effect("refreshes checks without permissions or comparison reads", () =>...@@ -76,7 +75,7 @@ it.effect("refreshes checks without permissions or comparison reads", () =>
76 );75 );
77 const provider = yield* make.pipe(76 const provider = yield* make.pipe(
78 Effect.provide(77 Effect.provide(
79 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({78 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
80 revalidateChecks: (_input, read) => read,79 revalidateChecks: (_input, read) => read,
81 getPullRequestDetail: () =>80 getPullRequestDetail: () =>
82 Effect.sync(() => {81 Effect.sync(() => {
...@@ -119,7 +118,7 @@ it.effect("uses one narrow read for a linked pull request summary", () =>...@@ -119,7 +118,7 @@ it.effect("uses one narrow read for a linked pull request summary", () =>
119 let summaryReads = 0;118 let summaryReads = 0;
120 const provider = yield* make.pipe(119 const provider = yield* make.pipe(
121 Effect.provide(120 Effect.provide(
122 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({121 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
123 revalidateChecks: (_input, read) => read,122 revalidateChecks: (_input, read) => read,
124 getPullRequestSummary: () =>123 getPullRequestSummary: () =>
125 Effect.sync(() => {124 Effect.sync(() => {
...@@ -169,7 +168,7 @@ it.effect("declares host-native stacks and passes the one the CLI reads through"...@@ -169,7 +168,7 @@ it.effect("declares host-native stacks and passes the one the CLI reads through"
169 };168 };
170 const provider = yield* make.pipe(169 const provider = yield* make.pipe(
171 Effect.provide(170 Effect.provide(
172 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({171 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
173 revalidateChecks: (_input, read) => read,172 revalidateChecks: (_input, read) => read,
174 getPullRequestStack: (input) => Effect.succeed(input.number === 7 ? stack : null),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,12 +188,11 @@ it.effect("reports a failed stack read against its own operation", () =>
189 Effect.gen(function* () {188 Effect.gen(function* () {
190 const provider = yield* make.pipe(189 const provider = yield* make.pipe(
191 Effect.provide(190 Effect.provide(
192 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({191 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
193 revalidateChecks: (_input, read) => read,192 revalidateChecks: (_input, read) => read,
194 getPullRequestStack: () =>193 getPullRequestStack: () =>
195 Effect.fail(194 Effect.fail(
196 new GitHubPullRequestCli.GitHubPullRequestReadError({195 new GitHubPullRequestApi.GitHubPullRequestReadError({
197 command: "gh",
198 cwd: "/w",196 cwd: "/w",
199 operation: "getPullRequestStack",197 operation: "getPullRequestStack",
200 cause: new Error("unreadable"),198 cause: new Error("unreadable"),
...@@ -343,7 +341,7 @@ describe("gitHubViewerPermissions", () => {...@@ -343,7 +341,7 @@ describe("gitHubViewerPermissions", () => {
343 }341 }
344 }).pipe(342 }).pipe(
345 Effect.provide(343 Effect.provide(
346 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({344 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
347 revalidateChecks: (_input, read) => read,345 revalidateChecks: (_input, read) => read,
348 getPullRequestDetail: () =>346 getPullRequestDetail: () =>
349 Effect.succeed({347 Effect.succeed({
...@@ -437,7 +435,7 @@ describe("gitHubViewerPermissions", () => {...@@ -437,7 +435,7 @@ describe("gitHubViewerPermissions", () => {
437 ]);435 ]);
438 }).pipe(436 }).pipe(
439 Effect.provide(437 Effect.provide(
440 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({438 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
441 revalidateChecks: (_input, read) => read,439 revalidateChecks: (_input, read) => read,
442 getPullRequestDetail: () =>440 getPullRequestDetail: () =>
443 Effect.succeed({441 Effect.succeed({
...@@ -541,7 +539,7 @@ it.effect(...@@ -541,7 +539,7 @@ it.effect(
541 Effect.gen(function* () {539 Effect.gen(function* () {
542 const provider = yield* make.pipe(540 const provider = yield* make.pipe(
543 Effect.provide(541 Effect.provide(
544 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({542 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
545 getPullRequestDetail: () =>543 getPullRequestDetail: () =>
546 Effect.succeed({ ...openDetail, comparison: { behindBy: 2, viewerCanUpdate: true } }),544 Effect.succeed({ ...openDetail, comparison: { behindBy: 2, viewerCanUpdate: true } }),
547 listWorkflowRunsRequiringApproval: () =>545 listWorkflowRunsRequiringApproval: () =>
...@@ -582,7 +580,7 @@ it.effect("does not classify same-repository gates as fork workflow approvals",...@@ -582,7 +580,7 @@ it.effect("does not classify same-repository gates as fork workflow approvals",
582 expect(detail.checks).toEqual([]);580 expect(detail.checks).toEqual([]);
583 }).pipe(581 }).pipe(
584 Effect.provide(582 Effect.provide(
585 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({583 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
586 revalidateChecks: (_input, read) => read,584 revalidateChecks: (_input, read) => read,
587 getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }),585 getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }),
588 listWorkflowRunsRequiringApproval: () =>586 listWorkflowRunsRequiringApproval: () =>
...@@ -621,13 +619,12 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () =>...@@ -621,13 +619,12 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () =>
621 ]);619 ]);
622 }).pipe(620 }).pipe(
623 Effect.provide(621 Effect.provide(
624 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({622 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
625 revalidateChecks: (_input, read) => read,623 revalidateChecks: (_input, read) => read,
626 getPullRequestDetail: () => Effect.succeed(openDetail),624 getPullRequestDetail: () => Effect.succeed(openDetail),
627 listWorkflowRunsRequiringApproval: () =>625 listWorkflowRunsRequiringApproval: () =>
628 Effect.fail(626 Effect.fail(
629 new GitHubPullRequestCli.GitHubWorkflowApprovalRefusedError({627 new GitHubPullRequestApi.GitHubWorkflowApprovalRefusedError({
630 command: "gh",
631 cwd: "/w",628 cwd: "/w",
632 number: 7,629 number: 7,
633 reason: "head-not-unique",630 reason: "head-not-unique",
...@@ -664,7 +661,7 @@ it.effect("propagates workflow discovery rate limits", () =>...@@ -664,7 +661,7 @@ it.effect("propagates workflow discovery rate limits", () =>
664 expect(error.reason).toBe("rate-limited");661 expect(error.reason).toBe("rate-limited");
665 }).pipe(662 }).pipe(
666 Effect.provide(663 Effect.provide(
667 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({664 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
668 revalidateChecks: (_input, read) => read,665 revalidateChecks: (_input, read) => read,
669 getPullRequestDetail: () => Effect.succeed(openDetail),666 getPullRequestDetail: () => Effect.succeed(openDetail),
670 listWorkflowRunsRequiringApproval: () =>667 listWorkflowRunsRequiringApproval: () =>
...@@ -713,7 +710,7 @@ describe("getViewerPermissions", () => {...@@ -713,7 +710,7 @@ describe("getViewerPermissions", () => {
713 expect(permissions.actions).not.toContain("update-branch");710 expect(permissions.actions).not.toContain("update-branch");
714 }).pipe(711 }).pipe(
715 Effect.provide(712 Effect.provide(
716 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({713 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
717 revalidateChecks: (_input, read) => read,714 revalidateChecks: (_input, read) => read,
718 getPullRequestDetail: () => Effect.die("Unexpected detail read"),715 getPullRequestDetail: () => Effect.die("Unexpected detail read"),
719 getViewerAccess: () => Effect.sync(() => (accessReads++, access)),716 getViewerAccess: () => Effect.sync(() => (accessReads++, access)),
...@@ -723,13 +720,13 @@ describe("getViewerPermissions", () => {...@@ -723,13 +720,13 @@ describe("getViewerPermissions", () => {
723 });720 });
724721
725 const layerWithDetail = (722 const layerWithDetail = (
726 detail: Effect.Effect<GitHubPullRequestCore, GitHubPullRequestCli.GitHubPullRequestCliError>,723 detail: Effect.Effect<GitHubPullRequestCore, GitHubPullRequestApi.GitHubPullRequestApiError>,
727 onDetail: (allowReserve: boolean) => void = () => {},724 onDetail: (allowReserve: boolean) => void = () => {},
728 ) =>725 ) =>
729 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({726 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
730 revalidateChecks: (_input, read) => read,727 revalidateChecks: (_input, read) => read,
731 getPullRequestDetail: () =>728 getPullRequestDetail: () =>
732 GitHubCli.AllowGitHubReserve.pipe(729 GitHubRepositoryApi.AllowGitHubReserve.pipe(
733 Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))),730 Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))),
734 Effect.flatMap(() => detail),731 Effect.flatMap(() => detail),
735 ),732 ),
...@@ -789,12 +786,11 @@ describe("getViewerPermissions", () => {...@@ -789,12 +786,11 @@ describe("getViewerPermissions", () => {
789 expect(permissions.actions).not.toContain("update-branch");786 expect(permissions.actions).not.toContain("update-branch");
790 }).pipe(787 }).pipe(
791 Effect.provide(788 Effect.provide(
792 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({789 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
793 revalidateChecks: (_input, read) => read,790 revalidateChecks: (_input, read) => read,
794 getPullRequestDetail: () =>791 getPullRequestDetail: () =>
795 Effect.fail(792 Effect.fail(
796 new GitHubPullRequestCli.GitHubPullRequestReadError({793 new GitHubPullRequestApi.GitHubPullRequestReadError({
797 command: "gh",
798 cwd: "/w",794 cwd: "/w",
799 operation: "getPullRequestDetail",795 operation: "getPullRequestDetail",
800 cause: new Error("head changed"),796 cause: new Error("head changed"),
...@@ -869,7 +865,7 @@ describe("getChangeRequest commits", () => {...@@ -869,7 +865,7 @@ describe("getChangeRequest commits", () => {
869 };865 };
870866
871 const layerWith = (commits: GitHubReviewThreadComments["commits"]) =>867 const layerWith = (commits: GitHubReviewThreadComments["commits"]) =>
872 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({868 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
873 revalidateChecks: (_input, read) => read,869 revalidateChecks: (_input, read) => read,
874 getPullRequestActivity: () =>870 getPullRequestActivity: () =>
875 Effect.succeed({871 Effect.succeed({
...@@ -956,7 +952,7 @@ describe("getChangeRequestActivity dismissed reviews", () => {...@@ -956,7 +952,7 @@ describe("getChangeRequestActivity dismissed reviews", () => {
956 viewer: { canUpdate: true, didAuthor: false },952 viewer: { canUpdate: true, didAuthor: false },
957 };953 };
958 const layerFor = (body: string) =>954 const layerFor = (body: string) =>
959 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({955 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
960 revalidateChecks: (_input, read) => read,956 revalidateChecks: (_input, read) => read,
961 getPullRequestActivity: () =>957 getPullRequestActivity: () =>
962 Effect.succeed({ author: null, comments: [dismissedReview(body)], commits: [] }),958 Effect.succeed({ author: null, comments: [dismissedReview(body)], commits: [] }),
...@@ -1040,7 +1036,7 @@ describe("editing", () => {...@@ -1040,7 +1036,7 @@ describe("editing", () => {
1040 ]);1036 ]);
1041 }).pipe(1037 }).pipe(
1042 Effect.provide(1038 Effect.provide(
1043 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({1039 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
1044 revalidateChecks: (_input, read) => read,1040 revalidateChecks: (_input, read) => read,
1045 updatePullRequest: (input) => Effect.sync(() => void rewrites.push(input)),1041 updatePullRequest: (input) => Effect.sync(() => void rewrites.push(input)),
1046 updateComment: (input) => Effect.sync(() => void rewrites.push(input)),1042 updateComment: (input) => Effect.sync(() => void rewrites.push(input)),
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+6-6
...@@ -7,8 +7,8 @@ import type {...@@ -7,8 +7,8 @@ import type {
7 PullRequestViewerPermissions,7 PullRequestViewerPermissions,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
99
10import * as GitHubCli from "../sourceControl/GitHubCli.ts";10import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts";
11import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";11import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
12import {12import {
13 PullRequestProviderError,13 PullRequestProviderError,
14 type PullRequestProviderFailure,14 type PullRequestProviderFailure,
...@@ -104,7 +104,7 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest...@@ -104,7 +104,7 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest
104104
105/** The tags that mean GitHub is out of reach for this account, rather than one request failing. */105/** The tags that mean GitHub is out of reach for this account, rather than one request failing. */
106export function gitHubProviderFailure(106export function gitHubProviderFailure(
107 error: GitHubPullRequestCli.GitHubPullRequestCliError,107 error: GitHubPullRequestApi.GitHubPullRequestApiError,
108): PullRequestProviderFailure {108): PullRequestProviderFailure {
109 switch (error._tag) {109 switch (error._tag) {
110 case "GitHubCliMissingError":110 case "GitHubCliMissingError":
...@@ -197,9 +197,9 @@ const rendersEmpty = (body: string): boolean =>...@@ -197,9 +197,9 @@ const rendersEmpty = (body: string): boolean =>
197 body.replace(/<!--[\s\S]*?-->/g, "").trim().length === 0;197 body.replace(/<!--[\s\S]*?-->/g, "").trim().length === 0;
198198
199export const make = Effect.gen(function* () {199export const make = Effect.gen(function* () {
200 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;200 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
201201
202 const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) =>202 const fail = (operation: string) => (error: GitHubPullRequestApi.GitHubPullRequestApiError) =>
203 new PullRequestProviderError({203 new PullRequestProviderError({
204 provider: "github",204 provider: "github",
205 operation,205 operation,
...@@ -526,7 +526,7 @@ export const make = Effect.gen(function* () {...@@ -526,7 +526,7 @@ export const make = Effect.gen(function* () {
526 // comparison, so one read usually answers what used to take three. When that heavier read526 // comparison, so one read usually answers what used to take three. When that heavier read
527 // fails, the light access read still answers, withholding only update-branch.527 // fails, the light access read still answers, withholding only update-branch.
528 return cli.getPullRequestDetail(input).pipe(528 return cli.getPullRequestDetail(input).pipe(
529 Effect.provideService(GitHubCli.AllowGitHubReserve, true),529 Effect.provideService(GitHubRepositoryApi.AllowGitHubReserve, true),
530 Effect.map((pullRequest) =>530 Effect.map((pullRequest) =>
531 gitHubViewerPermissions({531 gitHubViewerPermissions({
532 ...pullRequest.viewerAccess,532 ...pullRequest.viewerAccess,
apps/server/src/pullRequest/PullRequestProviderRegistry.ts+3-3
...@@ -16,7 +16,7 @@ import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";...@@ -16,7 +16,7 @@ import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
16import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts";16import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts";
17import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";17import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
18import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts";18import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts";
19import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";19import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
20import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";20import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";
21import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";21import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
22import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts";22import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts";
...@@ -61,11 +61,11 @@ export const make = Effect.map(...@@ -61,11 +61,11 @@ export const make = Effect.map(
6161
62export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(62export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
63 Layer.provide(63 Layer.provide(
64 GitHubPullRequestCli.layer.pipe(64 GitHubPullRequestApi.layer.pipe(
65 Layer.provide(65 Layer.provide(
66 GitHubApi.layer.pipe(66 GitHubApi.layer.pipe(
67 Layer.provide(GitHubCredentials.layer),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 Layer.provide(GitHubGraphQlBudget.layer),69 Layer.provide(GitHubGraphQlBudget.layer),
70 Layer.provide(SourceControlRateLimit.layer),70 Layer.provide(SourceControlRateLimit.layer),
71 ),71 ),
apps/server/src/pullRequest/PullRequestService.ts+1-1
...@@ -77,7 +77,7 @@ import {...@@ -77,7 +77,7 @@ import {
77import { resolveProjectSettings } from "@t3tools/shared/projectSettings";77import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
78import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";78import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
7979
80import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts";80import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts";
81import * as ProjectService from "../project/ProjectService.ts";81import * as ProjectService from "../project/ProjectService.ts";
82import * as ServerSettings from "../serverSettings.ts";82import * as ServerSettings from "../serverSettings.ts";
83import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";83import * 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,7 +9,7 @@ import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts";
99
10import * as GitHubApi from "../sourceControl/GitHubApi.ts";10import * as GitHubApi from "../sourceControl/GitHubApi.ts";
11import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts";11import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts";
12import type { GitHubPullRequestCliError } from "./GitHubPullRequestCli.ts";12import type { GitHubPullRequestApiError } from "./GitHubPullRequestApi.ts";
13import type { ProviderRepositoryRef } from "./PullRequestProvider.ts";13import type { ProviderRepositoryRef } from "./PullRequestProvider.ts";
1414
15const HeadSchema = Schema.Struct({15const HeadSchema = Schema.Struct({
...@@ -70,7 +70,7 @@ export const makeChecksRevalidator = Effect.gen(function* () {...@@ -70,7 +70,7 @@ export const makeChecksRevalidator = Effect.gen(function* () {
70 Pick<GitHubPullRequestDetail, "state" | "checks" | "headSha"> & {70 Pick<GitHubPullRequestDetail, "state" | "checks" | "headSha"> & {
71 workflowApprovalsRequired?: number;71 workflowApprovalsRequired?: number;
72 },72 },
73 GitHubPullRequestCliError73 GitHubPullRequestApiError
74 >,74 >,
75 ) =>75 ) =>
76 Effect.gen(function* () {76 Effect.gen(function* () {
apps/server/src/server.ts+3-3
...@@ -50,7 +50,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";...@@ -50,7 +50,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
50import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";50import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";
51import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";51import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
52import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";52import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
53import * as GitHubCli from "./sourceControl/GitHubCli.ts";53import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts";
54import * as GitLabCli from "./sourceControl/GitLabCli.ts";54import * as GitLabCli from "./sourceControl/GitLabCli.ts";
55import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";55import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";
56import * as TextGeneration from "./textGeneration/TextGeneration.ts";56import * as TextGeneration from "./textGeneration/TextGeneration.ts";
...@@ -270,7 +270,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p...@@ -270,7 +270,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p
270 Layer.mergeAll(270 Layer.mergeAll(
271 AzureDevOpsCli.layer,271 AzureDevOpsCli.layer,
272 BitbucketApi.layer,272 BitbucketApi.layer,
273 GitHubCli.layer,273 GitHubRepositoryApi.layer,
274 GitLabCli.layer,274 GitLabCli.layer,
275 ForgejoCli.layer,275 ForgejoCli.layer,
276 ),276 ),
...@@ -565,7 +565,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(...@@ -565,7 +565,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
565 Layer.provideMerge(layerServerSettings),565 Layer.provideMerge(layerServerSettings),
566 // The asset route uses the registry's GitHub credential for private PR media.566 // The asset route uses the registry's GitHub credential for private PR media.
567 Layer.provideMerge(layerSourceControlProviderRegistry),567 Layer.provideMerge(layerSourceControlProviderRegistry),
568 Layer.provideMerge(GitHubCli.layer),568 Layer.provideMerge(GitHubRepositoryApi.layer),
569 Layer.provideMerge(layerGit),569 Layer.provideMerge(layerGit),
570 Layer.provideMerge(layerVcs),570 Layer.provideMerge(layerVcs),
571 Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)),571 Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)),
apps/server/src/sourceControl/GitHubCli.test.ts deleted-645
...@@ -1,645 +0,0 @@
1import { assert, it, describe } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as TestClock from "effect/testing/TestClock";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";
9import { ChildProcessSpawner } from "effect/process";
10
11import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
12import * as VcsProcess from "../vcs/VcsProcess.ts";
13import * as GitHubApi from "./GitHubApi.ts";
14import * as GitHubCli from "./GitHubCli.ts";
15import * as GitHubCredentials from "./GitHubCredentials.ts";
16
17const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
18
19const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({
20 exitCode: ChildProcessSpawner.ExitCode(exitCode),
21 stdout,
22 stderr: "",
23 stdoutTruncated: false,
24 stderrTruncated: false,
25});
26
27const remotesOutput = (...entries: ReadonlyArray<readonly [string, string]>) =>
28 entries
29 .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
30 .join("\n");
31
32const 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
40const 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 */
57function 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
108describe("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
178describe("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
251describe("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
417describe("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
459describe("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
534describe("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 @@
1import * as Context from "effect/Context";
2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Request from "effect/Request";
9import * as RequestResolver from "effect/RequestResolver";
10import * as Result from "effect/Result";
11import * as Schema from "effect/Schema";
12
13import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts";
14import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
15import { decodeJsonResult } from "@t3tools/shared/schemaJson";
16import {
17 detectSourceControlProviderFromRemoteUrl,
18 isSshRemoteUrl,
19} from "@t3tools/shared/sourceControl";
20
21import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
22import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
23
24import * as VcsProcess from "../vcs/VcsProcess.ts";
25import * as GitHubApi from "./GitHubApi.ts";
26import * as GitHubCredentials from "./GitHubCredentials.ts";
27import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
28import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
29import {
30 decodeGitHubPullRequestEntries,
31 type NormalizedGitHubPullRequestRecord,
32} from "./gitHubPullRequests.ts";
33
34export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve;
35
36const gitHubCliFailureFields = {
37 command: Schema.Literal("gh"),
38 cwd: Schema.String,
39 cause: Schema.Defect(),
40} as const;
41
42export 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
51export 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
63export 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
72export 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
81export 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
95const gitHubCliDecodeFields = {
96 command: Schema.Literal("gh"),
97 cwd: Schema.String,
98 cause: Schema.Defect(),
99} as const;
100
101export 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
110export 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
119export 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
128export 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
137export const GitHubCliError = Schema.Union([
138 GitHubCliUnavailableError,
139 GitHubCliAuthenticationError,
140 GitHubCliRateLimitError,
141 GitHubPullRequestNotFoundError,
142 GitHubCliCommandError,
143 GitHubPullRequestListDecodeError,
144 GitHubChangeRequestListDecodeError,
145 GitHubPullRequestDecodeError,
146 GitHubRepositoryDecodeError,
147]);
148export type GitHubCliError = typeof GitHubCliError.Type;
149
150export const isGitHubCliError = Schema.is(GitHubCliError);
151
152/** Maps a GitHub API failure onto the errors callers of this service already handle. */
153function 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
180export 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
197function 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
205export interface GitHubRepositoryCloneUrls {
206 readonly nameWithOwner: string;
207 readonly url: string;
208 readonly sshUrl: string;
209}
210
211export 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 */
283export 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
321const 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});
327const decodeRawRepository = decodeJsonResult(RawRepositorySchema);
328
329function 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
335const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString }));
336
337type PullRequestListState = "open" | "closed" | "merged" | "all";
338
339/** The pull request fields every read selects, in GraphQL. */
340const PULL_REQUEST_NODE_SELECTION =
341 "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }";
342const 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 */
352const 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 */
359const HEAD_LOOKUP_BATCH_WINDOW = "50 millis";
360const 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 */
366const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25;
367/** A full document is 5,000 rows of well under 2 KB each. */
368const 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 */
374const OWNER_HEAD_SCAN_LIMIT = 100;
375
376class 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. */
392function 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
414const 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
420const 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
433const 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. */
442export 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. */
449function 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 */
476function 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 */
508export 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. */
520function 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. */
526export 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. */
543export 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
1086export 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 @@
1import { assert, it, describe } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as TestClock from "effect/testing/TestClock";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";
9import { ChildProcessSpawner } from "effect/process";
10
11import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
12import * as VcsProcess from "../vcs/VcsProcess.ts";
13import * as GitHubApi from "./GitHubApi.ts";
14import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts";
15import * as GitHubCredentials from "./GitHubCredentials.ts";
16
17const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
18
19const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({
20 exitCode: ChildProcessSpawner.ExitCode(exitCode),
21 stdout,
22 stderr: "",
23 stdoutTruncated: false,
24 stderrTruncated: false,
25});
26
27const remotesOutput = (...entries: ReadonlyArray<readonly [string, string]>) =>
28 entries
29 .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
30 .join("\n");
31
32const 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
40const 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 */
57function 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
111describe("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
181describe("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
257describe("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
423describe("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
465describe("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
540describe("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 @@
1import * as Context from "effect/Context";
2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Request from "effect/Request";
9import * as RequestResolver from "effect/RequestResolver";
10import * as Result from "effect/Result";
11import * as Schema from "effect/Schema";
12
13import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts";
14import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
15import { decodeJsonResult } from "@t3tools/shared/schemaJson";
16import {
17 detectSourceControlProviderFromRemoteUrl,
18 isSshRemoteUrl,
19} from "@t3tools/shared/sourceControl";
20
21import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
22import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
23
24import * as VcsProcess from "../vcs/VcsProcess.ts";
25import * as GitHubApi from "./GitHubApi.ts";
26import * as GitHubCredentials from "./GitHubCredentials.ts";
27import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
28import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
29import {
30 decodeGitHubPullRequestEntries,
31 type NormalizedGitHubPullRequestRecord,
32} from "./gitHubPullRequests.ts";
33
34export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve;
35
36const gitHubFailureFields = {
37 cwd: Schema.String,
38 cause: Schema.Defect(),
39} as const;
40
41export 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
50export 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
62export 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
71export 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
80export 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
94const gitHubDecodeFields = {
95 cwd: Schema.String,
96 cause: Schema.Defect(),
97} as const;
98
99export 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
108export 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
117export 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
126export 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
135export const GitHubRepositoryApiError = Schema.Union([
136 GitHubRepositoryUnavailableError,
137 GitHubRepositoryAuthenticationError,
138 GitHubRepositoryRateLimitError,
139 GitHubPullRequestNotFoundError,
140 GitHubRepositoryCommandError,
141 GitHubPullRequestListDecodeError,
142 GitHubChangeRequestListDecodeError,
143 GitHubPullRequestDecodeError,
144 GitHubRepositoryDecodeError,
145]);
146export type GitHubRepositoryApiError = typeof GitHubRepositoryApiError.Type;
147
148export const isGitHubRepositoryApiError = Schema.is(GitHubRepositoryApiError);
149
150/** Maps a GitHub API failure onto the errors callers of this service already handle. */
151function 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
181export 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
198function 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
206export interface GitHubRepositoryCloneUrls {
207 readonly nameWithOwner: string;
208 readonly url: string;
209 readonly sshUrl: string;
210}
211
212export 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 */
284export 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
322const 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});
328const decodeRawRepository = decodeJsonResult(RawRepositorySchema);
329
330function 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
336const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString }));
337
338type PullRequestListState = "open" | "closed" | "merged" | "all";
339
340/** The pull request fields every read selects, in GraphQL. */
341const PULL_REQUEST_NODE_SELECTION =
342 "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }";
343const 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 */
353const 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 */
360const HEAD_LOOKUP_BATCH_WINDOW = "50 millis";
361const 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 */
367const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25;
368/** A full document is 5,000 rows of well under 2 KB each. */
369const 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 */
375const OWNER_HEAD_SCAN_LIMIT = 100;
376
377class 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. */
393function 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
415const 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
421const 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
434const 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. */
443export 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. */
450function 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 */
477function 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 */
509export 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. */
521function 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. */
527export 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. */
544export 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
1081export 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,7 +8,7 @@ import { ChildProcessSpawner } from "effect/process";
88
9import * as VcsProcess from "../vcs/VcsProcess.ts";9import * as VcsProcess from "../vcs/VcsProcess.ts";
10import * as GitHubApi from "./GitHubApi.ts";10import * as GitHubApi from "./GitHubApi.ts";
11import * as GitHubCli from "./GitHubCli.ts";11import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts";
12import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";12import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
13import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";13import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
1414
...@@ -27,12 +27,15 @@ const processResult = (...@@ -27,12 +27,15 @@ const processResult = (
27});27});
2828
29function makeProvider(29function makeProvider(
30 github: Partial<GitHubCli.GitHubCli["Service"]>,30 github: Partial<GitHubRepositoryApi.GitHubRepositoryApi["Service"]>,
31 api: Partial<GitHubApi.GitHubApi["Service"]> = {},31 api: Partial<GitHubApi.GitHubApi["Service"]> = {},
32) {32) {
33 return GitHubSourceControlProvider.make.pipe(33 return GitHubSourceControlProvider.make.pipe(
34 Effect.provide(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,10 +90,9 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () =
87 }),90 }),
88);91);
8992
90it.effect("adds safe request context while retaining GitHub CLI causes", () =>93it.effect("adds safe request context while retaining GitHub API causes", () =>
91 Effect.gen(function* () {94 Effect.gen(function* () {
92 const cause = new GitHubCli.GitHubPullRequestNotFoundError({95 const cause = new GitHubRepositoryApi.GitHubPullRequestNotFoundError({
93 command: "gh",
94 cwd: "/repo",96 cwd: "/repo",
95 cause: new Error("raw upstream detail that should remain in the cause"),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,7 +111,6 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () =>
109 {111 {
110 provider: error.provider,112 provider: error.provider,
111 operation: error.operation,113 operation: error.operation,
112 command: error.command,
113 cwd: error.cwd,114 cwd: error.cwd,
114 reference: error.reference,115 reference: error.reference,
115 detail: error.detail,116 detail: error.detail,
...@@ -117,7 +118,6 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () =>...@@ -117,7 +118,6 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () =>
117 {118 {
118 provider: "github",119 provider: "github",
119 operation: "getChangeRequest",120 operation: "getChangeRequest",
120 command: "gh",
121 cwd: "/repo",121 cwd: "/repo",
122 reference: "https://github.com/pingdotgg/t3code/pull/42",122 reference: "https://github.com/pingdotgg/t3code/pull/42",
123 detail: "Pull request not found. Check the PR number or URL and try again.",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,8 +130,9 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () =>
130130
131it.effect("lists change request history through the batched head lookup", () =>131it.effect("lists change request history through the batched head lookup", () =>
132 Effect.gen(function* () {132 Effect.gen(function* () {
133 let lookup: Parameters<GitHubCli.GitHubCli["Service"]["listPullRequestsByHead"]>[0] | null =133 let lookup:
134 null;134 | Parameters<GitHubRepositoryApi.GitHubRepositoryApi["Service"]["listPullRequestsByHead"]>[0]
135 | null = null;
135 const provider = yield* makeProvider({136 const provider = yield* makeProvider({
136 listPullRequestsByHead: (input) => {137 listPullRequestsByHead: (input) => {
137 lookup = input;138 lookup = input;
...@@ -183,8 +184,9 @@ it.effect("lists change request history through the batched head lookup", () =>...@@ -183,8 +184,9 @@ it.effect("lists change request history through the batched head lookup", () =>
183184
184it.effect("creates GitHub PRs through provider-neutral input names", () =>185it.effect("creates GitHub PRs through provider-neutral input names", () =>
185 Effect.gen(function* () {186 Effect.gen(function* () {
186 let createInput: Parameters<GitHubCli.GitHubCli["Service"]["createPullRequest"]>[0] | null =187 let createInput:
187 null;188 | Parameters<GitHubRepositoryApi.GitHubRepositoryApi["Service"]["createPullRequest"]>[0]
189 | null = null;
188 const provider = yield* makeProvider({190 const provider = yield* makeProvider({
189 createPullRequest: (input) => {191 createPullRequest: (input) => {
190 createInput = input;192 createInput = input;
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+3-11
...@@ -15,7 +15,7 @@ import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";...@@ -15,7 +15,7 @@ import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
1515
16import * as ServerSettings from "../serverSettings.ts";16import * as ServerSettings from "../serverSettings.ts";
17import * as GitHubApi from "./GitHubApi.ts";17import * as GitHubApi from "./GitHubApi.ts";
18import * as GitHubCli from "./GitHubCli.ts";18import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts";
19import {19import {
20 effectiveGitHubAccount,20 effectiveGitHubAccount,
21 findAuthenticatedGitHubAccount,21 findAuthenticatedGitHubAccount,
...@@ -40,7 +40,7 @@ const decodeLinkSubject = Schema.decodeUnknownEffect(...@@ -40,7 +40,7 @@ const decodeLinkSubject = Schema.decodeUnknownEffect(
40 ),40 ),
41);41);
4242
43function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest {43function toChangeRequest(summary: GitHubRepositoryApi.GitHubPullRequestSummary): ChangeRequest {
44 return {44 return {
45 provider: "github",45 provider: "github",
46 number: summary.number,46 number: summary.number,
...@@ -264,7 +264,7 @@ export const makeDiscovery = Effect.gen(function* () {...@@ -264,7 +264,7 @@ export const makeDiscovery = Effect.gen(function* () {
264});264});
265265
266export const make = Effect.gen(function* () {266export const make = Effect.gen(function* () {
267 const github = yield* GitHubCli.GitHubCli;267 const github = yield* GitHubRepositoryApi.GitHubRepositoryApi;
268 const api = yield* GitHubApi.GitHubApi;268 const api = yield* GitHubApi.GitHubApi;
269269
270 const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] =270 const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] =
...@@ -286,7 +286,6 @@ export const make = Effect.gen(function* () {...@@ -286,7 +286,6 @@ export const make = Effect.gen(function* () {
286 new SourceControlProviderError({286 new SourceControlProviderError({
287 provider: "github",287 provider: "github",
288 operation: "listChangeRequests",288 operation: "listChangeRequests",
289 command: error.command,
290 cwd: input.cwd,289 cwd: input.cwd,
291 reference: SourceControlProvider.transportSafeSourceControlErrorValue(290 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
292 input.headSelector,291 input.headSelector,
...@@ -325,7 +324,6 @@ export const make = Effect.gen(function* () {...@@ -325,7 +324,6 @@ export const make = Effect.gen(function* () {
325 new SourceControlProviderError({324 new SourceControlProviderError({
326 provider: "github",325 provider: "github",
327 operation: "listChangeRequests",326 operation: "listChangeRequests",
328 command: error.command,
329 cwd: input.cwd,327 cwd: input.cwd,
330 reference: SourceControlProvider.transportSafeSourceControlErrorValue(328 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
331 input.headSelector,329 input.headSelector,
...@@ -403,7 +401,6 @@ export const make = Effect.gen(function* () {...@@ -403,7 +401,6 @@ export const make = Effect.gen(function* () {
403 new SourceControlProviderError({401 new SourceControlProviderError({
404 provider: "github",402 provider: "github",
405 operation: "getChangeRequest",403 operation: "getChangeRequest",
406 command: error.command,
407 cwd: input.cwd,404 cwd: input.cwd,
408 reference: SourceControlProvider.transportSafeSourceControlErrorValue(405 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
409 input.reference,406 input.reference,
...@@ -428,7 +425,6 @@ export const make = Effect.gen(function* () {...@@ -428,7 +425,6 @@ export const make = Effect.gen(function* () {
428 new SourceControlProviderError({425 new SourceControlProviderError({
429 provider: "github",426 provider: "github",
430 operation: "createChangeRequest",427 operation: "createChangeRequest",
431 command: error.command,
432 cwd: input.cwd,428 cwd: input.cwd,
433 reference: SourceControlProvider.transportSafeSourceControlErrorValue(429 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
434 input.headSelector,430 input.headSelector,
...@@ -445,7 +441,6 @@ export const make = Effect.gen(function* () {...@@ -445,7 +441,6 @@ export const make = Effect.gen(function* () {
445 new SourceControlProviderError({441 new SourceControlProviderError({
446 provider: "github",442 provider: "github",
447 operation: "getRepositoryCloneUrls",443 operation: "getRepositoryCloneUrls",
448 command: error.command,
449 cwd: input.cwd,444 cwd: input.cwd,
450 repository: SourceControlProvider.transportSafeSourceControlErrorValue(445 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
451 input.repository,446 input.repository,
...@@ -462,7 +457,6 @@ export const make = Effect.gen(function* () {...@@ -462,7 +457,6 @@ export const make = Effect.gen(function* () {
462 new SourceControlProviderError({457 new SourceControlProviderError({
463 provider: "github",458 provider: "github",
464 operation: "createRepository",459 operation: "createRepository",
465 command: error.command,
466 cwd: input.cwd,460 cwd: input.cwd,
467 repository: SourceControlProvider.transportSafeSourceControlErrorValue(461 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
468 input.repository,462 input.repository,
...@@ -486,7 +480,6 @@ export const make = Effect.gen(function* () {...@@ -486,7 +480,6 @@ export const make = Effect.gen(function* () {
486 new SourceControlProviderError({480 new SourceControlProviderError({
487 provider: "github",481 provider: "github",
488 operation: "getDefaultBranch",482 operation: "getDefaultBranch",
489 command: error.command,
490 cwd: input.cwd,483 cwd: input.cwd,
491 detail: error.message,484 detail: error.message,
492 cause: error,485 cause: error,
...@@ -500,7 +493,6 @@ export const make = Effect.gen(function* () {...@@ -500,7 +493,6 @@ export const make = Effect.gen(function* () {
500 new SourceControlProviderError({493 new SourceControlProviderError({
501 provider: "github",494 provider: "github",
502 operation: "checkoutChangeRequest",495 operation: "checkoutChangeRequest",
503 command: error.command,
504 cwd: input.cwd,496 cwd: input.cwd,
505 reference: SourceControlProvider.transportSafeSourceControlErrorValue(497 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
506 input.reference,498 input.reference,
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+2-2
...@@ -18,7 +18,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts";...@@ -18,7 +18,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts";
18import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";18import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
19import * as BitbucketApi from "./BitbucketApi.ts";19import * as BitbucketApi from "./BitbucketApi.ts";
20import * as GitHubApi from "./GitHubApi.ts";20import * as GitHubApi from "./GitHubApi.ts";
21import * as GitHubCli from "./GitHubCli.ts";21import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts";
22import * as GitLabCli from "./GitLabCli.ts";22import * as GitLabCli from "./GitLabCli.ts";
23import * as ForgejoCli from "./ForgejoCli.ts";23import * as ForgejoCli from "./ForgejoCli.ts";
24import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";24import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
...@@ -40,7 +40,7 @@ const layerSourceControlProviderRegistryTest = (input: {...@@ -40,7 +40,7 @@ const layerSourceControlProviderRegistryTest = (input: {
40 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),40 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
41 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),41 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
42 ServerSettings.ServerSettingsService.layerTest(),42 ServerSettings.ServerSettingsService.layerTest(),
43 Layer.mock(GitHubCli.GitHubCli)({}),43 Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)({}),
44 Layer.mock(GitHubApi.GitHubApi)({}),44 Layer.mock(GitHubApi.GitHubApi)({}),
45 Layer.mock(GitLabCli.GitLabCli)({}),45 Layer.mock(GitLabCli.GitLabCli)({}),
46 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),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,7 +15,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts";
15import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";15import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
16import * as BitbucketApi from "./BitbucketApi.ts";16import * as BitbucketApi from "./BitbucketApi.ts";
17import * as GitHubApi from "./GitHubApi.ts";17import * as GitHubApi from "./GitHubApi.ts";
18import * as GitHubCli from "./GitHubCli.ts";18import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts";
19import * as GitLabCli from "./GitLabCli.ts";19import * as GitLabCli from "./GitLabCli.ts";
20import * as ForgejoCli from "./ForgejoCli.ts";20import * as ForgejoCli from "./ForgejoCli.ts";
21import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";21import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
...@@ -42,7 +42,7 @@ function makeRegistry(input: {...@@ -42,7 +42,7 @@ function makeRegistry(input: {
42 readonly url: string;42 readonly url: string;
43 }>;43 }>;
44 readonly process?: Partial<VcsProcess.VcsProcess["Service"]>;44 readonly process?: Partial<VcsProcess.VcsProcess["Service"]>;
45 readonly github?: Partial<GitHubCli.GitHubCli["Service"]>;45 readonly github?: Partial<GitHubRepositoryApi.GitHubRepositoryApi["Service"]>;
46 readonly githubApi?: Partial<GitHubApi.GitHubApi["Service"]>;46 readonly githubApi?: Partial<GitHubApi.GitHubApi["Service"]>;
47 readonly gitlab?: Partial<GitLabCli.GitLabCli["Service"]>;47 readonly gitlab?: Partial<GitLabCli.GitLabCli["Service"]>;
48 readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"];48 readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"];
...@@ -98,7 +98,7 @@ function makeRegistry(input: {...@@ -98,7 +98,7 @@ function makeRegistry(input: {
98 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),98 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
99 Layer.mock(BitbucketApi.BitbucketApi)({}),99 Layer.mock(BitbucketApi.BitbucketApi)({}),
100 ServerSettings.ServerSettingsService.layerTest(),100 ServerSettings.ServerSettingsService.layerTest(),
101 Layer.mock(GitHubCli.GitHubCli)(input.github ?? {}),101 Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(input.github ?? {}),
102 Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}),102 Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}),
103 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),103 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),
104 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),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,7 +229,7 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.
229import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";229import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
230import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";230import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
231import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";231import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
232import * as GitHubCli from "./sourceControl/GitHubCli.ts";232import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts";
233import * as GitLabCli from "./sourceControl/GitLabCli.ts";233import * as GitLabCli from "./sourceControl/GitLabCli.ts";
234import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";234import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";
235import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";235import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
...@@ -3190,7 +3190,7 @@ export const layer = Layer.unwrap(...@@ -3190,7 +3190,7 @@ export const layer = Layer.unwrap(
3190 Layer.mergeAll(3190 Layer.mergeAll(
3191 AzureDevOpsCli.layer,3191 AzureDevOpsCli.layer,
3192 BitbucketApi.layer,3192 BitbucketApi.layer,
3193 GitHubCli.layer,3193 GitHubRepositoryApi.layer,
3194 GitLabCli.layer,3194 GitLabCli.layer,
3195 ForgejoCli.layer,3195 ForgejoCli.layer,
3196 ),3196 ),