diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index 414bd99d07459bcc736088cfc2c63dfe70bcc9b7..a8d2f9d986d1c2e470bf2fdfcde77bd30599e80e 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -23,7 +23,7 @@ import { } from "../src/textGeneration/ThreadTitleContext.ts"; import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; -import * as GitHubCli from "../src/sourceControl/GitHubCli.ts"; +import * as GitHubRepositoryApi from "../src/sourceControl/GitHubRepositoryApi.ts"; import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; @@ -152,7 +152,7 @@ await Effect.runPromise( SourceControlProviderRegistry.layer.pipe( Layer.provide( Layer.mergeAll( - GitHubCli.layer, + GitHubRepositoryApi.layer, GitLabCli.layer, ForgejoCli.layer, AzureDevOpsCli.layer, diff --git a/apps/server/scripts/measure-pr-preview.ts b/apps/server/scripts/measure-pr-preview.ts index c4a688b1aa7a91c36819af5b0b78b1d377c47afc..f1fc491b1a2bb655669c40ab8856d057af79f075 100644 --- a/apps/server/scripts/measure-pr-preview.ts +++ b/apps/server/scripts/measure-pr-preview.ts @@ -10,7 +10,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { FetchHttpClient } from "effect/http"; -import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.ts"; +import * as GitHubPullRequestApi from "../src/pullRequest/GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts"; import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts"; @@ -73,7 +73,7 @@ const measuredApi = Layer.effect( Layer.provide(NodeServices.layer), ); -const services = GitHubPullRequestCli.layer.pipe( +const services = GitHubPullRequestApi.layer.pipe( Layer.provideMerge(measuredApi), Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 61dd000633ef31c96d7b09b7216d1ebef8b9e644..f30af2c23c5b5554f3415f9660e68974e4a31c80 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -42,7 +42,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; @@ -92,7 +92,7 @@ interface FakeGhScenario { headRepositoryOwnerLogin?: string | null; }; repositoryCloneUrls?: Record; - failWith?: GitHubCli.GitHubCliError; + failWith?: GitHubRepositoryApi.GitHubRepositoryApiError; /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */ failAfterCalls?: number; } @@ -111,7 +111,9 @@ type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"]; type FakePullRequest = NonNullable; -function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequestSummary | null { +function normalizeFakePullRequestSummary( + raw: unknown, +): GitHubRepositoryApi.GitHubPullRequestSummary | null { if (!raw || typeof raw !== "object") { return null; } @@ -381,8 +383,8 @@ function createTextGeneration( }; } -function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { - service: GitHubCli.GitHubCli["Service"]; +function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { + service: GitHubRepositoryApi.GitHubRepositoryApi["Service"]; ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; @@ -398,7 +400,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const execute = (input: { readonly cwd: string; readonly args: ReadonlyArray; - }): Effect.Effect => { + }): Effect.Effect => { const args = [...input.args]; ghCalls.push(args.join(" ")); @@ -486,10 +488,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return fakeGhOutput(""); }, catch: (error) => - GitHubCli.isGitHubCliError(error) + GitHubRepositoryApi.isGitHubRepositoryApiError(error) ? error - : new GitHubCli.GitHubCliCommandError({ - command: "gh", + : new GitHubRepositoryApi.GitHubRepositoryCommandError({ cwd: input.cwd, cause: error, }), @@ -502,8 +503,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const cloneUrls = scenario.repositoryCloneUrls?.[repository]; if (!cloneUrls) { return Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", + new GitHubRepositoryApi.GitHubRepositoryCommandError({ cwd: input.cwd, cause: new Error(`Unexpected repository lookup: ${repository}`), }), @@ -523,8 +523,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } return Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", + new GitHubRepositoryApi.GitHubRepositoryCommandError({ cwd: input.cwd, cause: new Error(`Unexpected gh command: ${args.join(" ")}`), }), @@ -577,7 +576,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { Effect.map((raw) => raw .map((entry) => normalizeFakePullRequestSummary(entry)) - .filter((entry): entry is GitHubCli.GitHubPullRequestSummary => entry !== null), + .filter( + (entry): entry is GitHubRepositoryApi.GitHubPullRequestSummary => entry !== null, + ), ), ), createPullRequest: (input) => @@ -617,7 +618,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( - Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), + Effect.map( + (result) => JSON.parse(result.stdout) as GitHubRepositoryApi.GitHubPullRequestSummary, + ), ), getRepositoryCloneUrls: (input) => execute({ @@ -626,8 +629,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }).pipe(Effect.map((result) => JSON.parse(result.stdout))), createRepository: (input) => Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", + new GitHubRepositoryApi.GitHubRepositoryCommandError({ cwd: input.cwd, cause: new Error(`Unexpected repository create: ${input.repository}`), }), @@ -691,7 +693,9 @@ function makeManager(input?: { ProjectionStore.ProjectionStoreV2 | ProjectStore.ProjectStoreV2 >; }) { - const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); + const { service: gitHubRepositoryApi, ghCalls } = createGitHubRepositoryApiWithFakeGh( + input?.ghScenario, + ); const textGeneration = createTextGeneration(input?.textGeneration); const layerServerConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-", @@ -740,7 +744,7 @@ function makeManager(input?: { ), Effect.provide( Layer.merge( - Layer.succeed(GitHubCli.GitHubCli, gitHubCli), + Layer.succeed(GitHubRepositoryApi.GitHubRepositoryApi, gitHubRepositoryApi), Layer.mock(GitHubApi.GitHubApi)({}), ), ), @@ -1296,8 +1300,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -1779,8 +1782,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("gh is not available on PATH"), }), @@ -2640,8 +2642,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }, ]), }, - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2717,8 +2718,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("gh is not available on PATH"), }), @@ -2743,8 +2743,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value"; const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliRateLimitError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryRateLimitError({ cwd: repoDir, cause: new Error(upstreamCause), }), @@ -2770,7 +2769,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { errorTag: "SourceControlProviderError", provider: "github", providerOperation: "listChangeRequests", - providerCommand: "gh", errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.", }); const loggedText = [ @@ -2801,8 +2799,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2844,8 +2841,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2892,8 +2888,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2931,8 +2926,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2973,8 +2967,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -4618,8 +4611,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("gh is not available on PATH"), }), @@ -4648,8 +4640,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliAuthenticationError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryAuthenticationError({ cwd: repoDir, cause: new Error("gh is not authenticated"), }), diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index cbc0490a95c1d80b5b67c29a7c2f7b6b13a231da..e9b244dfbe86d060b03cff6ce7c255bbfd0f860d 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1286,7 +1286,7 @@ export const make = Effect.gen(function* () { ? { provider: error.provider, providerOperation: error.operation, - providerCommand: error.command ?? "unknown", + ...(error.command === undefined ? {} : { providerCommand: error.command }), errorDetail: error.detail, } : {}), diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 072bf930c038ebe0a1a199928a6d9eba041d7ed5..c1e0cff2658f615e792f6707ae24b69521d26176 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -345,7 +345,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), ), ), - // Wide enough that a sweep's GitHub branch lookups reach GitHubCli together and share one + // Wide enough that a sweep's GitHub branch lookups reach GitHubRepositoryApi together and share one // GraphQL document, instead of one `gh pr list` per branch. { concurrency: 32, discard: true }, ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c42bf6d147bfc76d361c995e76e18801155cd9f9 --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts @@ -0,0 +1,4545 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import { afterEach, assert, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as Redacted from "effect/Redacted"; + +import { HttpClient, HttpClientResponse } from "effect/http"; + +import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; +import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; +import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; +import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const coreResponse = (pullRequest: Readonly> = {}) => ({ + data: { + repository: { + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + viewerPermission: "WRITE", + pullRequest: { + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + headRefName: "feature", + baseRefName: "main", + headRefOid: "abc123", + state: "OPEN", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + viewerCanUpdate: true, + viewerDidAuthor: false, + viewerCanUpdateBranch: true, + baseRef: { compare: { behindBy: 2 } }, + reviewRequests: { nodes: [] }, + labels: { nodes: [] }, + commits: { nodes: [] }, + ...pullRequest, + }, + }, + }, +}); + +/** One request the CLI made, as the transport received it. */ +type ApiCall = + | ({ readonly kind: "graphql" } & GitHubApi.GitHubGraphQlInput) + | ({ readonly kind: "rest" } & GitHubApi.GitHubRestInput); + +type ApiAnswer = Effect.Effect; + +const mockedExecute = vi.fn<(call: ApiCall) => ApiAnswer>(); +const defaultCredential = (host: string) => + Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:token` }); +const mockedCredential = vi.fn(defaultCredential); +const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() => + Effect.succeed(output('{"data":{}}')), +); + +/** + * A transport that answers from the mocks above but spends the real GraphQL budget, the way + * GitHubApi does, so the reserve and pause behaviour is still the module's to prove. + */ +const mockApi = Layer.effect( + GitHubApi.GitHubApi, + Effect.gen(function* () { + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + return GitHubApi.GitHubApi.of({ + graphql: (input) => + budget + .query(input.host, input.query, input.allowReserve ? { allowReserve: true } : undefined) + .pipe( + Effect.flatMap((query) => + (input.query.includes("query PullRequestStackMemberships") + ? mockedStackMemberships + : mockedExecute)({ kind: "graphql", ...input, query }), + ), + Effect.map((answer) => answer.body), + Effect.tap((body) => budget.observe(input.host, body)), + ), + rest: (input) => mockedExecute({ kind: "rest", ...input }), + credential: (host) => mockedCredential(host), + }); + }), +); + +const layer = it.layer( + GitHubPullRequestApi.layer.pipe( + Layer.provideMerge(mockApi), + Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))), + ), +); + +/** A successful answer; GraphQL reads take its body. */ +function output( + body: string, + truncated = false, + invalidUtf8 = false, +): GitHubApi.GitHubRestResponse { + return { status: 200, headers: {}, body, truncated, invalidUtf8 }; +} + +/** + * Listing rows as GraphQL answers them. Overrides may use the flat shapes a test reads most + * easily: `reviewRequests` as `[{ login }]` or `[{ slug }]`, and `checks` as the rollup state. + */ +function rows( + count: number, + firstNumber: number, + overrides: (number: number) => Readonly> = () => ({}), +): ReadonlyArray> { + return Array.from({ length: count }, (_, index) => { + const number = firstNumber + index; + const { reviewRequests, checks, ...rest } = overrides(number) as { + reviewRequests?: ReadonlyArray>; + checks?: string; + } & Record; + return { + number, + title: `Pull request ${number}`, + url: `https://github.com/acme/web/pull/${number}`, + headRefName: "feat/page", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + repository: { nameWithOwner: "acme/web" }, + ...(reviewRequests === undefined + ? {} + : { + reviewRequests: { + nodes: reviewRequests.map((requestedReviewer) => ({ requestedReviewer })), + }, + }), + ...(checks === undefined + ? {} + : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }), + ...rest, + }; + }); +} + +/** A page of the searched listing. */ +function pullRequests( + count: number, + firstNumber: number, + overrides?: (number: number) => Readonly>, +): string { + return encodeJson({ + data: { + search: { pageInfo: { hasNextPage: false }, nodes: rows(count, firstNumber, overrides) }, + }, + }); +} + +/** A page of a repository's own list, which is what the search-free fallback reads. */ +function listedPullRequests( + count: number, + firstNumber: number, + overrides?: (number: number) => Readonly>, +): string { + return encodeJson({ + data: { + repository: { + pullRequests: { + pageInfo: { hasNextPage: false }, + nodes: rows(count, firstNumber, overrides), + }, + }, + }, + }); +} + +/** A search that found nothing, which is also what GitHub says for a repository it does not index. */ +const emptySearch = () => output(pullRequests(0, 1)); +const emptyList = () => output(listedPullRequests(0, 1)); + +function pullRequestFiles(count: number, firstIndex: number): string { + return encodeJson( + Array.from({ length: count }, (_, index) => ({ + filename: `src/file${firstIndex + index}.ts`, + status: "modified", + patch: "@@ -1 +1 @@\n-old\n+new", + })), + ); +} + +/** One thread's comments as the GraphQL read returns them, cursor and all. */ +function threadComments( + ids: ReadonlyArray, + endCursor: string | null, + totalCount = ids.length, +) { + return { + totalCount, + pageInfo: { hasNextPage: endCursor !== null, endCursor }, + nodes: ids.map((id) => ({ id, body: id, createdAt: "2026-07-01T00:00:00Z" })), + }; +} + +function thread(id: string, ...commentIds: ReadonlyArray) { + return { + id, + path: "src/a.ts", + line: 1, + diffSide: "RIGHT", + isResolved: false, + isOutdated: false, + comments: threadComments(commentIds, null), + }; +} + +function reviewThreadsPage( + nodes: ReadonlyArray>, + endCursor: string | null, +): string { + return encodeJson({ + data: { + repository: { + pullRequest: { + reviewThreads: { + totalCount: nodes.length, + pageInfo: { hasNextPage: endCursor !== null, endCursor }, + nodes, + }, + }, + }, + }, + }); +} + +function threadCommentsPage( + ids: ReadonlyArray, + endCursor: string | null, + totalCount: number, + pullRequestId = "PR_7", +): string { + return encodeJson({ + data: { + repository: { pullRequest: { id: "PR_7" } }, + node: { + pullRequest: { id: pullRequestId }, + comments: threadComments(ids, endCursor, totalCount), + }, + }, + }); +} + +/** What GitHub answers for a pull request it will not serve a whole diff for. */ +const diffRefused = new GitHubApi.GitHubApiResponseError({ + host: "github.com", + operation: "getPullRequestDiff", + status: 406, +}); + +/** The nth request the CLI made. */ +function callAt(index: number): ApiCall { + const call = mockedExecute.mock.calls[index]; + assert.isDefined(call); + return call[0]; +} + +/** The GraphQL variables of the nth request. */ +function varsAt(index: number): Readonly> { + const call = callAt(index); + assert.strictEqual(call.kind, "graphql"); + return call.kind === "graphql" ? (call.variables ?? {}) : {}; +} + +/** The GraphQL document of the nth request. */ +function queryAt(index: number): string { + const call = callAt(index); + return call.kind === "graphql" ? call.query : ""; +} + +/** The REST path of the nth request. */ +function pathAt(index: number): string { + const call = callAt(index); + return call.kind === "rest" ? call.path : ""; +} + +/** The search the nth request carried, or undefined for a read that did not search. */ +function searchOfCall(index: number): string | undefined { + const q = callAt(index).kind === "graphql" ? varsAt(index)["q"] : undefined; + return typeof q === "string" ? q : undefined; +} + +/** One row as a search answers it, which is the listing's row one connection deeper. */ +function searchItem(number: number, repository: string, updatedAt: string) { + return { + number, + title: `Pull request ${number}`, + url: `https://github.com/${repository}/pull/${number}`, + author: { login: "octocat", avatarUrl: "https://avatars/octocat" }, + headRefName: "feat/page", + baseRefName: "main", + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + createdAt: "2026-07-01T00:00:00Z", + updatedAt, + repository: { nameWithOwner: repository }, + reviewRequests: { nodes: [{ requestedReviewer: { login: "hubot" } }] }, + labels: { nodes: [{ name: "bug", color: "ff0000" }] }, + }; +} + +function searchPage(nodes: ReadonlyArray, hasNextPage = false) { + return output(encodeJson({ data: { search: { pageInfo: { hasNextPage }, nodes } } })); +} + +const searchQueryOfCall = searchOfCall; + +/** + * Answers every request with the body of the first route whose needle its GraphQL document or + * REST path contains, and anything unrouted with an empty object, which a mutation never reads. + */ +function route(...routes: ReadonlyArray): void { + mockedExecute.mockImplementation((call) => { + const target = call.kind === "graphql" ? call.query : call.path; + const match = routes.find(([needle]) => target.includes(needle)); + return Effect.succeed(output(match === undefined ? "{}" : encodeJson(match[1]))); + }); +} + +/** The variables of every GraphQL request whose document contains `needle`, in order. */ +function variablesOf(needle: string): ReadonlyArray>> { + return mockedExecute.mock.calls.flatMap(([call]) => + call.kind === "graphql" && call.query.includes(needle) ? [call.variables ?? {}] : [], + ); +} + +/** Every REST request whose path contains `needle`, in order. */ +function restCallsTo(needle: string): ReadonlyArray { + return mockedExecute.mock.calls.flatMap(([call]) => + call.kind === "rest" && call.path.includes(needle) ? [call] : [], + ); +} + +/** + * The check that a client-given node hangs off the pull request it names. Routed before the node + * id lookup, whose document it contains. + */ +const SUBJECT_SCOPE_QUERY = "node(id: $subjectId)"; +const subjectScope = (subjectId: string, pullRequestId: string) => ({ + data: { + repository: { pullRequest: { id: pullRequestId } }, + node: { id: subjectId, pullRequest: { id: pullRequestId } }, + }, +}); + +/** One page of a head commit's check contexts, the read a rollup past one page is walked by. */ +const checkContextsPage = ( + nodes: ReadonlyArray>, + endCursor: string | null, + headRefOid = "abc123", +) => + encodeJson({ + data: { + repository: { + pullRequest: { + headRefOid, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { nodes, pageInfo: { hasNextPage: endCursor !== null, endCursor } }, + }, + }, + }, + ], + }, + }, + }, + }, + }); + +/** A pull request's base and head, as the REST read a file expansion starts from answers them. */ +const pullRequestRefs = encodeJson({ base: { sha: "a1b2c3d" }, head: { sha: "b1c2d3e" } }); + +/** A cross-repository pull request whose head waits on a maintainer to run its workflows. */ +const crossRepositoryDetail = (headRefOid = "abc123") => + coreResponse({ + headRefName: "feat/page", + headRefOid, + isCrossRepository: true, + headRepositoryOwner: { login: "octocat" }, + }); + +/** Open pull requests on one head branch, every one of them from the same fork head. */ +const heads = (numbers: ReadonlyArray) => ({ + data: { + repository: { + pullRequests: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: numbers.map((number) => ({ + number, + headRefOid: "abc123", + isCrossRepository: true, + headRepositoryOwner: { login: "octocat" }, + })), + }, + }, + }, +}); + +const workflowRuns = (ids: ReadonlyArray) => ({ + workflow_runs: ids.map((id) => ({ + id, + name: id === 10 ? "build" : `run ${id}`, + html_url: `https://example.com/${id}`, + })), +}); + +/** Answers the reads an approval makes: the detail, the heads, the runs; approvals return nothing. */ +function workflowApprovalRoutes( + detail: () => unknown, + headsAnswer: unknown, + runsAnswer: unknown, +): void { + mockedExecute.mockImplementation((call) => + Effect.sync(() => + output( + call.kind === "rest" + ? call.path.endsWith("/approve") + ? "" + : encodeJson(runsAnswer) + : call.query.includes("headRefName: $head") + ? encodeJson(headsAnswer) + : encodeJson(detail()), + ), + ), + ); +} + +/** The pull request node id lookup, which the layer caches for every test after the first. */ +const NODE_ID_QUERY = "pullRequest(number: $number) { id }"; +const nodeIdAnswer = (id: string) => ({ data: { repository: { pullRequest: { id } } } }); + +afterEach(() => { + mockedExecute.mockReset(); + mockedStackMemberships.mockReset(); + mockedCredential.mockReset(); + mockedCredential.mockImplementation(defaultCredential); +}); + +it.effect( + "keeps a verified credential through an auth switch and separates token fingerprints", + () => + Effect.gen(function* () { + let activeToken = "broad-credential"; + const requests: Array<{ readonly url: string; readonly authorization: string | undefined }> = + []; + const credentials = Layer.succeed( + GitHubCredentials.GitHubCredentials, + GitHubCredentials.GitHubCredentials.of({ + get: (host) => + Effect.sync(() => ({ + host, + token: Redacted.make(activeToken), + source: "gh" as const, + fingerprint: `${host}:${activeToken.length}${activeToken.at(-1)}`, + })), + invalidate: () => Effect.void, + }), + ); + const http = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + requests.push({ url: request.url, authorization: request.headers["authorization"] }); + const body = request.url.endsWith("/user") + ? { id: 123, login: "same-account" } + : { data: { repository: { pullRequest: { id: "PR_1" } }, addComment: {} } }; + return HttpClientResponse.fromWeb(request, new Response(encodeJson(body))); + }), + ), + ); + const cli = yield* GitHubPullRequestApi.make.pipe( + Effect.provide( + GitHubApi.layer.pipe( + Layer.provide(Layer.mergeAll(credentials, http)), + Layer.provide(GitHubGraphQlBudget.layer), + Layer.provide(SourceControlRateLimit.layer), + Layer.merge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + const input = { cwd: "/repo", host: "github.com" }; + const first = yield* cli.withVerifiedCredential(input, (identity) => + Effect.gen(function* () { + activeToken = "restricted-credential"; + expect(yield* cli.getViewerLogin(input)).toBe("same-account"); + yield* cli.commentOnPullRequest({ + ...input, + repository: "owner/repo", + number: 1, + body: "comment", + }); + return identity; + }), + ); + const second = yield* cli.withVerifiedCredential(input, Effect.succeed); + expect(first.accountId).toBe(second.accountId); + expect(first.credentialFingerprint).not.toBe(second.credentialFingerprint); + expect(encodeJson([first, second])).not.toContain("broad-credential"); + expect(encodeJson([first, second])).not.toContain("restricted-credential"); + // The comment was written under the credential the page verified, not the switched one. + expect( + requests + .filter((request) => request.url.endsWith("/graphql")) + .map((request) => request.authorization), + ).toEqual(["Bearer broad-credential", "Bearer broad-credential"]); + expect( + requests + .filter((request) => request.url.endsWith("/user")) + .map((request) => request.authorization), + ).toEqual(["Bearer broad-credential", "Bearer restricted-credential"]); + expect(yield* cli.getRoutingIdentity(input)).toEqual({ + accountId: "123", + viewer: "same-account", + }); + }), +); + +layer("GitHubPullRequestApi.layer", (it) => { + it.effect("admits only one concurrent preview above the reserve and resumes after reset", () => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const resetAt = "2099-08-13T14:00:00Z"; + yield* budget.observe( + "preview-budget.example", + encodeJson({ data: { rateLimit: { cost: 1, limit: 5_000, remaining: 501, resetAt } } }), + ); + mockedExecute.mockReturnValue( + Effect.succeed( + output( + encodeJson({ + data: { + repository: { + pullRequest: { + number: 7, + title: "Fast previews", + url: "https://preview-budget.example/acme/web/pull/7", + state: "OPEN", + isDraft: false, + createdAt: "2026-07-01T00:00:00Z", + author: null, + }, + }, + rateLimit: { cost: 1, limit: 5_000, remaining: 500, resetAt }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const input = { + cwd: "/w", + repository: "acme/web", + host: "preview-budget.example", + number: 7, + }; + const results = yield* Effect.all( + Array.from({ length: 20 }, (_, index) => + cli.getPullRequestPreview({ ...input, number: index + 1 }).pipe(Effect.result), + ), + { concurrency: "unbounded" }, + ); + expect(results.filter((result) => result._tag === "Success")).toHaveLength(1); + for (const result of results) { + if (result._tag === "Failure") { + expect(result.failure._tag).toBe("SourceControlRateLimitPausedError"); + } + } + expect(mockedExecute).toHaveBeenCalledTimes(1); + yield* TestClock.setTime(Date.parse(resetAt)); + yield* cli.getPullRequestPreview(input); + expect(mockedExecute).toHaveBeenCalledTimes(2); + yield* TestClock.setTime(now); + }), + ); + + it.effect("loads the complete hover card with one GraphQL request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + repository: { + pullRequest: { + number: 7, + title: "Fast previews", + url: "https://github.example/acme/web/pull/7", + state: "MERGED", + isDraft: false, + createdAt: "2026-07-01T00:00:00Z", + author: { + login: "octocat", + name: "Octo Cat", + avatarUrl: "https://github.example/avatar.png", + }, + }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const preview = yield* cli.getPullRequestPreview({ + cwd: "/w", + repository: "acme/web", + host: "github.example", + number: 7, + }); + expect(preview).toEqual({ + number: 7, + title: "Fast previews", + url: "https://github.example/acme/web/pull/7", + state: "merged", + isDraft: false, + createdAt: "2026-07-01T00:00:00Z", + author: { + login: "octocat", + name: "Octo Cat", + avatarUrl: "https://github.example/avatar.png", + }, + }); + expect(mockedExecute).toHaveBeenCalledTimes(1); + expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.example" }); + }), + ); + + it.effect("coalesces concurrent identity verification for the same host and credential", () => + Effect.gen(function* () { + mockedExecute.mockImplementation(() => + Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const results = yield* Effect.all( + Array.from({ length: 4 }, () => + cli.getRoutingIdentity({ cwd: "/w", host: "github.identity-flight.test" }), + ), + { concurrency: 4 }, + ); + expect(results).toEqual( + Array.from({ length: 4 }, () => ({ accountId: "123", viewer: "viewer" })), + ); + expect(mockedExecute).toHaveBeenCalledTimes(1); + expect(pathAt(0)).toBe("user"); + }), + ); + + it.effect( + "lets another identity reader continue when the first verification is interrupted", + () => + Effect.gen(function* () { + const firstStarted = yield* Deferred.make(); + let verifications = 0; + mockedExecute.mockImplementation(() => + Effect.gen(function* () { + if (++verifications === 1) { + yield* Deferred.succeed(firstStarted, undefined); + return yield* Effect.never; + } + return output('{"id":123,"login":"viewer"}'); + }), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const input = { cwd: "/w", host: "github.identity-cancel.test" }; + const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); + yield* Deferred.await(firstStarted); + // The second reader is queued on the same credential's lock behind the first. + const second = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Fiber.interrupt(first); + expect(yield* Fiber.join(second)).toEqual({ accountId: "123", viewer: "viewer" }); + expect(verifications).toBe(2); + }), + ); + + it.effect("reads linked pull requests on one host together, filed back by position", () => + Effect.gen(function* () { + const node = (number: number) => ({ + number, + title: `Pull request ${number}`, + url: `https://github.com/acme/web/pull/${number}`, + author: { __typename: "User", login: "octocat", name: "Octo Cat", avatarUrl: null }, + baseRefName: "main", + headRefName: `feat/${number}`, + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: null, + latestReviews: { nodes: [{ state: "APPROVED", author: { login: "reviewer" } }] }, + additions: 12, + deletions: 3, + changedFiles: 2, + updatedAt: "2026-08-24T12:34:56.000Z", + mergedAt: null, + closedAt: null, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + }); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const reads = yield* Effect.forEach( + [7, 8], + (number) => + cli.getPullRequestSummary({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number, + }), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + const [seven, eight] = yield* Fiber.join(reads); + + assert.deepStrictEqual( + { + number: seven?.number, + state: seven?.state, + headBranch: seven?.headBranch, + author: seven?.author?.login, + changedFiles: seven?.changedFiles, + reviewDecision: seven?.reviewDecision, + checksState: seven?.checksState, + mergeability: seven?.mergeability, + }, + { + number: 7, + state: "open", + headBranch: "feat/7", + author: "octocat", + changedFiles: 2, + reviewDecision: "approved", + checksState: "passing", + mergeability: "mergeable", + }, + ); + assert.strictEqual(eight?.headBranch, "feat/8"); + expect(mockedExecute).toHaveBeenCalledOnce(); + const document = queryAt(0); + expect(document).toContain( + 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)', + ); + expect(document).toContain("pullRequest(number: 8)"); + }), + ); + + it.effect("fingerprints watched pull requests on one host in one read", () => + Effect.gen(function* () { + const node = (comments: number) => ({ + state: "OPEN", + mergeable: "MERGEABLE", + headRefOid: "abc123", + comments: { totalCount: comments, nodes: [] }, + reviews: { totalCount: 0, nodes: [] }, + reviewThreads: { totalCount: 0 }, + commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, + }); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify({ + data: { w0: { pullRequest: node(1) }, w1: { pullRequest: null } }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const reads = yield* Effect.forEach( + [7, 8], + (number) => + cli.getPullRequestWatchFingerprint({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number, + }), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + const [seven, eight] = yield* Fiber.join(reads); + + expect(seven?.remarks.startsWith("1 ")).toBe(true); + // GitHub had no answer for #8, so its watch reads it in full. + expect(eight).toBeNull(); + expect(mockedExecute).toHaveBeenCalledOnce(); + const call = callAt(0); + expect(call.kind === "graphql" ? call.query : "").toContain( + 'w1: repository(owner: "acme", name: "web") { pullRequest(number: 8)', + ); + }), + ); + + it.effect("reads a pull request the batch said nothing about on its own", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output('{"data":{"s0":{"pullRequest":null}}}'))) + .mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + s0: { + pullRequest: { + number: 7, + title: "Reuse the summary", + url: "https://github.com/acme/web/pull/7", + author: { login: "octocat", name: "Octo Cat" }, + baseRefName: "main", + headRefName: "feat/summary", + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + additions: 12, + deletions: 3, + changedFiles: 2, + updatedAt: "2026-08-24T12:34:56.000Z", + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const read = yield* cli + .getPullRequestSummary({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + const summary = yield* Fiber.join(read); + + assert.strictEqual(summary.headBranch, "feat/summary"); + assert.strictEqual(summary.checksState, "passing"); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + expect(varsAt(1)).toEqual({ owner: "acme", name: "web", number: 7 }); + }), + ); + + it.effect("reads the stack a pull request is in through the stacks preview, on its host", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson([ + { + id: 42, + number: 3, + url: "https://api.github.com/repos/acme/web/stacks/3", + base: { ref: "main" }, + pull_requests: [ + { + number: 6, + head: { ref: "feat/one" }, + state: "closed", + merged_at: "2026-09-02", + }, + { number: 7, head: { ref: "feat/two" }, state: "open", merged_at: null }, + ], + }, + ]), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const stack = yield* cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "ghe.example.com", + number: 7, + }); + + assert.deepStrictEqual(stack, { + id: "42", + number: 3, + url: "https://api.github.com/repos/acme/web/stacks/3", + base: "main", + layers: [ + { number: 6, headBranch: "feat/one", state: "merged" }, + { number: 7, headBranch: "feat/two", state: "open" }, + ], + }); + expect(callAt(0)).toMatchObject({ + kind: "rest", + host: "ghe.example.com", + path: "repos/acme/web/stacks?pull_request=7", + }); + }), + ); + + it.effect("fetches layer titles only when the caller asks for stack details", () => + Effect.gen(function* () { + const minimal = { + url: "https://api.github.com/repos/acme/web/stacks/3", + number: 3, + base: { ref: "main" }, + pull_requests: [ + { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null }, + ], + }; + mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson([minimal])))); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + ...minimal, + pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }], + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const stack = yield* cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + includeDetails: true, + }); + expect(stack?.layers[0]).toMatchObject({ + title: "Second layer", + headSha: "abc123", + isDraft: false, + }); + expect(pathAt(1)).toBe("repos/acme/web/stacks/3"); + }), + ); + + it.effect("reads an empty stacks listing as not stacked", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const stack = yield* cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.isNull(stack); + }), + ); + + it.effect("reads a host that refuses the stacks preview as not stacked", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.fail( + new GitHubApi.GitHubApiNotFoundError({ + host: "github.com", + operation: "getPullRequestStack", + }), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const stack = yield* cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.isNull(stack); + }), + ); + + it.effect("does not read a refused credential as an unstacked pull request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.fail( + new GitHubApi.GitHubApiAuthenticationError({ + host: "github.com", + operation: "getPullRequestStack", + }), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + + assert.strictEqual(error._tag, "GitHubApiAuthenticationError"); + }), + ); + + it.effect("preserves transient stack failures instead of reporting no stack", () => + Effect.gen(function* () { + const failure = new GitHubApi.GitHubApiResponseError({ + host: "github.com", + operation: "getPullRequestStack", + status: 503, + }); + mockedExecute.mockReturnValueOnce(Effect.fail(failure)); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const error = yield* Effect.flip( + cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + assert.strictEqual(error, failure); + }), + ); + + it.effect("reports a stacks answer it cannot read against the stack read", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]'))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + + assert.strictEqual(error._tag, "GitHubPullRequestReadError"); + if (error._tag !== "GitHubPullRequestReadError") return; + assert.strictEqual(error.operation, "getPullRequestStack"); + }), + ); + + it.effect("asks for one row more than the page, to probe for a next page", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 3); + assert.isFalse(batch.truncated); + expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.com" }); + expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); + expect(queryAt(0)).toContain("first: 11"); + }), + ); + + it.effect("reports truncation from the extra row, counted before decoding", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1)))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 10); + assert.isTrue(batch.truncated); + }), + ); + + it.effect("excludes merged pull requests from the Closed tab", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "closed", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + // A closed pull request may also be a merged one, so the tab narrows through search. + expect(searchOfCall(0)).toBe("is:pr is:closed is:unmerged sort:updated-desc repo:acme/web"); + }), + ); + + it.effect("narrows to the author on the authored tab", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "authored", + viewer: "bilal", + limit: 10, + }); + + expect(searchOfCall(0)).toBe("is:pr is:open author:bilal sort:updated-desc repo:acme/web"); + }), + ); + + it.effect("narrows through search on the reviewing tab", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "reviewing", + viewer: "bilal", + limit: 10, + }); + + expect(searchOfCall(0)).toBe( + "is:pr is:open review-requested:bilal sort:updated-desc repo:acme/web", + ); + }), + ); + + it.effect("carries every repository and every qualifier into one search", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web", "pingdotgg/t3code"], + state: "closed", + involvement: "reviewing", + viewer: "bilal", + limit: 10, + query: "pull requests page", + cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, + }); + + // One request for both repositories, carrying everything the per-repository read expresses + // as a flag: the tab, the involvement, the reader's words, where to carry on from, and the + // order the page reads in. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + assert.strictEqual( + searchQueryOfCall(0), + 'is:pr is:closed is:unmerged review-requested:bilal "pull requests page" ' + + "updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web repo:pingdotgg/t3code", + ); + }), + ); + + it.effect("narrows a search to the author, and to merged on the merged tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web"], + state: "merged", + involvement: "authored", + viewer: "bilal", + limit: 10, + }); + + assert.strictEqual( + searchQueryOfCall(0), + "is:pr is:merged author:bilal sort:updated-desc repo:acme/web", + ); + }), + ); + + it.effect("keeps a searched-for qualifier inside the phrase", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web"], + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: 'x" is:merged repo:evil/repo', + }); + + // Quoted and escaped, so the words a reader typed narrow the listing rather than widening it. + assert.strictEqual( + searchQueryOfCall(0), + 'is:pr is:open "x\\" is:merged repo:evil/repo" sort:updated-desc repo:acme/web', + ); + }), + ); + + it.effect("refuses to search for a repository GitHub cannot address", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const failure = yield* Effect.flip( + cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web", "acme/web is:merged"], + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }), + ); + + // Nothing is sent: a name that could end its own qualifier is refused rather than escaped. + assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("files each searched row under the repository it came from", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + searchPage([ + searchItem(7, "acme/web", "2026-07-03T00:00:00Z"), + searchItem(9, "pingdotgg/t3code", "2026-07-02T00:00:00Z"), + // Not a pull request, which `is:pr` excludes and a decode skips rather than fails on. + {}, + ]), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const batch = yield* cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web", "pingdotgg/t3code"], + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + assert.deepStrictEqual( + batch.items.map((item) => [item.repository, item.number, item.author?.avatarUrl]), + [ + ["acme/web", 7, "https://avatars/octocat"], + ["pingdotgg/t3code", 9, "https://avatars/octocat"], + ], + ); + // The listing leaves the line counts to a read of their own. + assert.deepStrictEqual( + batch.items.map((item) => [item.additions, item.deletions]), + [ + [0, 0], + [0, 0], + ], + ); + assert.isFalse(batch.truncated); + }), + ); + + it.effect("reports truncation from the extra row, and from a page GitHub says has more", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + searchPage([ + searchItem(1, "acme/web", "2026-07-03T00:00:00Z"), + searchItem(2, "acme/web", "2026-07-02T00:00:00Z"), + searchItem(3, "acme/web", "2026-07-01T00:00:00Z"), + ]), + ), + ) + .mockReturnValueOnce( + Effect.succeed(searchPage([searchItem(1, "acme/web", "2026-07-03T00:00:00Z")], true)), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const read = () => + cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web"], + state: "open", + involvement: "all", + viewer: "bilal", + limit: 2, + }); + + const overflowing = yield* read(); + const capped = yield* read(); + + // The extra row is the probe, and it is not handed on. + assert.strictEqual(overflowing.items.length, 2); + assert.isTrue(overflowing.truncated); + // A slice at GitHub's own ceiling has no extra row to probe with, so `hasNextPage` answers. + assert.isTrue(capped.truncated); + }), + ); + + it.effect("enriches only the visible fallback rows after filtering and widening", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(listedPullRequests(3, 1, () => ({ isDraft: true })))), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(listedPullRequests(6, 1, (number) => ({ isDraft: number < 4 })))), + ); + mockedStackMemberships.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + s0: { + pullRequest: { + stack: { number: 3, size: 2, baseRefName: "main" }, + stackEntry: { position: 1 }, + }, + }, + s1: { pullRequest: { stack: null, stackEntry: null } }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 2, + filters: { draft: "hide" }, + }); + expect(batch.items.map((item) => item.number)).toEqual([4, 5]); + expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); + expect(batch.items[1]?.stack).toBeUndefined(); + expect(batch.truncated).toBe(true); + expect(batch.continues).toBe(false); + expect(mockedStackMemberships).toHaveBeenCalledTimes(1); + const membership = mockedStackMemberships.mock.calls[0]?.[0]; + const query = membership?.kind === "graphql" ? membership.query : ""; + expect(query).toContain("pullRequest(number: 4)"); + expect(query).toContain("pullRequest(number: 5)"); + expect(query).not.toContain("pullRequest(number: 1)"); + expect(query).not.toContain("pullRequest(number: 6)"); + }), + ); + + it.effect("batches membership reads and keeps successful rows when one chunk fails", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1)))); + mockedStackMemberships.mockImplementation((input) => + input.kind === "graphql" && input.query.includes("pullRequest(number: 26)") + ? Effect.fail( + new GitHubApi.GitHubApiResponseError({ + host: "github.com", + operation: "listPullRequestStackMemberships", + status: 502, + }), + ) + : Effect.succeed( + output( + encodeJson({ + data: { + s0: { + pullRequest: { + stack: { number: 3, size: 2, baseRefName: "main" }, + stackEntry: { position: 1 }, + }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 26, + }); + expect(batch.items.map((item) => item.number)).toEqual( + Array.from({ length: 26 }, (_, i) => i + 1), + ); + expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); + expect(batch.items[25]?.stack).toBeUndefined(); + expect(batch.truncated).toBe(true); + expect(batch.continues).toBe(true); + expect(mockedStackMemberships).toHaveBeenCalledTimes(2); + }), + ); + + it.effect("skips membership enrichment for empty pages and enterprise hosts", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); + mockedExecute.mockReturnValueOnce(Effect.succeed(emptyList())); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7)))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const input = { + cwd: "/w", + repository: "acme/web", + state: "open" as const, + involvement: "all" as const, + viewer: "bilal", + limit: 2, + }; + const empty = yield* cli.listPullRequests({ ...input, host: "github.com" }); + const enterprise = yield* cli.listPullRequests({ ...input, host: "github.acme.test" }); + expect(empty.items).toEqual([]); + expect(enterprise.items.map((item) => item.number)).toEqual([7]); + expect(mockedStackMemberships).not.toHaveBeenCalled(); + }), + ); + + it.effect("reads the line counts in chunks, and files them back by position", () => + Effect.gen(function* () { + const changeRequests = Array.from({ length: 26 }, (_, index) => ({ + repository: "acme/web", + number: index + 1, + })); + mockedExecute.mockImplementation(() => + // Every chunk answers for its first alias only, so a row GitHub said nothing about is + // dropped rather than shown as a change of no size. + Effect.succeed( + output(encodeJson({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const stats = yield* cli.listPullRequestStats({ + cwd: "/w", + host: "github.com", + changeRequests, + }); + + // Twenty-five aliases a request, so twenty-six rows are two requests. + assert.strictEqual(mockedExecute.mock.calls.length, 2); + assert.deepStrictEqual(stats, [ + { repository: "acme/web", number: 1, additions: 4, deletions: 1 }, + { repository: "acme/web", number: 26, additions: 4, deletions: 1 }, + ]); + const document = queryAt(0); + expect(document).toContain('s0: repository(owner: "acme", name: "web")'); + expect(document).toContain("pullRequest(number: 25)"); + }), + ); + + it.effect("refuses to look up counts for a repository GitHub cannot address", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const failure = yield* Effect.flip( + cli.listPullRequestStats({ + cwd: "/w", + host: "github.com", + changeRequests: [{ repository: 'acme/web") { x } #', number: 1 }], + }), + ); + + assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("hands a search to GitHub rather than to the rows already read", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: "pull requests page", + }); + + // The recency qualifier rides along, because free text would otherwise reorder the page + // by relevance and truncation would drop the newest matches. + expect(searchOfCall(0)).toBe( + 'is:pr is:open "pull requests page" sort:updated-desc repo:acme/web', + ); + }), + ); + + it.effect("joins a search onto the tab's own qualifiers instead of replacing them", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "closed", + involvement: "reviewing", + viewer: "bilal", + limit: 10, + query: "page", + }); + + expect(searchOfCall(0)).toBe( + 'is:pr is:closed is:unmerged review-requested:bilal "page" sort:updated-desc repo:acme/web', + ); + }), + ); + + it.effect("carries the further narrowings into the search as qualifiers", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + filters: { + draft: "hide", + review: "changes-requested", + checks: "failing", + labels: [["needs design"], ['quo"te']], + excludedLabels: ["wip"], + author: "octocat", + }, + }); + + // Quotes around anything a reader typed, and the one character that could end a quoted + // value early dropped rather than escaped. + expect(searchOfCall(0)).toBe( + 'is:pr is:open label:"needs design" label:"quote" -label:"wip" author:"octocat" ' + + "draft:false review:changes_requested status:failure sort:updated-desc repo:acme/web", + ); + }), + ); + + it.effect('resolves an author filter of "me" to the viewer, not the literal word', () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + filters: { author: "me" }, + }); + + expect(searchOfCall(0)).toBe('is:pr is:open author:"bilal" sort:updated-desc repo:acme/web'); + }), + ); + + it.effect("sends one label qualifier per group, its names joined the way GitHub ors them", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + filters: { labels: [["size:S", "size:XS"], ["bug"]] }, + }); + + // One qualifier satisfied by either size, and a second one that must hold as well. + expect(searchOfCall(0)).toBe( + 'is:pr is:open label:"size:S","size:XS" label:"bug" sort:updated-desc repo:acme/web', + ); + }), + ); + + it.effect( + "falls back for a repository the index does not cover under a checks filter, keeping only the matching rows", + () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + listedPullRequests(2, 1, (number) => ({ + checks: number === 1 ? "SUCCESS" : "FAILURE", + })), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + filters: { checks: "passing" }, + }); + + // The fallback's rows carry `checksState` exactly as a search's rows do, so `checks` is + // now a filter the fallback judges itself, the same as `draft`: an empty search answer + // under it is still ambiguous, and the row picked out afterwards is the one whose own + // `checksState` reads "passing". + expect(searchOfCall(1)).toBeUndefined(); + assert.deepStrictEqual( + batch.items.map((item) => item.number), + [1], + ); + }), + ); + + it.effect("fails a checks filter for a row whose checks are still pending", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(listedPullRequests(1, 1, () => ({ checks: "PENDING" })))), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + filters: { checks: "passing" }, + }); + + // Pending equals neither "passing" nor "failing", so it satisfies neither filter value — + // the same row would also be dropped by `checks: "failing"`. + assert.deepStrictEqual(batch.items, []); + }), + ); + + it.effect( + "falls back for a repository the index does not cover even under a judgeable filter", + () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(listedPullRequests(2, 1, (number) => ({ isDraft: number === 1 })))), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + filters: { draft: "hide" }, + }); + + // `draft` is a filter the fallback can judge over its own rows just as search judges it, + // so an empty search answer under it alone is still ambiguous between "nothing matches" + // and "this repository is not indexed" — and the fallback applies the filter itself, + // keeping only the non-draft row. + expect(searchOfCall(1)).toBeUndefined(); + expect(batch.items.map((item) => item.number)).toEqual([2]); + }), + ); + + it.effect("carries the further narrowings into a batched search", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web"], + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + filters: { draft: "only", review: "none", labels: [["bug"]] }, + }); + + assert.strictEqual( + searchQueryOfCall(0), + 'is:pr is:open label:"bug" draft:true review:none sort:updated-desc repo:acme/web', + ); + }), + ); + + it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: '-- is:merged label:secret "widen me"', + }); + + // Every word stays inside one phrase: nothing before it, nothing after it, and the + // leading dashes are text rather than the start of another argument. + expect(searchOfCall(0)).toBe( + String.raw`is:pr is:open "-- is:merged label:secret \"widen me\"" sort:updated-desc repo:acme/web`, + ); + }), + ); + + it.effect("escapes a backslash before the quote it would otherwise let out", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: String.raw`a\" is:merged`, + }); + + // GitHub reads `\\` as one backslash and `\"` as one quote, so the phrase ends where + // this says it does; escaping the quote alone would have closed it early. + expect(searchOfCall(0)).toBe( + String.raw`is:pr is:open "a\\\" is:merged" sort:updated-desc repo:acme/web`, + ); + }), + ); + + it.effect("asks for nothing but the order when the reader typed only spaces", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: " ", + }); + + // An empty phrase would match nothing rather than everything, so it is left out; the + // order the page reads rows in is asked for whether or not anything was typed. + expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); + }), + ); + + it.effect("carries on from the instant the last slice ended on", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output(pullRequests(3, 1)))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, + }); + + // Inclusive, so the rows already sent at that instant come back for the caller to drop — + // which is what keeps the ones beside them from being skipped. + expect(searchOfCall(0)).toBe( + "is:pr is:open updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web", + ); + assert.isTrue(batch.continues); + }), + ); + + it.effect("answers a search that found nothing with nothing, not with the whole repository", () => + Effect.gen(function* () { + // The fallback is for a repository the index does not cover. Under a text search an empty + // answer means the text matched nothing, and listing everything instead would fill the + // page with rows the reader did not search for. + mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: "fdsfklj", + }); + + assert.strictEqual(batch.items.length, 0); + assert.strictEqual(mockedExecute.mock.calls.length, 1); + }), + ); + + it.effect("reads a repository GitHub will not search from its own list", () => + Effect.gen(function* () { + // GitHub answers for a repository outside its search index with no rows and no error. + mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(listedPullRequests(3, 1, () => ({ state: "CLOSED" })))), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "closed", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 3); + // The fallback itself uses no search, then narrows the decoded rows locally. They arrive + // newest-created first, so nothing can carry on from them. + expect(searchOfCall(1)).toBeUndefined(); + expect(varsAt(1)).toMatchObject({ owner: "acme", name: "web", states: ["CLOSED", "MERGED"] }); + assert.isFalse(batch.continues); + }), + ); + + it.effect("keeps state and involvement filters on the search-free fallback", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + listedPullRequests(4, 1, (number) => ({ + state: number === 4 ? "OPEN" : "CLOSED", + ...(number === 3 ? { mergedAt: "2026-07-03T00:00:00Z" } : {}), + reviewRequests: + number === 2 ? [{ slug: "platform", name: "Platform" }] : [{ login: "bilal" }], + })), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "closed", + involvement: "reviewing", + viewer: "bilal", + limit: 10, + }); + + // Individual requests for this viewer and team requests survive. The fallback cannot + // resolve team membership, so dropping team-routed reviews would hide legitimate work. + expect(batch.items.map((item) => item.number)).toEqual([1, 2]); + expect(searchOfCall(1)).toBeUndefined(); + assert.isFalse(batch.continues); + }), + ); + + it.effect("grows the search-free fallback until it fills the filtered page", () => + Effect.gen(function* () { + const unrelated = () => ({ reviewRequests: [{ login: "somebody-else" }] }); + mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(listedPullRequests(3, 1, unrelated))), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + listedPullRequests(4, 1, (number) => + number === 4 ? { reviewRequests: [{ login: "bilal" }] } : unrelated(), + ), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "reviewing", + viewer: "bilal", + limit: 2, + }); + + expect(batch.items.map((item) => item.number)).toEqual([4]); + expect(queryAt(1)).toContain("first: 3,"); + expect(queryAt(2)).toContain("first: 6,"); + assert.isFalse(batch.truncated); + }), + ); + + it.effect("bounds a sparse search-free fallback and reports the unread tail", () => + Effect.gen(function* () { + // A repository with far more pull requests than the bound, none of them for this reader. + let listed = 0; + mockedExecute.mockImplementation((call) => { + if (call.kind !== "graphql" || !call.query.includes("pullRequests(")) { + return Effect.succeed(emptySearch()); + } + const first = Number(/first: (\d+)/.exec(call.query)?.[1]); + const nodes = rows(first, listed + 1, () => ({ + reviewRequests: [{ login: "somebody-else" }], + })); + listed += first; + return Effect.succeed( + output( + encodeJson({ + data: { + repository: { + pullRequests: { pageInfo: { hasNextPage: true, endCursor: `c${listed}` }, nodes }, + }, + }, + }), + ), + ); + }); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "reviewing", + viewer: "bilal", + limit: 2, + }); + + // The last widening reads a thousand rows, a page of a hundred at a time, and stops there. + const last = mockedExecute.mock.calls.length - 1; + expect(varsAt(last)["after"]).toBe(`c${listed - 100}`); + expect(listed - (3 + 6 + 12 + 24 + 48 + 96 + 192 + 384 + 768)).toBe(1000); + assert.strictEqual(batch.items.length, 0); + assert.isTrue(batch.truncated); + }), + ); + + it.effect("takes an empty slice for a repository that has run out, not one to read again", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, + }); + + // A repository that answered the search once answers it again, so an empty slice under a + // cursor is the end of it rather than a repository search cannot reach. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + }), + ); + + /** The state a merge or branch update reads first: behind its base and blocked on checks. */ + const actionState = (pullRequest: Readonly> = {}) => ({ + data: { + repository: { + pullRequest: { + id: "PR_7", + headRefOid: "abc123", + isMergeQueueEnabled: false, + mergeStateStatus: "BLOCKED", + baseRef: { compare: { behindBy: 2 } }, + ...pullRequest, + }, + }, + }, + }); + const mergeMessage = (body: string, isMergeQueueEnabled = false) => ({ + data: { + repository: { + pullRequest: { isMergeQueueEnabled, headRefOid: "abc123", viewerMergeBodyText: body }, + }, + }, + }); + + it.effect("updates a stale branch with a merge commit unless asked to rebase", () => + Effect.gen(function* () { + route(["query PullRequestActionState", actionState()]); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "update-branch", + }); + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "update-branch", + updateMethod: "rebase", + }); + + // GitHub's own default: a merge commit unless asked to rebase, pinned to the head it read. + expect(variablesOf("updatePullRequestBranch(")).toEqual([ + { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "MERGE" }, + { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "REBASE" }, + ]); + }), + ); + + it.effect("merges with the strategy it was asked for", () => + Effect.gen(function* () { + route(["query PullRequestActionState", actionState()]); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "merge", + mergeMethod: "squash", + }); + + expect(variablesOf("mergePullRequest(")).toEqual([ + { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, + ]); + expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); + }), + ); + + it.effect.each(["merge", "enable-auto-merge"] as const)( + "removes agent credits from the proposed message for %s", + (action) => + Effect.gen(function* () { + route( + [ + "query PullRequestMergeMessage", + mergeMessage( + "Details\n\nCo-authored-by: Alice \nCo-authored-by: Claude ", + ), + ], + ["query PullRequestActionState", actionState()], + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action, + mergeMethod: "squash", + removeAgentCreditsOnMerge: true, + }); + expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("SQUASH"); + expect( + variablesOf(action === "merge" ? "mergePullRequest(" : "enablePullRequestAutoMerge("), + ).toEqual([ + { + input: { + pullRequestId: "PR_7", + mergeMethod: "SQUASH", + // Pinned to the head the message was read from, so it cannot describe other commits. + expectedHeadOid: "abc123", + commitBody: "Details\n\nCo-authored-by: Alice ", + }, + }, + ]); + }), + ); + + it.effect.each([ + { + description: "an unchanged message", + body: "Details\n\nCo-authored-by: Alice ", + queued: false, + }, + { + description: "a merge queue", + body: "Co-authored-by: Claude ", + queued: true, + }, + ] as const)("keeps GitHub's default message for $description", ({ body, queued }) => + Effect.gen(function* () { + route( + ["query PullRequestMergeMessage", mergeMessage(body, queued)], + ["query PullRequestActionState", actionState({ isMergeQueueEnabled: queued })], + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "merge", + removeAgentCreditsOnMerge: true, + }); + expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("MERGE"); + // A merge queue takes the pull request through auto-merge, with its own message. + expect(variablesOf(queued ? "enablePullRequestAutoMerge(" : "mergePullRequest(")).toEqual([ + { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, + ]); + }), + ); + + it.effect("passes an explicitly empty body when the proposed message only credits an agent", () => + Effect.gen(function* () { + route( + [ + "query PullRequestMergeMessage", + mergeMessage("Co-authored-by: Claude "), + ], + ["query PullRequestActionState", actionState()], + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "merge", + removeAgentCreditsOnMerge: true, + }); + expect(variablesOf("mergePullRequest(")).toEqual([ + { + input: { + pullRequestId: "PR_7", + mergeMethod: "MERGE", + expectedHeadOid: "abc123", + commitBody: "", + }, + }, + ]); + }), + ); + + it.effect("does not fetch a message for rebase merges", () => + Effect.gen(function* () { + route(["query PullRequestActionState", actionState()]); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "merge", + mergeMethod: "rebase", + removeAgentCreditsOnMerge: true, + }); + expect(variablesOf("query PullRequestMergeMessage")).toEqual([]); + expect(variablesOf("mergePullRequest(")).toEqual([ + { input: { pullRequestId: "PR_7", mergeMethod: "REBASE" } }, + ]); + }), + ); + + it.effect("refuses to merge when the proposed message cannot be read", () => + Effect.gen(function* () { + route( + ["query PullRequestMergeMessage", { data: { repository: { pullRequest: null } } }], + ["query PullRequestActionState", actionState()], + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const result = yield* Effect.result( + cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "merge", + removeAgentCreditsOnMerge: true, + }), + ); + expect(result._tag).toBe("Failure"); + expect(variablesOf("mergePullRequest(")).toEqual([]); + }), + ); + + it.effect("arms auto-merge with the same strategy a merge would have used", () => + Effect.gen(function* () { + route(["query PullRequestActionState", actionState()]); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "enable-auto-merge", + mergeMethod: "squash", + }); + // No strategy asked for is GitHub's own default, exactly as it is for a merge now. + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "enable-auto-merge", + }); + + expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([ + { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, + { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, + ]); + expect(variablesOf("mergePullRequest(")).toEqual([]); + }), + ); + + it.effect("merges at once when auto-merge is asked of a pull request that is ready now", () => + Effect.gen(function* () { + route(["query PullRequestActionState", actionState({ mergeStateStatus: "CLEAN" })]); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "enable-auto-merge", + mergeMethod: "squash", + }); + + expect(variablesOf("mergePullRequest(")).toEqual([ + { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, + ]); + expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); + }), + ); + + it.effect("takes auto-merge back off without naming a strategy", () => + Effect.gen(function* () { + route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "disable-auto-merge", + mergeMethod: "squash", + }); + + expect(variablesOf("disablePullRequestAutoMerge(")).toEqual([{ pullRequestId: "PR_7" }]); + }), + ); + + it.effect("opens a pull request that reverts a merged pull request", () => + Effect.gen(function* () { + route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "revert", + }); + + expect(variablesOf("revertPullRequest(")).toEqual([{ pullRequestId: "PR_7" }]); + }), + ); + + it.effect("does not approve action-required runs for a same-repository pull request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson( + coreResponse({ + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + headRefName: "feat/page", + headRefOid: "abc123", + isCrossRepository: false, + headRepositoryOwner: { login: "acme" }, + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + }), + ), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "approve-workflows", + }); + + expect(mockedExecute).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("finds and approves every workflow waiting on a maintainer", () => + Effect.gen(function* () { + workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([10, 11])); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "approve-workflows", + }); + + // Open pull requests on this head branch, and runs scoped to this exact head. + expect(variablesOf("headRefName: $head")[0]).toEqual({ + owner: "acme", + name: "web", + head: "feat/page", + after: null, + }); + expect(restCallsTo("actions/runs?")[0]?.path).toBe( + "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fpage&event=pull_request&status=action_required&per_page=100&page=1", + ); + // Each run is approved only after the head is read again and still lists it. + expect(restCallsTo("/approve").map((call) => [call.method, call.path])).toEqual([ + ["POST", "repos/acme/web/actions/runs/10/approve"], + ["POST", "repos/acme/web/actions/runs/11/approve"], + ]); + assert.strictEqual(restCallsTo("actions/runs?").length, 3); + }), + ); + + it.effect("counts revalidated runs that wait on a maintainer as GitHub reports them", () => + Effect.gen(function* () { + workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([])); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const runs = yield* cli + .listWorkflowRunsRequiringApproval({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + headSha: "abc123", + headBranch: "feat/page", + headRepositoryOwner: "octocat", + isCrossRepository: true, + }) + .pipe( + Effect.provideService(KnownWorkflowRuns, { + headSha: "abc123", + runs: [ + // Live shape: waiting on approval is completed + action_required. + { + id: 10, + status: "completed", + conclusion: "action_required", + head_branch: "feat/page", + }, + { id: 11, status: "completed", conclusion: "success", head_branch: "feat/page" }, + ], + }), + ); + + expect(runs.map((run) => run.id)).toEqual([10]); + assert.strictEqual(restCallsTo("actions/runs?").length, 0); + }), + ); + + it.effect("refuses a stale workflow approval after the pull request head changes", () => + Effect.gen(function* () { + let detailReads = 0; + workflowApprovalRoutes( + () => crossRepositoryDetail(++detailReads === 1 ? "abc123" : "def456"), + heads([7]), + workflowRuns([10]), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "approve-workflows", + }), + ); + + expect(error).toMatchObject({ + _tag: "GitHubWorkflowApprovalHeadChangedError", + number: 7, + }); + assert.strictEqual(detailReads, 2); + expect(restCallsTo("/approve")).toEqual([]); + }), + ); + + it.effect("reads workflow runs and their pull request scope concurrently", () => + Effect.gen(function* () { + const headsStarted = yield* Deferred.make(); + const runsStarted = yield* Deferred.make(); + mockedExecute.mockImplementation((call) => + call.kind === "graphql" + ? Deferred.succeed(headsStarted, undefined).pipe( + Effect.andThen(Deferred.await(runsStarted)), + Effect.as(output(encodeJson(heads([7])))), + ) + : Deferred.succeed(runsStarted, undefined).pipe( + Effect.andThen(Deferred.await(headsStarted)), + Effect.as(output(encodeJson(workflowRuns([10])))), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const runs = yield* cli.listWorkflowRunsRequiringApproval({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + headSha: "abc123", + headBranch: "feat/page", + headRepositoryOwner: "octocat", + isCrossRepository: true, + }); + + expect(runs).toEqual([{ id: 10, name: "build", url: "https://example.com/10" }]); + }), + ); + + it.effect("refuses workflow approval when one head belongs to several pull requests", () => + Effect.gen(function* () { + workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7, 8]), workflowRuns([])); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.listWorkflowRunsRequiringApproval({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + headSha: "abc123", + headBranch: "feat/page", + headRepositoryOwner: "octocat", + isCrossRepository: true, + }), + ); + + expect(error).toMatchObject({ + _tag: "GitHubWorkflowApprovalRefusedError", + reason: "head-not-unique", + number: 7, + observedCount: 2, + limit: 1_000, + }); + expect(error.message).toContain("instead of uniquely matching #7"); + }), + ); + + it.effect("refuses workflow approval when GitHub omits the head repository", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson( + coreResponse({ + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + headRefName: "feat/page", + headRefOid: "abc123", + isCrossRepository: true, + headRepositoryOwner: null, + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + }), + ), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "approve-workflows", + }), + ); + + expect(error).toMatchObject({ + _tag: "GitHubWorkflowApprovalHeadUnavailableError", + number: 7, + }); + expect(mockedExecute).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("surfaces a workflow run list beyond the safe approval bound", () => + Effect.gen(function* () { + let runPages = 0; + mockedExecute.mockImplementation((call) => { + if (call.kind === "graphql") return Effect.succeed(output(encodeJson(heads([7])))); + const page = runPages++; + return Effect.succeed( + output( + encodeJson( + workflowRuns(Array.from({ length: 100 }, (_, index) => page * 100 + index + 1)), + ), + ), + ); + }); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.listWorkflowRunsRequiringApproval({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + headSha: "abc123", + headBranch: "feat/page", + headRepositoryOwner: "octocat", + isCrossRepository: true, + }), + ); + + // Paged a hundred at a time, it stops at the first page past the bound. + expect(error).toMatchObject({ + _tag: "GitHubWorkflowApprovalRefusedError", + reason: "run-list-truncated", + number: 7, + observedCount: 1_100, + limit: 1_000, + }); + expect(error.message).toContain("more than 1000 workflow runs"); + assert.strictEqual(runPages, 11); + }), + ); + + it.effect("returns a pull request to draft by converting it", () => + Effect.gen(function* () { + route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "draft", + }); + + expect(variablesOf("convertPullRequestToDraft(")).toEqual([{ pullRequestId: "PR_7" }]); + expect(variablesOf("markPullRequestReadyForReview(")).toEqual([]); + }), + ); + + it.effect("sends a comment body as a variable, never inside the document", () => + Effect.gen(function* () { + route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.commentOnPullRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + body: "Looks good.", + }); + + expect(variablesOf("addComment(")).toEqual([{ subjectId: "PR_7", body: "Looks good." }]); + expect(queryAt(mockedExecute.mock.calls.length - 1)).not.toContain("Looks good."); + }), + ); + + it.effect("names the host on every repository it addresses", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.acme.dev", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + // A request sent to github.com would read a different repository of the same name. + assert.isAbove(mockedExecute.mock.calls.length, 0); + expect(new Set(mockedExecute.mock.calls.map(([call]) => call.host))).toEqual( + new Set(["github.acme.dev"]), + ); + expect(searchOfCall(0)).toContain("repo:acme/web"); + }), + ); + + it.effect("asks a GitHub Enterprise host for its own review threads", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + repository: { pullRequest: { reviewThreads: { totalCount: 0, nodes: [] } } }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.listReviewThreadComments({ + cwd: "/w", + repository: "acme/web", + host: "github.acme.dev", + number: 7, + }); + + expect(callAt(0).host).toBe("github.acme.dev"); + expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); + }), + ); + + it.effect("serves a diff GitHub hands over whole in one request, with no next slice", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("diff --git a/a b/a"))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const diff = yield* cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.strictEqual(diff.patch, "diff --git a/a b/a"); + assert.isNull(diff.nextCursor); + assert.isFalse(diff.truncated); + // The common case pays for one request and not the files API on top of it. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + // GitHub's combined pull-request diff: one section per changed file, not one per commit. + const call = callAt(0); + assert.strictEqual(call.kind, "rest"); + if (call.kind === "rest") { + assert.strictEqual(call.path, "repos/acme/web/pulls/7"); + assert.strictEqual(call.accept, "application/vnd.github.diff"); + } + }), + ); + + it.effect("reads one files page when GitHub refuses the diff, and says it is the last", () => + Effect.gen(function* () { + // GitHub answers 406 rather than a diff past 300 changed files. + mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1)))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const diff = yield* cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.acme.dev", + number: 7, + }); + + assert.isFalse(diff.truncated); + // A short page is the end of the change set, so there is nothing to carry on from. + assert.isNull(diff.nextCursor); + expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); + expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts"); + assert.strictEqual(callAt(1).host, "github.acme.dev"); + assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); + }), + ); + + it.effect("hands back a cursor for the next page rather than walking on by itself", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const diff = yield* cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + // A full page means more files, which the reader asks for; it is not a truncated slice. + assert.isFalse(diff.truncated); + assert.isNotNull(diff.nextCursor); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + }), + ); + + it.effect("carries on from a cursor without asking for the whole diff again", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const target = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }; + + const first = yield* cli.getPullRequestDiff(target); + assert.isNotNull(first.nextCursor); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(4, 100)))); + const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); + + assert.isNull(second.nextCursor); + expect(second.patch).toContain("diff --git a/src/file100.ts b/src/file100.ts"); + // The second slice is one request: the cursor already says where to read. + assert.strictEqual(mockedExecute.mock.calls.length, 3); + assert.strictEqual(pathAt(2), "repos/acme/web/pulls/7/files?per_page=100&page=2"); + }), + ); + + it.effect("refuses a cursor it never handed out rather than reading it into a request", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + cursor: "1&per_page=1", + }), + ); + + assert.strictEqual(error._tag, "GitHubDiffCursorError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("reads a named commit from the commit endpoint rather than the whole diff", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(`{"files":${pullRequestFiles(2, 1)}}`)), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const diff = yield* cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + }); + + // One request: the commit's own changes never take the whole-diff road. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + assert.isNull(diff.nextCursor); + // The commit endpoint wraps its files in an object, which the decoder unwraps. + expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); + assert.strictEqual( + pathAt(0), + "repos/acme/web/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0?per_page=100&page=1", + ); + }), + ); + + it.effect("pages inside a commit the way it pages the pull request's own files", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(`{"files":${pullRequestFiles(100, 0)}}`)), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const target = { + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commit: "a1b2c3d", + }; + + const first = yield* cli.getPullRequestDiff(target); + assert.isNotNull(first.nextCursor); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(`{"files":${pullRequestFiles(4, 100)}}`)), + ); + const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); + + assert.isNull(second.nextCursor); + expect(second.patch).toContain("src/file100.ts"); + assert.strictEqual(pathAt(1), "repos/acme/web/commits/a1b2c3d?per_page=100&page=2"); + }), + ); + + it.effect("refuses a commit that is not a sha rather than reading it into a request", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commit: "../../pulls/8/files", + }), + ); + + assert.strictEqual(error._tag, "GitHubDiffCommitError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("expands a new file from a root commit without requiring a parent", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(encodeJson({ sha: "a1b2c3d", parents: [] }))), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n"))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const contents = yield* cli.getPullRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commit: "a1b2c3d", + changeType: "new", + oldPath: "src/root.ts", + newPath: "src/root.ts", + }); + + expect(contents).toEqual({ oldContents: "", newContents: "root contents\n" }); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + assert.strictEqual(pathAt(0), "repos/acme/web/commits/a1b2c3d"); + assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/root.ts?ref=a1b2c3d"); + }), + ); + + it.effect("reports unusable diff revisions as a structured error", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("not-a-sha\tstill-not-a-sha\n"))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.getPullRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commit: "a1b2c3d", + changeType: "change", + oldPath: "src/a.ts", + newPath: "src/a.ts", + }), + ); + + assert.strictEqual(error._tag, "GitHubDiffRevisionsUnavailableError"); + if (error._tag === "GitHubDiffRevisionsUnavailableError") { + assert.strictEqual(error.number, 7); + assert.strictEqual(error.commit, "a1b2c3d"); + } + }), + ); + + it.effect("reports an oversized diff file with its path and reason", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.getPullRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + changeType: "deleted", + oldPath: "src/large.ts", + newPath: "src/large.ts", + }), + ); + + assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError"); + if (error._tag === "GitHubDiffFileContentsUnavailableError") { + assert.strictEqual(error.path, "src/large.ts"); + assert.strictEqual(error.reason, "oversized"); + } + // A deleted file is read at the base revision only. + assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/large.ts?ref=a1b2c3d"); + }), + ); + + it.effect("reports undecodable diff file contents as binary", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("binary�contents", false, true))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.getPullRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + changeType: "deleted", + oldPath: "assets/logo.png", + newPath: "assets/logo.png", + }), + ); + + assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError"); + if (error._tag === "GitHubDiffFileContentsUnavailableError") { + assert.strictEqual(error.path, "assets/logo.png"); + assert.strictEqual(error.reason, "binary"); + } + }), + ); + + it.effect("returns valid text containing a literal replacement character", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("before�after"))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const contents = yield* cli.getPullRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + changeType: "deleted", + oldPath: "docs/encoding.md", + newPath: "docs/encoding.md", + }); + + assert.strictEqual(contents.oldContents, "before�after"); + }), + ); + + it.effect("ends the diff on a page with no files rather than asking for it again", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const diff = yield* cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + cursor: "4", + }); + + assert.strictEqual(diff.patch, ""); + assert.isNull(diff.nextCursor); + }), + ); + + it.effect("reports the refused diff when the files API cannot answer either", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("not json"))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + + assert.strictEqual(error, diffRefused); + }), + ); + + it.effect("skips the avatar lookup when a listing named nobody", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const avatars = yield* cli.listActorAvatars({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + ids: [], + }); + + assert.strictEqual(avatars.size, 0); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("accounts for the avatar lookup in the GraphQL budget", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }], + rateLimit: { + cost: 1, + limit: 5_000, + remaining: 4_999, + resetAt: "2099-08-13T14:00:00Z", + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const avatars = yield* cli.listActorAvatars({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + ids: ["MDQ6VXNlcjE="], + }); + + expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] }); + expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); + expect(avatars.get("octocat")).toBe("https://avatars/octocat"); + }), + ); + + it.effect("fails when the authenticated account has no login", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(" "))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w", host: "github.com" })); + + assert.strictEqual(error._tag, "GitHubViewerLoginUnavailableError"); + }), + ); + + it.effect("looks up the authenticated account on the requested enterprise host", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed(output('{"id":456,"login":"enterprise-user"}')), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" }); + + expect(login).toBe("enterprise-user"); + expect(mockedCredential).toHaveBeenCalledWith("github.acme.com"); + expect(callAt(0)).toMatchObject({ kind: "rest", host: "github.acme.com", path: "user" }); + }), + ); + + it.effect("reuses verified credentials offline and refuses an unverified replacement", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const input = { cwd: "/w", host: "github.identity-cache.test" }; + mockedCredential.mockImplementation((host) => + Effect.succeed({ token: Redacted.make("test-credential-a"), fingerprint: `${host}:a` }), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}'))); + expect(yield* cli.getRoutingIdentity(input)).toEqual({ + accountId: "123", + viewer: "maria-rcks", + }); + + // The same credential again is answered from what was verified, without asking GitHub. + expect(yield* cli.getRoutingIdentity(input)).toEqual({ + accountId: "123", + viewer: "maria-rcks", + }); + expect(mockedExecute).toHaveBeenCalledTimes(1); + + // A switched credential is not trusted on the strength of the old one's answer. + mockedCredential.mockImplementation((host) => + Effect.succeed({ token: Redacted.make("test-credential-b"), fingerprint: `${host}:b` }), + ); + mockedExecute.mockReturnValueOnce( + Effect.fail( + new GitHubApi.GitHubApiResponseError({ + host: "github.com", + operation: "getRoutingIdentity", + status: 502, + }), + ), + ); + // GitHub's own refusal is reported as itself, so the page can say what went wrong. + const failure = yield* cli.getRoutingIdentity(input).pipe(Effect.flip); + expect(failure._tag).toBe("GitHubApiResponseError"); + expect(String(failure)).not.toContain("test-credential-b"); + expect(mockedExecute).toHaveBeenCalledTimes(2); + + mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}'))); + expect(yield* cli.getRoutingIdentity(input)).toEqual({ + accountId: "456", + viewer: "maria-rcks", + }); + }), + ); + + it.effect("sends a whole review as one request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.submitReview({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + verdict: "approve", + body: "Looks right.", + comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], + }); + + // One request, so nothing is on the pull request until the verdict is. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(restCallsTo("/reviews")).toMatchObject([ + { + method: "POST", + path: "repos/acme/web/pulls/7/reviews", + body: { + event: "APPROVE", + body: "Looks right.", + comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }], + }, + }, + ]); + }), + ); + + it.effect("sends a reply body as a variable, never inside the document", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.replyToReviewThread({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + threadId: "PRRT_1", + body: "Fixed in 42ff8ec.", + }); + + expect(variablesOf("addPullRequestReviewThreadReply(")).toEqual([ + { threadId: "PRRT_1", body: "Fixed in 42ff8ec." }, + ]); + expect(queryAt(0)).not.toContain("Fixed in 42ff8ec."); + }), + ); + + it.effect("resolves and unresolves through the mutation each one needs", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.setReviewThreadResolution({ + cwd: "/w", + repository: "acme/web", + host: "github.acme.dev", + threadId: "PRRT_1", + resolved: true, + }); + yield* cli.setReviewThreadResolution({ + cwd: "/w", + repository: "acme/web", + host: "github.acme.dev", + threadId: "PRRT_1", + resolved: false, + }); + + expect(queryAt(0)).toContain("resolveReviewThread("); + expect(queryAt(0)).not.toContain("unresolveReviewThread("); + expect(queryAt(1)).toContain("unresolveReviewThread("); + expect([varsAt(0), varsAt(1)]).toEqual([{ threadId: "PRRT_1" }, { threadId: "PRRT_1" }]); + // A GitHub Enterprise thread is resolved on its own host, not on github.com. + expect([callAt(0).host, callAt(1).host]).toEqual(["github.acme.dev", "github.acme.dev"]); + }), + ); + + it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () => + Effect.gen(function* () { + route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.setReaction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + subjectId: "IC_1", + content: "heart", + reacted: true, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 2); + expect(variablesOf(SUBJECT_SCOPE_QUERY)).toEqual([ + { owner: "acme", name: "web", number: 7, subjectId: "IC_1" }, + ]); + expect(variablesOf("addReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); + }), + ); + + it.effect("refuses a given subject that belongs to a different pull request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + repository: { pullRequest: { id: "PR_thisOne" } }, + // A comment on pull request #99 of a different repository, named as though it + // belonged to #7 here. + node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.setReaction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + subjectId: "IC_99", + content: "heart", + reacted: true, + }), + ); + + assert.strictEqual(error._tag, "GitHubSubjectScopeError"); + // Refused before any mutation was sent. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + }), + ); + + it.effect("looks up the pull request's own node id when no subject was given", () => + Effect.gen(function* () { + route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + // Its own pull request: a node id looked up once is remembered for the life of the service. + yield* cli.setReaction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 21, + content: "rocket", + reacted: true, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 2); + expect(variablesOf(NODE_ID_QUERY)).toEqual([{ owner: "acme", name: "web", number: 21 }]); + expect(variablesOf("addReaction(")).toEqual([{ subjectId: "PR_kwDOA", content: "ROCKET" }]); + }), + ); + + it.effect("takes a reaction back through the remove mutation", () => + Effect.gen(function* () { + route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.setReaction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + subjectId: "IC_1", + content: "heart", + reacted: false, + }); + + expect(variablesOf("removeReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); + expect(variablesOf("addReaction(")).toEqual([]); + }), + ); + + it.effect("rewrites only the words a request named", () => + Effect.gen(function* () { + route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const rewrite = (fields: { readonly title?: string; readonly body?: string }) => + cli.updatePullRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 22, + ...fields, + }); + + yield* rewrite({ title: "A better title" }); + yield* rewrite({ body: "A better description." }); + yield* rewrite({ title: "Both", body: "at once." }); + + // One node id lookup for the pull request, then a mutation per rewrite. + assert.strictEqual(variablesOf(NODE_ID_QUERY).length, 1); + expect(variablesOf("updatePullRequest(")).toEqual([ + { pullRequestId: "PR_kwDOA", title: "A better title" }, + { pullRequestId: "PR_kwDOA", body: "A better description." }, + { pullRequestId: "PR_kwDOA", title: "Both", body: "at once." }, + ]); + }), + ); + + it.effect("rewrites a remark through the mutation its kind needs", () => + Effect.gen(function* () { + route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const rewrite = (kind: "issue-comment" | "review-comment") => + cli.updateComment({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commentId: "IC_1", + kind, + body: "Reworded.", + }); + + yield* rewrite("issue-comment"); + yield* rewrite("review-comment"); + + expect(variablesOf(SUBJECT_SCOPE_QUERY).map((variables) => variables["subjectId"])).toEqual([ + "IC_1", + "IC_1", + ]); + expect(variablesOf("updateIssueComment(")).toEqual([ + { commentId: "IC_1", body: "Reworded." }, + ]); + expect(variablesOf("updatePullRequestReviewComment(")).toEqual([ + { commentId: "IC_1", body: "Reworded." }, + ]); + }), + ); + + it.effect("refuses a comment that belongs to a different pull request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + repository: { pullRequest: { id: "PR_thisOne" } }, + node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.updateComment({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commentId: "IC_99", + kind: "issue-comment", + body: "Reworded.", + }), + ); + + assert.strictEqual(error._tag, "GitHubSubjectScopeError"); + if (error._tag === "GitHubSubjectScopeError") + assert.strictEqual(error.operation, "updateComment"); + // Refused before any mutation was sent. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + }), + ); + + it.effect("fails the read when gh returns something unreadable", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.getPullRequestDetail({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + + assert.strictEqual(error._tag, "GitHubPullRequestReadError"); + }), + ); + + it.effect("keeps the core detail read separate from conversation activity", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson( + coreResponse({ + title: "Progressive detail", + author: { login: "octocat" }, + body: "Core body", + changedFiles: 2, + }), + ), + ), + ), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + repository: { + pullRequest: { + author: { __typename: "User", login: "octocat", avatarUrl: "https://a/o" }, + commits: { nodes: [] }, + comments: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + reviews: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const input = { + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + } as const; + + const detail = yield* cli.getPullRequestDetail(input); + const activity = yield* cli.getPullRequestActivity(input); + + expect(detail.body).toBe("Core body"); + expect(activity.author?.login).toBe("octocat"); + expect(varsAt(0)).toMatchObject({ number: 7, headRef: "refs/pull/7/head" }); + expect(queryAt(0)).toContain("viewerCanUpdateBranch"); + expect(queryAt(0)).not.toContain("reviews("); + expect(detail.viewerAccess.mergeCapabilities).toEqual({ + merge: true, + squash: false, + rebase: true, + }); + expect(detail.comparison).toEqual({ behindBy: 2, viewerCanUpdate: true }); + // Conversation activity is its own read, and asks for the head of the conversation once. + expect(queryAt(1)).toContain("reviews("); + expect(varsAt(1)).toMatchObject({ head: true, withComments: true, withReviews: true }); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + }), + ); + + it.effect("decodes reviewers, labels and workflow checks without another detail read", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson( + coreResponse({ + baseRef: null, + reviewRequests: { + nodes: [ + { requestedReviewer: { login: "reviewer" } }, + { requestedReviewer: { slug: "maintainers", name: "Maintainers" } }, + ], + }, + labels: { nodes: [{ name: "bug", color: "ff0000" }] }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { + nodes: [ + { + __typename: "CheckRun", + name: "build", + status: "COMPLETED", + conclusion: "SUCCESS", + checkSuite: { workflowRun: { workflow: { name: "linux" } } }, + }, + { + __typename: "CheckRun", + name: "build", + status: "COMPLETED", + conclusion: "FAILURE", + checkSuite: { workflowRun: { workflow: { name: "windows" } } }, + }, + ], + pageInfo: { hasNextPage: false }, + }, + }, + }, + }, + ], + }, + }), + ), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const detail = yield* cli.getPullRequestDetail({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + expect(mockedExecute).toHaveBeenCalledOnce(); + expect(detail.comparison).toBeNull(); + expect(detail.reviewRequestLogins).toEqual(["reviewer"]); + expect(detail.hasTeamReviewRequest).toBe(true); + expect(detail.labels).toEqual([{ name: "bug", color: "ff0000" }]); + expect(detail.checks).toHaveLength(2); + expect(detail.checksState).toBe("failing"); + }), + ); + + it.effect("reads every check when the combined response has another page", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson( + coreResponse({ + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { + nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }], + pageInfo: { hasNextPage: true, endCursor: "c1" }, + }, + }, + }, + }, + ], + }, + }), + ), + ), + ), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + checkContextsPage( + [ + { name: "first", status: "COMPLETED", conclusion: "SUCCESS" }, + { name: "last", status: "COMPLETED", conclusion: "FAILURE" }, + ], + null, + ), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const detail = yield* cli.getPullRequestDetail({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + expect(detail.checks).toHaveLength(2); + expect(detail.checksState).toBe("failing"); + expect(detail.checksTruncated).toBe(false); + // The whole rollup is walked from its start, so no check is counted twice or skipped. + expect(varsAt(1)).toMatchObject({ number: 7, after: null }); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + }), + ); + + it.effect("refuses to combine checks from different head revisions", () => + Effect.gen(function* () { + const response = coreResponse({ + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { + nodes: [], + pageInfo: { hasNextPage: true }, + }, + }, + }, + }, + ], + }, + }); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response)))); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + ...response.data.repository.pullRequest, + headRefOid: "new-head", + reviewRequests: [], + labels: [], + statusCheckRollup: [], + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const error = yield* Effect.flip( + cli.getPullRequestDetail({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + expect(error._tag).toBe("GitHubPullRequestReadError"); + }), + ); + + it.effect("preserves the reserve for automatic detail reads and allows manual checks", () => + Effect.gen(function* () { + const response = coreResponse(); + mockedExecute.mockReturnValue( + Effect.succeed( + output( + encodeJson({ + ...response, + data: { + ...response.data, + rateLimit: { + cost: 1, + limit: 5000, + remaining: 500, + resetAt: "2099-08-13T14:00:00Z", + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const input = { + cwd: "/w", + repository: "acme/web", + host: "github.core-reserve.test", + number: 7, + }; + yield* cli.getPullRequestDetail(input); + const error = yield* Effect.flip(cli.getPullRequestDetail(input)); + expect(error._tag).toBe("SourceControlRateLimitPausedError"); + expect(mockedExecute).toHaveBeenCalledOnce(); + yield* cli.getPullRequestDetail(input).pipe(Effect.provideService(AllowGitHubReserve, true)); + expect(mockedExecute).toHaveBeenCalledTimes(2); + }), + ); + + it.effect("fails a files page too large to read rather than calling the diff whole", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1), true))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + + // What matters is that it fails at all: an empty patch with no cursor would render as a + // change with no files and report the rest of it as already read. The refusal that sent + // the read down this road is the one reported, by design. + assert.strictEqual(error, diffRefused); + }), + ); + + it.effect("pages an oversized patch by file rather than handing back a severed one", () => + Effect.gen(function* () { + // The whole diff came back, but cut at a byte, which lands mid-file. + mockedExecute.mockReturnValueOnce( + Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1)))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const slice = yield* cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + // The severed patch is thrown away; what comes back is assembled from whole files. + assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); + expect(slice.patch).toContain("src/file1.ts"); + expect(slice.patch).not.toContain("diff --git a/a b/a"); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + }), + ); + + it.effect("follows the cursor to the review threads the first page left behind", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(reviewThreadsPage([thread("PRRT_1", "c1")], "Y3Vyc29yOjE"))), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(reviewThreadsPage([thread("PRRT_2", "c2")], null))), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const conversation = yield* cli.listReviewThreadComments({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + // The first page asks from the beginning; the next carries on from where it stopped. + expect(varsAt(0)["cursor"]).toBeNull(); + expect(varsAt(1)["cursor"]).toBe("Y3Vyc29yOjE"); + expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2"]); + assert.isFalse(conversation.truncated); + }), + ); + + it.effect("stops at the thread bound and says the conversation was cut short", () => + Effect.gen(function* () { + // A host that never runs out of pages: the walk has to end itself. + mockedExecute.mockReturnValue( + Effect.succeed( + output( + reviewThreadsPage( + [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }], + "Y3Vyc29yOjE", + ), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const conversation = yield* cli.listReviewThreadComments({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 10); + assert.isTrue(conversation.truncated); + assert.isTrue(conversation.reviewThreadsTruncated); + }), + ); + + it.effect("leaves a long thread paged until the reader asks for more", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + reviewThreadsPage( + [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }], + null, + ), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const conversation = yield* cli.listReviewThreadComments({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1"]); + expect(conversation.reviewThreads[0]).toMatchObject({ + commentCount: 3, + nextCommentsCursor: "Y3Vyc29yOjI", + }); + assert.isTrue(conversation.truncated); + assert.isFalse(conversation.reviewThreadsTruncated); + }), + ); + + it.effect("reads one requested page from a review thread cursor", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(threadCommentsPage(["c2", "c3"], null, 3))), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const page = yield* cli.getReviewThreadComments({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + threadId: "PRRT_1", + cursor: "Y3Vyc29yOjI", + }); + + expect(varsAt(0)).toMatchObject({ + owner: "acme", + name: "web", + number: 7, + threadId: "PRRT_1", + cursor: "Y3Vyc29yOjI", + }); + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(page.comments.map((comment) => comment.id)).toEqual(["c2", "c3"]); + expect(page.nextCursor).toBeNull(); + }), + ); + + it.effect("refuses a review thread from another pull request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(threadCommentsPage(["foreign"], null, 1, "PR_8"))), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.getReviewThreadComments({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + threadId: "PRRT_FOREIGN", + cursor: "Y3Vyc29yOjI", + }), + ); + + assert.strictEqual(error._tag, "GitHubSubjectScopeError"); + }), + ); + + it.effect( + "asks for the reader's standing on the repository and on the pull request at once", + () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + encodeJson({ + data: { + repository: { + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + viewerPermission: "READ", + pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const access = yield* cli.getViewerAccess({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + // One request, because both answers hang off the same repository object. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); + expect(queryAt(0)).toContain("mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed"); + expect(access).toEqual({ + mergeCapabilities: { merge: true, squash: false, rebase: true }, + canWrite: false, + canTriage: false, + canUpdate: true, + didAuthor: true, + }); + }), + ); + + it.effect("stops GraphQL reads at the protected reserve until reset", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + encodeJson({ + data: { + repository: { + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + viewerPermission: "READ", + pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, + }, + rateLimit: { + cost: 1, + limit: 5_000, + remaining: 500, + resetAt: "2099-08-13T14:00:00Z", + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const input = { + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + } as const; + + yield* cli.getViewerAccess(input); + expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); + + const error = yield* Effect.flip(cli.getViewerAccess(input)); + + assert.strictEqual(error._tag, "SourceControlRateLimitPausedError"); + if (error._tag !== "SourceControlRateLimitPausedError") return; + assert.strictEqual(error.host, "github.com"); + assert.strictEqual(error.retryAt, Date.parse("2099-08-13T14:00:00Z")); + assert.strictEqual(mockedExecute.mock.calls.length, 1); + yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z")); + }), + ); + + it.effect("lets an interactive permission read use the protected reserve", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + repository: { + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + viewerPermission: "READ", + pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, + }, + rateLimit: { + cost: 1, + limit: 5_000, + remaining: 500, + resetAt: "2099-08-13T14:00:00Z", + }, + }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + repository: { + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + viewerPermission: "READ", + pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.getViewerAccess({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + const access = yield* cli.getViewerAccess({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + allowReserve: true, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 2); + expect(access).toEqual({ + mergeCapabilities: { merge: true, squash: false, rebase: true }, + canWrite: false, + canTriage: false, + canUpdate: true, + didAuthor: true, + }); + yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z")); + }), + ); + + it.effect("asks GitHub to review, naming the collection a request is added to", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.setReviewerRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + reviewers: [ + { id: "octocat", kind: "user" }, + { id: "reviewers", kind: "team" }, + ], + requested: true, + }); + + expect(callAt(0)).toMatchObject({ + kind: "rest", + method: "POST", + path: "repos/acme/web/pulls/7/requested_reviewers", + body: { reviewers: ["octocat"], team_reviewers: ["reviewers"] }, + }); + }), + ); + + it.effect("takes a request back by deleting from the same collection it was added to", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.setReviewerRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + reviewers: [{ id: "octocat", kind: "user" }], + requested: false, + }); + + expect(callAt(0)).toMatchObject({ + kind: "rest", + method: "DELETE", + path: "repos/acme/web/pulls/7/requested_reviewers", + body: { reviewers: ["octocat"], team_reviewers: [] }, + }); + }), + ); + + it.effect("reads who may review and who already has in one request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + encodeJson({ + data: { + repository: { + assignableUsers: { + pageInfo: { hasNextPage: false }, + nodes: [{ login: "bilal" }, { login: "octocat" }, { login: "hubot" }], + }, + pullRequest: { + author: { login: "bilal" }, + reviewRequests: { nodes: [{ requestedReviewer: { login: "octocat" } }] }, + }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const list = yield* cli.listReviewerCandidates({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + // The people, who has been asked and who opened the pull request all hang off the same + // repository object, so the menu costs one request. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); + expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ + ["octocat", true], + ["hubot", false], + ]); + }), + ); + + it.effect("puts labels on by posting to the issue's own collection, all at once", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.setLabels({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + labels: ["bug", "size:XL"], + applied: true, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(callAt(0)).toMatchObject({ + kind: "rest", + method: "POST", + path: "repos/acme/web/issues/7/labels", + body: { labels: ["bug", "size:XL"] }, + }); + }), + ); + + it.effect("takes labels off one at a time, naming each in the path encoded", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.setLabels({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + labels: ["good first issue", "area/web"], + applied: false, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 2); + expect(callAt(0)).toMatchObject({ + method: "DELETE", + path: "repos/acme/web/issues/7/labels/good%20first%20issue", + }); + expect(callAt(1)).toMatchObject({ + method: "DELETE", + path: "repos/acme/web/issues/7/labels/area%2Fweb", + }); + }), + ); + + it.effect("reads every page of viewed files, and says so when there are too many", () => + Effect.gen(function* () { + const page = (index: number, hasNextPage: boolean) => + Effect.succeed( + output( + encodeJson({ + data: { + repository: { + pullRequest: { + files: { + pageInfo: { hasNextPage, endCursor: `cursor-${index}` }, + nodes: [ + { path: `src/file${index}.ts`, viewerViewedState: "VIEWED" }, + { path: `src/other${index}.ts`, viewerViewedState: "UNVIEWED" }, + ], + }, + }, + }, + }, + }), + ), + ); + mockedExecute + .mockReturnValueOnce(page(0, true)) + .mockReturnValueOnce(page(1, true)) + .mockReturnValueOnce(page(2, false)); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const viewed = yield* cli.getPullRequestFilesViewed({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 3); + // The first page asks from the start; each one after it carries the cursor before it. + expect([varsAt(0)["after"], varsAt(1)["after"], varsAt(2)["after"]]).toEqual([ + null, + "cursor-0", + "cursor-1", + ]); + assert.isFalse(viewed.truncated); + expect(viewed.files.map((file) => [file.path, file.state])).toEqual([ + ["src/file0.ts", "viewed"], + ["src/other0.ts", "unviewed"], + ["src/file1.ts", "viewed"], + ["src/other1.ts", "unviewed"], + ["src/file2.ts", "viewed"], + ["src/other2.ts", "unviewed"], + ]); + }), + ); + + it.effect("stops paging viewed files rather than following a change without end", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + encodeJson({ + data: { + repository: { + pullRequest: { + files: { + pageInfo: { hasNextPage: true, endCursor: "cursor" }, + nodes: [{ path: "src/file.ts", viewerViewedState: "VIEWED" }], + }, + }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const viewed = yield* cli.getPullRequestFilesViewed({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 5); + assert.isTrue(viewed.truncated); + assert.strictEqual(viewed.files.length, 5); + }), + ); + + it.effect("clears and restores a burst of files in one request", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output(encodeJson({ data: { repository: { pullRequest: { id: "PR_1" } } } })), + ), + ) + .mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.setPullRequestFilesViewed({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 23, + files: [ + { path: "src/a.ts", viewed: true }, + { path: "src/b.ts", viewed: false }, + ], + }); + + // One request to learn the pull request's node id, one for every press together. + assert.strictEqual(mockedExecute.mock.calls.length, 2); + expect(queryAt(1)).toContain("f0: markFileAsViewed"); + expect(queryAt(1)).toContain("f1: unmarkFileAsViewed"); + expect(varsAt(1)).toEqual({ + pullRequestId: "PR_1", + path0: "src/a.ts", + path1: "src/b.ts", + }); + }), + ); + + it.effect("asks the host nothing when nothing was pressed", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.setPullRequestFilesViewed({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + files: [], + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("looks a pull request's node id up once, however often it is written to", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output(encodeJson({ data: { repository: { pullRequest: { id: "PR_24" } } } })), + ), + ) + .mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const pullRequest = { cwd: "/w", repository: "acme/web", host: "github.com", number: 24 }; + + yield* cli.setPullRequestFilesViewed({ + ...pullRequest, + files: [{ path: "src/a.ts", viewed: true }], + }); + yield* cli.setPullRequestFilesViewed({ + ...pullRequest, + files: [{ path: "src/b.ts", viewed: true }], + }); + yield* cli.updatePullRequest({ ...pullRequest, title: "Ticked through" }); + + // One lookup, then a mutation per write, every one of them addressed by the id it answered. + assert.strictEqual(mockedExecute.mock.calls.length, 4); + expect(varsAt(0)).toEqual({ owner: "acme", name: "web", number: 24 }); + expect([1, 2, 3].map((index) => varsAt(index)["pullRequestId"])).toEqual([ + "PR_24", + "PR_24", + "PR_24", + ]); + }), + ); + + it.effect("does not remember a node id lookup that failed", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))) + .mockReturnValueOnce( + Effect.succeed( + output(encodeJson({ data: { repository: { pullRequest: { id: "PR_25" } } } })), + ), + ) + .mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const write = () => + cli.setPullRequestFilesViewed({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 25, + files: [{ path: "src/a.ts", viewed: true }], + }); + + const error = yield* Effect.flip(write()); + assert.strictEqual(error._tag, "GitHubPullRequestReadError"); + + yield* write(); + + assert.strictEqual(mockedExecute.mock.calls.length, 3); + expect(varsAt(2)["pullRequestId"]).toEqual("PR_25"); + }), + ); + it.effect("keeps the pull request being ticked through, not the one looked up first", () => + Effect.gen(function* () { + // Ordered by insertion alone, a hit does not renew its entry, so the review the reader is + // working down is the first thing evicted once a listing has walked a cache's worth of cold + // pull requests, and every press after that pays a round trip again. + // This block shares one cache, so these numbers are its own and it runs last. + const HOT = 9_000; + const lookupsOf = new Map(); + mockedExecute.mockImplementation((input) => { + if (input.kind !== "graphql" || !input.query.includes(NODE_ID_QUERY)) { + return Effect.succeed(output("{}")); + } + const number = Number(input.variables?.["number"]); + lookupsOf.set(number, (lookupsOf.get(number) ?? 0) + 1); + return Effect.succeed( + output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })), + ); + }); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const tick = (number: number) => + cli.setPullRequestFilesViewed({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number, + files: [{ path: "src/a.ts", viewed: true }], + }); + + yield* tick(HOT); + // A cache's worth of cold pull requests, with the open one pressed in between each of them. + for (let filled = 0; filled < GitHubPullRequestApi.NODE_ID_CACHE_CAPACITY; filled += 1) { + yield* tick(HOT + 1 + filled); + yield* tick(HOT); + } + + assert.strictEqual(lookupsOf.get(HOT), 1); + }), + ); +}); diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.ts new file mode 100644 index 0000000000000000000000000000000000000000..116a8a742d1a7c0a0c61810ba59e30f076c60f37 --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.ts @@ -0,0 +1,2847 @@ +import { removeAgentCredits } from "./mergeMessage.ts"; +import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; +import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Request from "effect/Request"; +import * as RequestResolver from "effect/RequestResolver"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { + resolvePullRequestAuthorFilter, + PositiveInt, + TrimmedNonEmptyString, + type PullRequestAction, + type PullRequestStackHead, + type PullRequestActor, + type PullRequestComment, + type PullRequestCommit, + type PullRequestFileViewed, + type PullRequestInvolvement, + type PullRequestListFilters, + type PullRequestListState, + type PullRequestMergeMethod, + type PullRequestOmittedFileStat, + type PullRequestReaction, + type PullRequestReactionContent, + type PullRequestReviewCommentDraft, + type PullRequestReviewVerdict, + type PullRequestReviewerCandidateList, + type PullRequestReviewerKind, + type PullRequestLabelCandidateList, + type PullRequestThreadCommentsResult, + type PullRequestUpdateMethod, + type PullRequestPreview, +} from "@t3tools/contracts"; + +import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; +import { + ACTOR_AVATARS_GRAPHQL_QUERY, + ADD_REACTION_GRAPHQL_MUTATION, + buildReviewSubmission, + buildReviewerRequest, + buildSetFilesViewedGraphQlMutation, + decodeActorAvatarsJson, + decodePullRequestActivityJson, + decodePullRequestCheckContextsJson, + decodePullRequestCoreJson, + decodeCommitFilesJson, + pullRequestCheckContextsGraphQlQuery, + pullRequestChecksFromContexts, + pullRequestListGraphQlQuery, + pullRequestSummaryGraphQlQuery, + PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, + PULL_REQUEST_HEADS_GRAPHQL_QUERY, + decodeWorkflowRunsJson, + type GitHubCheckContext, + pullRequestCoreGraphQlQuery, + type GitHubPullRequestCore, + type GitHubPullRequestSummary, + type GitHubPullRequestWatchFingerprint, + decodePullRequestPreviewJson, + PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, + decodePullRequestFilesJson, + decodePullRequestFilesViewedJson, + decodePullRequestHeadsJson, + decodePullRequestListJson, + decodePullRequestNodeIdJson, + decodePullRequestSearchJson, + decodePullRequestStacksJson, + decodePullRequestStatsJson, + decodePullRequestSummariesJson, + decodePullRequestWatchFingerprintsJson, + decodeReactionSubjectScopeJson, + decodeReviewerCandidatesJson, + decodeLabelCandidatesJson, + buildLabelRequest, + LABEL_CANDIDATES_GRAPHQL_QUERY, + decodeReviewDismissalsJson, + decodeReviewThreadCommentsJson, + decodeReviewThreadsJson, + buildPullRequestStatsGraphQlQuery, + buildPullRequestSummariesGraphQlQuery, + buildPullRequestWatchFingerprintsGraphQlQuery, + buildPullRequestStackMembershipsGraphQlQuery, + decodePullRequestStackMembershipsJson, + pullRequestSearchGraphQlQuery, + PULL_REQUEST_SEARCH_MAX_ROWS, + PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, + PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, + REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, + REMOVE_REACTION_GRAPHQL_MUTATION, + REVERT_PULL_REQUEST_GRAPHQL_MUTATION, + gitHubReactionContent, + RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, + REVIEWER_CANDIDATES_GRAPHQL_QUERY, + REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, + REVIEW_DISMISSALS_GRAPHQL_QUERY, + REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, + REVIEW_THREADS_GRAPHQL_QUERY, + reviewThreadConversation, + UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, + UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION, + UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, + UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, + VIEWER_PERMISSIONS_GRAPHQL_QUERY, + decodeViewerPermissionsJson, + type GitHubPullRequestActivity, + type GitHubPullRequestActivityPage, + type GitHubWorkflowRunPage, + type GitHubPullRequestHead, + type GitHubPullRequestListItem, + type GitHubPullRequestSearchItem, + type GitHubPullRequestStack, + type GitHubReviewThreadComments, + type GitHubRepositoryAccess, + type GitHubWorkflowRunApproval, + type GitHubReviewThreadEntry, + type GitHubReviewThreadPage, + type GitHubViewerAccess, +} from "./gitHubPullRequestJson.ts"; +import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullRequestProvider.ts"; + +/** + * Names the read that produced unusable output, so a failure reports the call it came from + * rather than borrowing another operation's message. + */ +export class GitHubPullRequestReadError extends Schema.TaggedError()( + "GitHubPullRequestReadError", + { + cwd: Schema.String, + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `GitHub returned an unreadable ${this.operation} response.`; + } +} + +/** Not a decode failure: GitHub answered, the account it answered for just has no login. */ +export class GitHubViewerLoginUnavailableError extends Schema.TaggedError()( + "GitHubViewerLoginUnavailableError", + { + cwd: Schema.String, + }, +) { + override get message(): string { + return "GitHub returned no login for the authenticated account."; + } +} + +/** Not a decode failure: GitHub answered, but the pull request carried no update time. */ +export class GitHubPullRequestUpdatedAtUnavailableError extends Schema.TaggedError()( + "GitHubPullRequestUpdatedAtUnavailableError", + { + cwd: Schema.String, + repository: Schema.String, + number: Schema.Int, + }, +) { + override get message(): string { + return `Pull request ${this.repository}#${this.number} reported no update time.`; + } +} + +/** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */ +export class GitHubDiffCursorError extends Schema.TaggedError()( + "GitHubDiffCursorError", + { + cwd: Schema.String, + }, +) { + override get message(): string { + return "The diff cursor was not one this pull request handed out."; + } +} + +/** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */ +export class GitHubDiffCommitError extends Schema.TaggedError()( + "GitHubDiffCommitError", + { + cwd: Schema.String, + }, +) { + override get message(): string { + return "The named commit was not a commit sha."; + } +} + +/** The revisions read successfully, but cannot name both sides this file needs. */ +export class GitHubDiffRevisionsUnavailableError extends Schema.TaggedError()( + "GitHubDiffRevisionsUnavailableError", + { + cwd: Schema.String, + number: Schema.Int, + commit: Schema.optional(Schema.String), + }, +) { + override get message(): string { + return this.commit === undefined + ? `Pull request #${this.number} reported no usable base and head revisions.` + : `Commit ${this.commit} reported no usable revisions for this file.`; + } +} + +/** A blob exists, but expanding it would be unsafe or would not produce text. */ +export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedError()( + "GitHubDiffFileContentsUnavailableError", + { + cwd: Schema.String, + path: Schema.String, + reason: Schema.Literals(["oversized", "binary"]), + }, +) { + override get message(): string { + return this.reason === "oversized" + ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.` + : `The diff file '${this.path}' is binary.`; + } +} + +/** + * Not a decode failure: a repository was named that cannot go into a search or into a GraphQL + * document as itself. Every qualifier and every alias below is composed from `owner/name`, so a + * name that is not one is refused here rather than escaped into something GitHub might read as a + * qualifier of its own. + */ +export class GitHubRepositorySelectorError extends Schema.TaggedError()( + "GitHubRepositorySelectorError", + { + cwd: Schema.String, + operation: Schema.String, + }, +) { + override get message(): string { + return "A repository was named that GitHub cannot address."; + } +} + +/** Not a decode failure: the reader named a subject this pull request never handed out. */ +export class GitHubSubjectScopeError extends Schema.TaggedError()( + "GitHubSubjectScopeError", + { + cwd: Schema.String, + operation: Schema.String, + }, +) { + override get message(): string { + return "The named subject did not belong to the named pull request."; + } +} + +/** GitHub answered successfully, but approving every returned workflow would be unsafe. */ +export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError()( + "GitHubWorkflowApprovalRefusedError", + { + cwd: Schema.String, + number: Schema.Int, + reason: Schema.Literals(["head-list-truncated", "head-not-unique", "run-list-truncated"]), + observedCount: Schema.Int, + limit: Schema.Int, + }, +) { + override get message(): string { + if (this.reason === "head-list-truncated") { + return `GitHub returned more than ${this.limit} pull requests for this head branch.`; + } + if (this.reason === "head-not-unique") { + return `The head revision matched ${this.observedCount} pull requests instead of uniquely matching #${this.number}.`; + } + return `GitHub returned more than ${this.limit} workflow runs awaiting approval.`; + } +} + +/** GitHub omitted the immutable head identity needed to scope an approval safely. */ +export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedError()( + "GitHubWorkflowApprovalHeadUnavailableError", + { + cwd: Schema.String, + number: Schema.Int, + }, +) { + override get message(): string { + return `GitHub did not report a complete head revision for #${this.number}.`; + } +} + +/** The pull request moved after its approval candidates were read. */ +export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError()( + "GitHubWorkflowApprovalHeadChangedError", + { + cwd: Schema.String, + number: Schema.Int, + }, +) { + override get message(): string { + return `The head revision of #${this.number} changed before its workflows could be approved.`; + } +} + +export type GitHubPullRequestApiError = + | GitHubStackActionError + | GitHubApi.GitHubApiError + | GitHubPullRequestReadError + | GitHubDiffCursorError + | GitHubDiffCommitError + | GitHubDiffRevisionsUnavailableError + | GitHubDiffFileContentsUnavailableError + | GitHubRepositorySelectorError + | GitHubSubjectScopeError + | GitHubWorkflowApprovalRefusedError + | GitHubWorkflowApprovalHeadUnavailableError + | GitHubWorkflowApprovalHeadChangedError + | SourceControlRateLimit.SourceControlRateLimitPausedError + | GitHubViewerLoginUnavailableError + | GitHubPullRequestUpdatedAtUnavailableError; + +/** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ +const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +const DIFF_TIMEOUT = Duration.seconds(60); +/** Pierre expansion is for source files, not blobs large enough to stall a review surface. */ +const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; + +/** What `gh pr list --state` asked a repository's own list for, without search. */ +const LIST_STATES: Record> = { + all: ["OPEN", "CLOSED", "MERGED"], + open: ["OPEN"], + // Closed includes merged here, the way GitHub's own list reads it; merged rows are filtered out + // locally by `matchesUnsortedListing`. + closed: ["CLOSED", "MERGED"], + merged: ["MERGED"], +}; + +interface GitHubPullRequestListPage { + readonly items: ReadonlyArray; + readonly rawCount: number; + readonly endCursor: string | null; +} + +/** A search-free fallback may scan older rows for local filters, but never the whole repository. */ +const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; + +/** What the files API serves at most in one response, which is what one slice is made of. */ +const DIFF_FILES_PAGE_SIZE = 100; +/** + * How many hundred-file pages of viewed state one read will walk. A point of the hourly GraphQL + * budget per page, against a change request nobody reviews in one sitting past the first few + * hundred files: beyond this the read stops and says it was cut short. + */ +const FILES_VIEWED_MAX_PAGES = 5; + +/** + * How many pull requests' node ids are remembered at once. A long-lived server sees far more of + * them than a reader ever has open, and least recently used rather than first in: a listing + * walking cold pull requests must not evict the review being ticked through. + */ +export const NODE_ID_CACHE_CAPACITY = 128; + +/** + * Pages of review threads to follow before the conversation is reported as truncated. GitHub + * serves a hundred threads a page, so this is a thousand threads — past anything a pull request + * a person is reading has, and short of walking a repository-sized conversation forever. + */ +const REVIEW_THREAD_PAGES = 10; + +/** Pages of a hundred check contexts to walk for one head before the rollup says it was cut. */ +const CHECK_CONTEXT_PAGES = 10; + +export interface GitHubPullRequestListBatch { + readonly items: ReadonlyArray; + readonly truncated: boolean; + /** False for a page GitHub would not search, which came back in `gh`'s own order instead. */ + readonly continues: boolean; +} + +export interface GitHubPullRequestStat { + readonly repository: string; + readonly number: number; + readonly additions: number; + readonly deletions: number; +} + +/** + * Aliased lookups per request, and requests at once. Measured over a hundred rows: one request + * carrying all hundred takes ~5.2s, four of twenty-five in parallel ~2.1s. + */ +const STAT_ALIASES_PER_REQUEST = 25; +const STAT_REQUEST_CONCURRENCY = 4; +/** + * How long a summary read waits for company. The background sync asks for every linked pull + * request at once, and each read reaches the resolver after its own cache check, so a batch + * needs a moment longer than one scheduler tick to gather them. + */ +const SUMMARY_BATCH_WINDOW = "10 millis"; + +class PullRequestSummaryRead extends Request.Class< + { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }, + ProviderChangeRequestSummary, + GitHubPullRequestApiError +> {} + +class PullRequestWatchFingerprintRead extends Request.Class< + { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }, + GitHubPullRequestWatchFingerprint | null, + GitHubPullRequestApiError +> {} + +export interface GitHubPullRequestSearchBatch { + /** Rows across every repository asked for, newest update first, each naming its own. */ + readonly items: ReadonlyArray; + readonly truncated: boolean; +} + +export interface GitHubPullRequestDiffSlice { + readonly patch: string; + /** Files in this slice had their hunks withheld, as opposed to there being more slices. */ + readonly truncated: boolean; + /** Where the next slice starts, or null once the patch is whole. */ + readonly nextCursor: string | null; + /** GitHub's own counts for the files whose hunks it withheld from this slice. */ + readonly omittedFileStats?: ReadonlyArray; +} + +export interface GitHubPullRequestFilesViewed { + readonly files: ReadonlyArray; + /** GitHub had more files than the page budget below would read. */ + readonly truncated: boolean; +} + +export class GitHubPullRequestApi extends Context.Service< + GitHubPullRequestApi, + { + readonly withVerifiedCredential: ( + input: { readonly cwd: string; readonly host: string }, + use: (identity: { + readonly accountId: string; + readonly viewer: string; + readonly credentialFingerprint: string; + }) => Effect.Effect, + ) => Effect.Effect; + readonly getRoutingIdentity: (input: { + readonly cwd: string; + readonly host: string; + }) => Effect.Effect< + { readonly accountId: string; readonly viewer: string }, + GitHubPullRequestApiError + >; + readonly getViewerLogin: (input: { + readonly cwd: string; + readonly host: string; + }) => Effect.Effect; + + readonly listPullRequests: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + /** Free text for `--search`, matched as one literal phrase. */ + readonly query?: string | undefined; + /** Where to carry on from, as a `updated:` qualifier on the same search. */ + readonly cursor?: ProviderListCursor | undefined; + /** Further narrowings, as qualifiers on the search and as a local pass on the fallback. */ + readonly filters?: PullRequestListFilters | undefined; + }) => Effect.Effect; + + /** + * The same listing for a whole host in one search. `limit` is the size of the slice across + * all of the repositories rather than per repository, because that is what a search answers: + * the newest rows of the lot, which is exactly the page. + */ + readonly searchPullRequests: (input: { + /** Any checkout on the host; the search names its repositories itself. */ + readonly cwd: string; + readonly host: string; + readonly repositories: ReadonlyArray; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + readonly query?: string | undefined; + readonly cursor?: ProviderListCursor | undefined; + readonly filters?: PullRequestListFilters | undefined; + }) => Effect.Effect; + + /** The line counts the search leaves out, for rows already on the page. */ + readonly listPullRequestStats: (input: { + readonly cwd: string; + readonly host: string; + readonly changeRequests: ReadonlyArray<{ + readonly repository: string; + readonly number: number; + }>; + }) => Effect.Effect, GitHubPullRequestApiError>; + + readonly getPullRequestSummary: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + /** + * What a watch compares between passes, batched like summaries. Null when GitHub gave no + * answer for this pull request, so the watch reads it in full instead. + */ + readonly getPullRequestWatchFingerprint: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + readonly revalidateChecks: Effect.Success; + + readonly getPullRequestDetail: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + readonly getPullRequestPreview: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect< + Omit, + GitHubPullRequestApiError + >; + + readonly listWorkflowRunsRequiringApproval: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly headSha: string; + readonly headBranch: string; + readonly headRepositoryOwner: string; + readonly isCrossRepository: true; + }) => Effect.Effect, GitHubPullRequestApiError>; + + /** + * The host-native stack this pull request is in, or null when it is in none — which is also + * the answer for a host that refuses the stacks preview altogether. + */ + readonly getPullRequestStack: (input: { + readonly cwd: string; + readonly includeDetails?: boolean; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + readonly getPullRequestActivity: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + readonly getPullRequestDiff: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + /** Absent asks for the first slice; anything else is a cursor a slice handed back. */ + readonly cursor?: string | undefined; + /** One commit's own changes, rather than everything the pull request carries. */ + readonly commit?: string | undefined; + }) => Effect.Effect; + + readonly getPullRequestDiffFileContents: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly commit?: string | undefined; + readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; + readonly oldPath: string; + readonly newPath: string; + }) => Effect.Effect< + { readonly oldContents: string; readonly newContents: string }, + GitHubPullRequestApiError + >; + + /** + * Which files of the pull request the signed-in account has cleared, and which of those have + * been pushed to since. Read apart from the patch because GitHub only reports it over GraphQL. + */ + readonly getPullRequestFilesViewed: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + /** + * Clears files, or puts them back, as one request. GitHub takes a single path per mutation, + * so a burst is batched with aliases into one document rather than one subprocess per press. + */ + readonly setPullRequestFilesViewed: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>; + }) => Effect.Effect; + + readonly listReviewThreadComments: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + /** One request for a listing's authors, since no `gh` JSON field reports an avatar. */ + readonly listActorAvatars: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly ids: ReadonlyArray; + }) => Effect.Effect, GitHubPullRequestApiError>; + + readonly getReviewThreadComments: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly threadId: string; + readonly cursor: string; + }) => Effect.Effect; + + /** The viewer's standing on its own, for deciding a write without reading the whole detail. */ + readonly getViewerAccess: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + /** Manual action checks may use the quota held back from automatic reads. */ + readonly allowReserve?: boolean | undefined; + }) => Effect.Effect; + + /** Who this pull request may be sent to, and who it has already been sent to. */ + readonly listReviewerCandidates: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + readonly setReviewerRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly reviewers: ReadonlyArray<{ + readonly id: string; + readonly kind: PullRequestReviewerKind; + }>; + /** False deletes the same collection a request posts to, which takes the request back. */ + readonly requested: boolean; + }) => Effect.Effect; + + /** The repository's labels, and which of them this pull request already wears. */ + readonly listLabelCandidates: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + readonly setLabels: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly labels: ReadonlyArray; + /** False takes each label off; true adds each to whatever is already there. */ + readonly applied: boolean; + }) => Effect.Effect; + + readonly runPullRequestAction: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly action: PullRequestAction; + readonly stackNumber?: number; + readonly expectedStackHeads?: ReadonlyArray; + readonly removeAgentCreditsOnMerge?: boolean; + readonly mergeMethod?: PullRequestMergeMethod; + readonly updateMethod?: PullRequestUpdateMethod; + }) => Effect.Effect; + + readonly commentOnPullRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly body: string; + }) => Effect.Effect; + + readonly submitReview: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly verdict: PullRequestReviewVerdict; + readonly body: string; + readonly comments: ReadonlyArray; + }) => Effect.Effect; + + readonly replyToReviewThread: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly threadId: string; + readonly body: string; + }) => Effect.Effect; + + readonly setReviewThreadResolution: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly threadId: string; + readonly resolved: boolean; + }) => Effect.Effect; + + /** + * Adds a reaction to a remark, or takes it back. `subjectId` is any node GitHub calls + * reactable — a comment, a review, or the pull request itself, which is looked up here + * because nothing in the conversation names it. A given `subjectId` is confirmed to belong + * to this pull request before the mutation runs, since nothing else ties the two together. + */ + readonly setReaction: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly subjectId?: string | undefined; + readonly content: PullRequestReactionContent; + readonly reacted: boolean; + }) => Effect.Effect; + + /** Rewrites the pull request's own words, leaving whichever of the two was not given. */ + readonly updatePullRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly title?: string | undefined; + readonly body?: string | undefined; + }) => Effect.Effect; + + /** + * Rewrites a remark. `commentId` is trusted to be whatever node it names, so it is confirmed + * to belong to this pull request before the mutation runs, the way a reaction subject is. + * Whether the remark is the reader's to rewrite is GitHub's own answer, not one asked here. + */ + readonly updateComment: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly commentId: string; + readonly kind: "issue-comment" | "review-comment"; + readonly body: string; + }) => Effect.Effect; + } +>()("t3/pullRequest/GitHubPullRequestApi") {} + +/** + * The GraphQL API takes owner and name as separate arguments, so `owner/repo` is split here. + * The host is not read off the identity: it travels alongside it, because the identity a + * project records is the path below its host and never names the host itself. + */ +function parseRepositorySelector(value: string): { + readonly owner: string; + readonly name: string; +} { + const parts = value.trim().split("/").filter(Boolean); + return { name: parts.at(-1) ?? "", owner: parts.at(-2) ?? "" }; +} + +/** + * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor + * arrives from the reader as a string and goes straight into a request path, so it is parsed + * rather than trusted; the length bound keeps a page number out of exponential notation. + */ +function diffCursorPage(cursor: string): number | null { + return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null; +} + +/** + * A commit sha arrives from the reader and goes straight into a request path, so it is checked + * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a + * whole sha. + */ +function isCommitSha(value: string): boolean { + return /^[0-9a-f]{7,64}$/i.test(value); +} + +/** + * The reader's own words as one literal phrase of a GitHub search query. Quoting is the whole + * defence: outside quotes GitHub reads `is:merged` as a qualifier and `label:x` as another, so + * text typed into a search box could widen the very listing it is meant to narrow — inside them + * it is only text. The two characters that could end the phrase early are therefore escaped + * first, which GitHub reads back as themselves; an unbalanced quote is dropped instead, which + * would let everything after it out of the phrase. + * + * The phrase is one argv element, so nothing in it can become a flag of its own either. + */ +function searchPhrase(query: string): string { + return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +} + +/** GitHub's own spelling of a review state, which is not the contract's. */ +const REVIEW_QUALIFIERS = { + approved: "approved", + "changes-requested": "changes_requested", + "review-required": "required", + none: "none", +} as const; + +/** + * The extra narrowings as GitHub search qualifiers. Values a reader typed are quoted, and the + * one character that could end the quoted value early is dropped rather than escaped: no GitHub + * label or login holds a double quote, so there is nothing to preserve and everything to lose. + */ +function qualifierValue(value: string): string { + return `"${value.replaceAll('"', "").trim()}"`; +} + +function filterQualifiers( + filters: PullRequestListFilters | undefined, + viewer: string, +): ReadonlyArray { + if (filters === undefined) return []; + return [ + // One qualifier per group, its names joined by commas — GitHub's own OR. + ...(filters.labels ?? []).flatMap((group) => + group.length === 0 ? [] : [`label:${group.map(qualifierValue).join(",")}`], + ), + ...(filters.excludedLabels ?? []).map((label) => `-label:${qualifierValue(label)}`), + ...(filters.author === undefined + ? [] + : [`author:${qualifierValue(resolvePullRequestAuthorFilter(filters.author, viewer))}`]), + ...(filters.draft === undefined ? [] : [`draft:${filters.draft === "only"}`]), + ...(filters.review === undefined ? [] : [`review:${REVIEW_QUALIFIERS[filters.review]}`]), + ...(filters.checks === undefined + ? [] + : [`status:${filters.checks === "passing" ? "success" : "failure"}`]), + ]; +} + +/** + * The same narrowings over a row that has already arrived, for the search-free fallback. Every + * listed row now carries its own `checksState`, so `checks` is judged the way `review` is: by + * equality against the row's field. Unlike `review`, `checks` has no `"none"` value to catch an + * absent state on purpose — a row with no checks configured, or whose checks are still `pending`, + * equals neither `"passing"` nor `"failing"` and so fails both, the same as a row search would + * not have surfaced for `status:success` or `status:failure`. + */ +function matchesFilters( + item: GitHubPullRequestListItem, + filters: PullRequestListFilters | undefined, + viewer: string, +): boolean { + if (filters === undefined) return true; + const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase())); + const holds = (label: string) => labels.has(label.trim().toLowerCase()); + return ( + (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && + (filters.review === undefined || + (filters.review === "none" + ? item.reviewDecision === null + : item.reviewDecision === filters.review)) && + (filters.checks === undefined || item.checksState === filters.checks) && + (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) && + (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) && + (filters.author === undefined || + item.author?.login.toLowerCase() === + resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase()) + ); +} + +/** The search-free fallback is wider than the request, so narrow its decoded rows locally. */ +function matchesUnsortedListing( + item: GitHubPullRequestListItem, + input: { + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly filters?: PullRequestListFilters | undefined; + }, +): boolean { + const matchesState = input.state === "all" || item.state === input.state; + const viewer = input.viewer.toLowerCase(); + const matchesInvolvement = + input.involvement === "all" || + (input.involvement === "authored" + ? item.author?.login.toLowerCase() === viewer + : item.hasTeamReviewRequest || + item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer)); + return matchesState && matchesInvolvement && matchesFilters(item, input.filters, input.viewer); +} + +/** What a repository selector may hold before it goes into a search as itself. */ +const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; + +/** + * A listing as one GitHub search, across one repository or several: the only way to read a + * whole host in one request, and the only order (`updated`) a continuation can carry on from. + * + * `--state closed` in GitHub's own list includes merged pull requests, so the Closed tab is + * `is:closed is:unmerged` here. + * + * Null where a repository is not `owner/name`. A name is written into the query as itself, and a + * name holding a space could otherwise end the `repo:` qualifier and start a qualifier of its + * own — so an unaddressable one refuses the whole read rather than being escaped into something + * GitHub might still read. + */ +function searchQuery(input: { + readonly repositories: ReadonlyArray; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly query?: string | undefined; + readonly cursor?: ProviderListCursor | undefined; + readonly filters?: PullRequestListFilters | undefined; +}): string | null { + if (input.repositories.length === 0) return null; + const repositories = input.repositories.map((repository) => repository.trim()); + if (!repositories.every((repository) => SEARCH_REPOSITORY.test(repository))) return null; + const query = input.query?.trim() ?? ""; + return [ + "is:pr", + // "all" is every state, which `is:pr` already is. + ...(input.state === "open" ? ["is:open"] : []), + ...(input.state === "closed" ? ["is:closed", "is:unmerged"] : []), + ...(input.state === "merged" ? ["is:merged"] : []), + ...(input.involvement === "authored" ? [`author:${input.viewer}`] : []), + ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []), + ...(query.length === 0 ? [] : [searchPhrase(query)]), + // Inclusive, and de-duplicated by the caller, for the reason the per-repository read gives. + ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]), + ...filterQualifiers(input.filters, input.viewer), + // The order the page reads its rows in, and the only order a continuation can carry on from. + "sort:updated-desc", + ...repositories.map((repository) => `repo:${repository}`), + ].join(" "); +} + +const MERGE_MESSAGE_GRAPHQL_QUERY = ` +query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + isMergeQueueEnabled + headRefOid + viewerMergeBodyText(mergeType: $method) + } + } +}`; + +/** The two revisions a file is expanded between: a pull request's base and head, or a commit's parent and itself. */ +const decodeRevisionRefs = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + sha: Schema.optional(Schema.String), + parents: Schema.optional(Schema.Array(Schema.Struct({ sha: Schema.String }))), + base: Schema.optional(Schema.Struct({ sha: Schema.String })), + head: Schema.optional(Schema.Struct({ sha: Schema.String })), + }), + ), +); + +const decodeMergeMessageResponse = Schema.decodeUnknownResult( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + isMergeQueueEnabled: Schema.Boolean, + headRefOid: Schema.String, + viewerMergeBodyText: Schema.String, + }), + }), + }), + }), + ), +); +const decodeMergeMessage = (raw: string) => + Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest); + +/** What `gh pr merge` and `gh pr update-branch` read before they act. */ +const ACTION_STATE_GRAPHQL_QUERY = ` +query PullRequestActionState($owner: String!, $name: String!, $number: Int!, $headRef: String!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + id + headRefOid + isMergeQueueEnabled + mergeStateStatus + baseRef { compare(headRef: $headRef) { behindBy } } + } + } +}`; + +const decodeActionStateResponse = Schema.decodeUnknownResult( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + id: Schema.String, + headRefOid: Schema.String, + isMergeQueueEnabled: Schema.optional(Schema.Boolean), + mergeStateStatus: Schema.optional(Schema.NullOr(Schema.String)), + baseRef: Schema.optional( + Schema.NullOr( + Schema.Struct({ compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })) }), + ), + ), + }), + }), + }), + }), + ), +); +const decodeActionState = (raw: string) => + Result.map(decodeActionStateResponse(raw), (response) => response.data.repository.pullRequest); + +const MERGE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($input: MergePullRequestInput!) { + mergePullRequest(input: $input) { clientMutationId } +}`; +const ENABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($input: EnablePullRequestAutoMergeInput!) { + enablePullRequestAutoMerge(input: $input) { clientMutationId } +}`; +const DISABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { + disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { clientMutationId } +}`; +const UPDATE_BRANCH_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!, $updateMethod: PullRequestBranchUpdateMethod!) { + updatePullRequestBranch(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid, updateMethod: $updateMethod }) { clientMutationId } +}`; +const READY_FOR_REVIEW_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { + markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { clientMutationId } +}`; +const CONVERT_TO_DRAFT_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { + convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { clientMutationId } +}`; +const CLOSE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { + closePullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } +}`; +const REOPEN_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { + reopenPullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } +}`; +const ADD_COMMENT_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $body: String!) { + addComment(input: { subjectId: $subjectId, body: $body }) { clientMutationId } +}`; + +const GRAPHQL_MERGE_METHODS = { + merge: "MERGE", + squash: "SQUASH", + rebase: "REBASE", +} as const satisfies Record; + +/** States in which `gh pr merge --auto` merges at once instead of arming auto-merge. */ +const IMMEDIATELY_MERGEABLE = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]); + +/** The mutations that only need the pull request's node id. */ +const SIMPLE_ACTION_MUTATIONS = { + "disable-auto-merge": DISABLE_AUTO_MERGE_GRAPHQL_MUTATION, + ready: READY_FOR_REVIEW_GRAPHQL_MUTATION, + draft: CONVERT_TO_DRAFT_GRAPHQL_MUTATION, + close: CLOSE_PULL_REQUEST_GRAPHQL_MUTATION, + reopen: REOPEN_PULL_REQUEST_GRAPHQL_MUTATION, +} as const satisfies Partial>; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const vcsProcess = yield* VcsProcess.VcsProcess; + const fileSystem = yield* FileSystem.FileSystem; + const revalidateChecks = yield* makeChecksRevalidator; + const routingIdentities = new Map< + string, + { + at: number; + value: { accountId: string; viewer: string }; + } + >(); + const identityLocks = new Map(); + const decodeRoutingIdentity = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + id: PositiveInt, + login: TrimmedNonEmptyString, + }), + ), + ); + const captureVerifiedCredential = Effect.fn("GitHubPullRequestApi.captureVerifiedCredential")( + function* (input: { readonly cwd: string; readonly host: string }) { + const unavailable = () => new GitHubViewerLoginUnavailableError({ cwd: input.cwd }); + const host = input.host.toLowerCase(); + // A missing or signed-out credential keeps its own error, so the page can say which. + const { token, fingerprint: key } = yield* api.credential(host); + const credential = { host, token, credentialFingerprint: key }; + // A cold page may ask several times. Wait per credential and check again after the + // first verification; cancellation releases the next waiter without losing its request. + return yield* Effect.acquireUseRelease( + Effect.sync(() => { + const lock = identityLocks.get(key) ?? { gate: Semaphore.makeUnsafe(1), users: 0 }; + lock.users++; + identityLocks.set(key, lock); + return lock; + }), + (lock) => + lock.gate.withPermit( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const cached = routingIdentities.get(key); + if (cached !== undefined && now - cached.at < 10 * 60_000) + return { ...credential, ...cached.value }; + // Pin this read so an auth switch cannot poison its cache entry. + const response = yield* api + .rest({ host, operation: "getRoutingIdentity", path: "user", allowReserve: true }) + .pipe( + Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), + Effect.provideService(SourceControlRateLimit.CredentialScope, key), + ); + const identity = yield* decodeRoutingIdentity(response.body).pipe( + Effect.mapError(unavailable), + ); + const value = { accountId: String(identity.id), viewer: identity.login }; + if (routingIdentities.size >= 128) + routingIdentities.delete(routingIdentities.keys().next().value!); + routingIdentities.set(key, { at: now, value }); + return { ...credential, ...value }; + }), + ), + (lock) => + Effect.sync(() => { + lock.users--; + if (lock.users === 0) identityLocks.delete(key); + }), + ); + }, + ); + const withVerifiedCredential: GitHubPullRequestApi["Service"]["withVerifiedCredential"] = ( + input, + use, + ) => + captureVerifiedCredential(input).pipe( + Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) => + use({ accountId, viewer, credentialFingerprint }).pipe( + Effect.provideService(SourceControlRateLimit.CredentialScope, credentialFingerprint), + Effect.provideService(GitHubApi.PinnedGitHubCredential, { + host, + token, + credentialFingerprint, + }), + ), + ), + ); + const getRoutingIdentity: GitHubPullRequestApi["Service"]["getRoutingIdentity"] = (input) => + captureVerifiedCredential(input).pipe( + Effect.map(({ accountId, viewer }) => ({ accountId, viewer })), + ); + + /** + * The pull request's own node id, which is what a mutation against the pull request itself is + * addressed by: a reaction on its description, or a rewrite of its words. + * + * A pull request keeps its node id for life, so it is remembered rather than re-read: a reader + * ticking files viewed would otherwise pay a GraphQL round trip per press. + */ + const nodeIds = new Map(); + + const pullRequestNodeId = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly operation: string; + }): Effect.Effect => { + const { owner, name } = parseRepositorySelector(input.repository); + const key = `${input.host} ${owner}/${name} ${input.number}`; + const held = nodeIds.get(key); + if (held !== undefined) { + // Put back at the end on every hit, so what falls out is the pull request nobody has looked + // at rather than the one being ticked through: a run of cold reads would otherwise evict the + // open review and make it pay a round trip per press. + nodeIds.delete(key); + nodeIds.set(key, held); + return Effect.succeed(held); + } + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: input.operation, + allowReserve: true, + variables: { owner, name, number: input.number }, + query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, + decode: decodePullRequestNodeIdJson, + }).pipe( + Effect.tap((nodeId) => + Effect.sync(() => { + if (nodeIds.size >= NODE_ID_CACHE_CAPACITY) { + const oldest = nodeIds.keys().next().value; + if (oldest !== undefined) nodeIds.delete(oldest); + } + nodeIds.set(key, nodeId); + }), + ), + ); + }; + + /** + * Whether a client-given subject actually belongs to the pull request the request names. A + * subject id is trusted to be whatever node it names, and that node can hang off any pull + * request on the host — so the mutation itself would write wherever the id actually belongs, + * not wherever the request says it does, unless this confirms the two agree first. + */ + const subjectBelongsToPullRequest = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly subjectId: string; + readonly operation: string; + }) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: input.operation, + allowReserve: true, + variables: { owner, name, number: input.number, subjectId: input.subjectId }, + query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, + decode: decodeReactionSubjectScopeJson, + }); + }; + + /** A GraphQL mutation whose answer is not read back; a GraphQL error fails it. */ + const graphql = (input: { + readonly host: string; + readonly operation: string; + readonly query: string; + readonly variables: Readonly>; + }) => api.graphql(input).pipe(Effect.asVoid); + + const readError = (cwd: string, operation: string, cause: unknown) => + new GitHubPullRequestReadError({ cwd, operation, cause }); + + /** Decodes an answer, reporting a failure against the read that made it. */ + const decodeWith = ( + cwd: string, + operation: string, + decode: (raw: string) => Result.Result, + raw: string, + ): Effect.Effect => { + const decoded = decode(raw.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(readError(cwd, operation, decoded.failure)); + }; + + /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */ + const graphqlRead = (input: { + readonly cwd: string; + readonly host: string; + readonly operation: string; + readonly allowReserve?: boolean | undefined; + readonly variables?: Readonly>; + readonly query: string; + readonly decode: (raw: string) => Result.Result; + }): Effect.Effect => + api + .graphql({ + host: input.host, + operation: input.operation, + query: input.query, + ...(input.variables === undefined ? {} : { variables: input.variables }), + ...(input.allowReserve === true ? { allowReserve: true } : {}), + }) + .pipe(Effect.flatMap((raw) => decodeWith(input.cwd, input.operation, input.decode, raw))); + + /** A REST read whose JSON answer is decoded the same way. */ + const restRead = (input: { + readonly cwd: string; + readonly host: string; + readonly operation: string; + readonly path: string; + readonly decode: (raw: string) => Result.Result; + }): Effect.Effect => + api + .rest({ host: input.host, operation: input.operation, path: input.path }) + .pipe( + Effect.flatMap((response) => + decodeWith(input.cwd, input.operation, input.decode, response.body), + ), + ); + + /** + * One page of the patch, read from the files API. GitHub refuses a whole diff past 300 changed + * files, and still serves those files' hunks here. + * + * A page is a whole number of files, so each one parses on its own; the caller carries on from + * `nextCursor` for as long as GitHub keeps handing pages back. + * + * A named commit is read from the commit endpoint, which lists the same file entries and pages + * them the same way, only wrapped in an object. + */ + const diffFilesPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly page: number; + readonly commit?: string | undefined; + }): Effect.Effect => { + const { owner, name } = parseRepositorySelector(input.repository); + const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`; + return api + .rest({ + host: input.host, + operation: "getPullRequestDiff", + path: + input.commit === undefined + ? `repos/${owner}/${name}/pulls/${input.number}/files?${paging}` + : `repos/${owner}/${name}/commits/${input.commit}?${paging}`, + maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, + timeout: DIFF_TIMEOUT, + }) + .pipe( + Effect.flatMap((response) => { + // Checked before decoding: a byte-truncated response is a JSON prefix, which would + // fail to parse. Nothing of this page can be shown, and an empty patch would render + // as a change with no files rather than as the failure it is; slices already handed + // over stay with the reader either way. + if (response.truncated) { + return Effect.fail( + readError( + input.cwd, + "getPullRequestDiff", + new Error(`Page ${input.page} of the changed files was too large to read.`), + ), + ); + } + return decodeWith( + input.cwd, + "getPullRequestDiff", + input.commit === undefined ? decodePullRequestFilesJson : decodeCommitFilesJson, + response.body, + ); + }), + Effect.map((files) => { + // Counted before decoding, so a page whose files all failed to decode still moves on + // rather than pointing the reader back at the page it just read. + const morePages = files.rawCount >= DIFF_FILES_PAGE_SIZE; + return { + patch: files.patch, + truncated: files.truncated, + nextCursor: morePages ? String(input.page + 1) : null, + ...(files.omittedFileStats.length === 0 + ? {} + : { omittedFileStats: files.omittedFileStats }), + }; + }), + ); + }; + + const getPullRequestDiffFileContents: GitHubPullRequestApi["Service"]["getPullRequestDiffFileContents"] = + (input) => + Effect.gen(function* () { + if (input.commit !== undefined && !isCommitSha(input.commit)) { + return yield* new GitHubDiffCommitError({ cwd: input.cwd }); + } + const { owner, name } = parseRepositorySelector(input.repository); + const refsResponse = yield* api.rest({ + host: input.host, + operation: "getPullRequestDiffFileContents", + path: + input.commit === undefined + ? `repos/${owner}/${name}/pulls/${input.number}` + : `repos/${owner}/${name}/commits/${input.commit}`, + timeout: DIFF_TIMEOUT, + }); + const refs = decodeRevisionRefs(refsResponse.body); + // A root commit has no parent, which is an absent old revision. Every file in it is new, + // so that is a usable answer whenever the caller does not need the old side. + const baseRef = Option.isSome(refs) + ? input.commit === undefined + ? refs.value.base?.sha + : (refs.value.parents?.[0]?.sha ?? "") + : undefined; + const headRef = Option.isSome(refs) + ? input.commit === undefined + ? refs.value.head?.sha + : refs.value.sha + : undefined; + const rootCommitNewFile = + input.commit !== undefined && input.changeType === "new" && baseRef === ""; + if ( + headRef === undefined || + (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) || + !isCommitSha(headRef) + ) { + return yield* new GitHubDiffRevisionsUnavailableError({ + cwd: input.cwd, + number: input.number, + ...(input.commit === undefined ? {} : { commit: input.commit }), + }); + } + + const readFile = (revision: string, filePath: string) => + api + .rest({ + host: input.host, + operation: "getPullRequestDiffFileContents", + accept: "application/vnd.github.raw+json", + path: `repos/${owner}/${name}/contents/${filePath + .split("/") + .map(encodeURIComponent) + .join("/")}?ref=${encodeURIComponent(revision)}`, + maxResponseBytes: DIFF_FILE_MAX_OUTPUT_BYTES, + timeout: DIFF_TIMEOUT, + }) + .pipe( + Effect.flatMap((response) => + response.truncated || response.body.includes("\0") || response.invalidUtf8 + ? Effect.fail( + new GitHubDiffFileContentsUnavailableError({ + cwd: input.cwd, + path: filePath, + reason: response.truncated ? "oversized" : "binary", + }), + ) + : Effect.succeed(response.body), + ), + ); + + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" + ? Effect.succeed("") + : readFile(baseRef ?? "", input.oldPath), + input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + }); + + /** + * Every check context of the head commit, a page at a time, for a rollup the detail read cut at + * its first hundred. Each page names the head it read, so a push mid-walk fails rather than + * mixing two revisions' checks. + */ + const readAllCheckContexts = ( + input: Parameters[0], + allowReserve: boolean, + ) => + Effect.gen(function* () { + const { owner, name } = parseRepositorySelector(input.repository); + const contexts: GitHubCheckContext[] = []; + let headSha: string | null = null; + let after: string | null = null; + for (let page = 0; page < CHECK_CONTEXT_PAGES; page++) { + const read: { + readonly headSha: string; + readonly contexts: ReadonlyArray; + readonly nextCursor: string | null; + } = yield* graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestDetail", + allowReserve, + variables: { owner, name, number: input.number, after }, + query: pullRequestCheckContextsGraphQlQuery(input.host), + decode: decodePullRequestCheckContextsJson, + }); + if (headSha !== null && read.headSha !== headSha) { + return yield* readError( + input.cwd, + "getPullRequestDetail", + new Error("Pull request head changed while reading checks."), + ); + } + headSha = read.headSha; + contexts.push(...read.contexts); + after = read.nextCursor; + if (after === null) break; + } + return { headSha, contexts, truncated: after !== null }; + }); + + const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return AllowGitHubReserve.pipe( + Effect.flatMap((allowReserve) => + graphqlRead({ + allowReserve, + cwd: input.cwd, + host: input.host, + operation: "getPullRequestDetail", + variables: { + owner, + name, + number: input.number, + headRef: `refs/pull/${input.number}/head`, + }, + query: pullRequestCoreGraphQlQuery(input.host), + decode: decodePullRequestCoreJson, + }).pipe( + // Past a hundred checks the first page would let the rest imply success, so the whole + // rollup is walked instead. + Effect.filterOrElse( + (core) => !core.checksTruncated, + (core) => + readAllCheckContexts(input, allowReserve).pipe( + Effect.filterOrFail( + (all) => all.headSha === core.headSha, + () => + readError( + input.cwd, + "getPullRequestDetail", + new Error("Pull request head changed while reading checks."), + ), + ), + Effect.map((all) => ({ + ...core, + ...pullRequestChecksFromContexts(all.contexts), + checksTruncated: all.truncated, + })), + ), + ), + ), + ), + ); + }; + + const workflowApprovalLimit = 1_000; + const workflowApprovalReadError = (cwd: string, cause: unknown) => + readError(cwd, "listWorkflowRunsRequiringApproval", cause); + + /** Every open pull request whose head branch carries this name, up to one past the limit. */ + const listHeadsByBranch = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly headBranch: string; + }) => + Effect.gen(function* () { + const { owner, name } = parseRepositorySelector(input.repository); + const heads: GitHubPullRequestHead[] = []; + let after: string | null = null; + do { + const page: { + readonly heads: ReadonlyArray; + readonly nextCursor: string | null; + } = yield* graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listWorkflowRunsRequiringApproval", + allowReserve: true, + variables: { owner, name, head: input.headBranch, after }, + query: PULL_REQUEST_HEADS_GRAPHQL_QUERY, + decode: decodePullRequestHeadsJson, + }); + heads.push(...page.heads); + after = page.nextCursor; + } while (after !== null && heads.length <= workflowApprovalLimit); + return heads; + }); + + /** + * The runs waiting on a maintainer for this exact head, up to one past the limit. `head_sha` is + * what scopes them; `head_branch` is checked as well, the way `gh run list --branch` did. + */ + const listActionRequiredRuns = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly headSha: string; + readonly headBranch: string; + }) => + Effect.gen(function* () { + // The checks revalidator may have just confirmed every run of this head as current. + const known = yield* KnownWorkflowRuns; + if (known !== null && known.headSha === input.headSha) { + return known.runs.flatMap((run) => + // A run waiting on a maintainer reports `completed` with an `action_required` + // conclusion; the `status=action_required` query matches either. + (run.conclusion === "action_required" || run.status === "action_required") && + run.head_branch === input.headBranch + ? [ + { + id: run.id, + name: run.name?.trim() || `Workflow run ${run.id}`, + url: run.html_url?.trim() || null, + }, + ] + : [], + ); + } + const { owner, name } = parseRepositorySelector(input.repository); + const runs: GitHubWorkflowRunApproval[] = []; + for (let page = 1; runs.length <= workflowApprovalLimit; page++) { + const read: GitHubWorkflowRunPage = yield* restRead({ + cwd: input.cwd, + host: input.host, + operation: "listWorkflowRunsRequiringApproval", + 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}`, + decode: decodeWorkflowRunsJson, + }).pipe(Effect.mapError((error) => workflowApprovalReadError(input.cwd, error))); + runs.push(...read.runs); + if (read.rawCount < 100) break; + } + return runs; + }); + + const listWorkflowRunsRequiringApproval: GitHubPullRequestApi["Service"]["listWorkflowRunsRequiringApproval"] = + (input) => + Effect.all( + [ + listHeadsByBranch(input).pipe( + Effect.flatMap( + ( + heads, + ): Effect.Effect< + GitHubPullRequestHead, + GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError + > => { + const exactHeads = heads.filter( + (pullRequest) => + pullRequest.headSha === input.headSha && + pullRequest.isCrossRepository === true && + pullRequest.headRepositoryOwner?.toLowerCase() === + input.headRepositoryOwner.toLowerCase(), + ); + if (heads.length > workflowApprovalLimit) { + return Effect.fail( + new GitHubWorkflowApprovalRefusedError({ + cwd: input.cwd, + number: input.number, + reason: "head-list-truncated", + observedCount: heads.length, + limit: workflowApprovalLimit, + }), + ); + } + if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { + return Effect.fail( + new GitHubWorkflowApprovalRefusedError({ + cwd: input.cwd, + number: input.number, + reason: "head-not-unique", + observedCount: exactHeads.length, + limit: workflowApprovalLimit, + }), + ); + } + return Effect.succeed(exactHeads[0]); + }, + ), + ), + listActionRequiredRuns(input).pipe( + Effect.flatMap((runs) => + runs.length > workflowApprovalLimit + ? Effect.fail( + new GitHubWorkflowApprovalRefusedError({ + cwd: input.cwd, + number: input.number, + reason: "run-list-truncated", + observedCount: runs.length, + limit: workflowApprovalLimit, + }), + ) + : Effect.succeed(runs), + ), + ), + ], + { concurrency: 2 }, + ).pipe(Effect.map(([, runs]) => runs)); + + /** One pull request's summary, through the same aliased query the batch uses. */ + const viewPullRequestSummary = (input: PullRequestSummaryRead) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestSummary", + variables: { owner, name, number: input.number }, + query: pullRequestSummaryGraphQlQuery(input.host === "github.com"), + decode: decodePullRequestSummariesJson, + }).pipe( + Effect.flatMap((summaries) => { + const summary = summaries.get(0); + return summary === undefined + ? Effect.fail( + readError( + input.cwd, + "getPullRequestSummary", + new Error(`GitHub answered nothing for ${input.repository}#${input.number}.`), + ), + ) + : Effect.succeed(summary); + }), + ); + }; + + /** + * Summaries asked for together, on one host under one credential, share aliased GraphQL reads + * of twenty-five: the background sync reads every linked pull request each minute, and one + * `gh pr view` apiece is most of what it spends. Whatever the batch cannot answer — a selector + * GraphQL cannot address, a pull request GitHub returned nothing for — is read on its own. + */ + const summaryResolver = RequestResolver.makeGrouped({ + key: ({ request, context }) => + JSON.stringify([ + request.host.toLowerCase(), + Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) + ?.credentialFingerprint ?? null, + Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), + ]), + resolver: (entries) => { + const [first] = entries; + const batchable = entries.filter( + (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null, + ); + // Stack membership rides along where GitHub serves stacks, so the background sync can skip + // the REST stack read for pull requests that are in none. + const query = buildPullRequestSummariesGraphQlQuery( + batchable.map((entry) => entry.request), + first.request.host === "github.com", + ); + const batched = + query === null + ? Effect.succeed(new Map()) + : graphqlRead({ + cwd: first.request.cwd, + host: first.request.host, + operation: "getPullRequestSummary", + query, + decode: decodePullRequestSummariesJson, + }); + return batched.pipe( + // A GraphQL error anywhere fails the whole document — one repository gone or out of + // reach — so a batch that could not be read leaves every entry to its own read. A paused + // budget is the exception: reading one at a time would only spend what is being saved. + Effect.catchCauseIf( + (cause) => + !Cause.hasInterruptsOnly(cause) && + !Cause.findErrorOption(cause).pipe( + Option.exists((error) => error._tag === "SourceControlRateLimitPausedError"), + ), + (cause) => + Effect.logDebug("batched pull request summary read failed", { cause }).pipe( + Effect.as(new Map()), + ), + ), + Effect.flatMap((summaries) => { + const unanswered = entries.filter((entry) => { + const summary = summaries.get(batchable.indexOf(entry)); + if (summary === undefined) return true; + entry.completeUnsafe(Exit.succeed(summary)); + return false; + }); + return Effect.forEach( + unanswered, + (entry) => + viewPullRequestSummary(entry.request).pipe( + Effect.exit, + Effect.map((exit) => entry.completeUnsafe(exit)), + ), + { concurrency: STAT_REQUEST_CONCURRENCY, discard: true }, + ); + }), + Effect.catchCause((cause) => + Effect.sync(() => { + for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + }), + ), + ); + }, + }).pipe( + RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), + RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), + ); + const getPullRequestSummary: GitHubPullRequestApi["Service"]["getPullRequestSummary"] = (input) => + Effect.request(new PullRequestSummaryRead(input), summaryResolver); + + // Every watched pull request on a host in one read per pass. A pull request the batch has no + // answer for gets null, and its watch reads it in full; a failed batch fails every entry. + const watchFingerprintResolver = RequestResolver.makeGrouped< + PullRequestWatchFingerprintRead, + string + >({ + key: ({ request, context }) => + JSON.stringify([ + request.host.toLowerCase(), + Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) + ?.credentialFingerprint ?? null, + Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), + ]), + resolver: (entries) => { + const [first] = entries; + const batchable = entries.filter( + (entry) => buildPullRequestWatchFingerprintsGraphQlQuery([entry.request]) !== null, + ); + const query = buildPullRequestWatchFingerprintsGraphQlQuery( + batchable.map((entry) => entry.request), + ); + const read = + query === null + ? Effect.succeed(new Map()) + : graphqlRead({ + cwd: first.request.cwd, + host: first.request.host, + operation: "getPullRequestWatchFingerprint", + query, + decode: decodePullRequestWatchFingerprintsJson, + }); + return read.pipe( + Effect.map((fingerprints) => { + for (const entry of entries) { + const index = batchable.indexOf(entry); + entry.completeUnsafe( + Exit.succeed(index === -1 ? null : (fingerprints.get(index) ?? null)), + ); + } + }), + Effect.catchCause((cause) => + Effect.sync(() => { + for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + }), + ), + ); + }, + }).pipe( + RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), + RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), + ); + const getPullRequestWatchFingerprint: GitHubPullRequestApi["Service"]["getPullRequestWatchFingerprint"] = + (input) => Effect.request(new PullRequestWatchFingerprintRead(input), watchFingerprintResolver); + + return GitHubPullRequestApi.of({ + withVerifiedCredential, + revalidateChecks, + getRoutingIdentity, + getViewerLogin: (input) => + getRoutingIdentity(input).pipe(Effect.map((identity) => identity.viewer)), + + listPullRequests: (input) => { + const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS); + const { owner, name } = parseRepositorySelector(input.repository); + const query = searchQuery({ ...input, repositories: [input.repository] }); + if (query === null) { + return Effect.fail( + new GitHubRepositorySelectorError({ + cwd: input.cwd, + operation: "listPullRequests", + }), + ); + } + /** Pages of up to a hundred until `rows` have arrived or GitHub has no more. */ + const collect = ( + rows: number, + page: ( + after: string | null, + rows: number, + ) => Effect.Effect, + ) => + Effect.gen(function* () { + const items: GitHubPullRequestListItem[] = []; + let rawCount = 0; + let after: string | null = null; + do { + const read: GitHubPullRequestListPage = yield* page(after, rows - rawCount); + items.push(...read.items); + rawCount += read.rawCount; + after = read.endCursor; + } while (after !== null && rawCount < rows); + return { items, rawCount }; + }); + const read = ( + continues: boolean, + requestedRows = input.limit + 1, + ): Effect.Effect => + collect(requestedRows, (after, rows) => + continues + ? graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listPullRequests", + // The reader's own words are in the query. + variables: { q: query, after }, + query: pullRequestSearchGraphQlQuery(rows, false, true), + decode: decodePullRequestSearchJson, + }) + : graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listPullRequests", + variables: { owner, name, states: LIST_STATES[input.state], after }, + query: pullRequestListGraphQlQuery(rows), + decode: decodePullRequestListJson, + }), + ).pipe( + Effect.flatMap(({ items: rawItems, rawCount }) => { + const items = continues + ? rawItems + : rawItems.filter((item) => matchesUnsortedListing(item, input)); + if ( + !continues && + items.length < input.limit && + rawCount >= requestedRows && + requestedRows < fallbackMaxRows + ) { + const nextRows = Math.min(requestedRows * 2, fallbackMaxRows); + if (nextRows > requestedRows) return read(false, nextRows); + } + return Effect.succeed({ + items: items.slice(0, input.limit), + // One row over the page size is the probe for a next page, and it is + // counted before decoding: a skipped malformed row must not end paging. + truncated: continues + ? rawCount > input.limit + : items.length > input.limit || rawCount >= requestedRows, + continues, + }); + }), + ); + // GitHub does not index every repository for search, and one it will not search answers + // with no rows rather than with an error — so an empty listing is read again from the + // repository's own list. Those rows come back newest-created first, an order no `updated:` + // qualifier can carry on from, so that page says it cannot be continued and the reader + // reaches the rest of it by asking for a larger page, as every listing used to. + // + // Only ever the first slice: a repository that answered the search once will answer it + // again, so an empty slice under a cursor is a repository that has run out. + // A text search that finds nothing has found nothing: falling back would answer it with the + // repository's whole list, which is every row the reader did not search for. The fallback + // is for a repository the index does not cover, and a listing with no text to match is the + // only place an empty answer can mean that. + // Every filter is a qualifier `matchesFilters` can judge over the fallback's own rows just + // as well as search judges them over its own, so carrying them into the fallback answers + // the same read rather than a wider one. Free text is the one thing the fallback cannot + // judge locally — it lists rows, it does not search their text — so a query still rules + // the fallback out: an empty answer under one is already the answer. + const hasQuery = (input.query?.trim().length ?? 0) > 0; + return read(true).pipe( + Effect.filterOrElse( + (batch) => batch.items.length > 0 || input.cursor !== undefined || hasQuery, + () => read(false), + ), + Effect.flatMap((batch) => { + // Match the search query's host support, and enrich only rows that survived paging. + if (input.host !== "github.com" || batch.items.length === 0) return Effect.succeed(batch); + const chunks: Array> = []; + for (let start = 0; start < batch.items.length; start += STAT_ALIASES_PER_REQUEST) { + chunks.push(batch.items.slice(start, start + STAT_ALIASES_PER_REQUEST)); + } + return Effect.forEach( + chunks, + (chunk) => { + const query = buildPullRequestStackMembershipsGraphQlQuery( + input.repository, + chunk.map((item) => item.number), + ); + if (query === null) return Effect.succeed(chunk); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listPullRequestStackMemberships", + query, + decode: decodePullRequestStackMembershipsJson, + }).pipe( + Effect.map((memberships) => + chunk.map((item, index) => { + const stack = memberships.get(index); + return stack === undefined ? item : { ...item, stack }; + }), + ), + // Optional badges must not take down a listing that already read successfully. + Effect.catch(() => + Effect.logWarning("Pull request stack membership enrichment failed", { + operation: "listPullRequestStackMemberships", + host: input.host, + rows: chunk.length, + }).pipe(Effect.as(chunk)), + ), + ); + }, + { concurrency: STAT_REQUEST_CONCURRENCY }, + ).pipe(Effect.map((chunks) => ({ ...batch, items: chunks.flat() }))); + }), + ); + }, + + searchPullRequests: (input) => { + const query = searchQuery(input); + if (query === null) { + return Effect.fail( + new GitHubRepositorySelectorError({ + cwd: input.cwd, + operation: "searchPullRequests", + }), + ); + } + // One extra row reveals that the host has more than the slice shows, the way the + // per-repository read does — up to GitHub's own ceiling on a search page, past which + // `hasNextPage` is what says there is more. + const rows = Math.min(input.limit + 1, PULL_REQUEST_SEARCH_MAX_ROWS); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "searchPullRequests", + variables: { q: query, after: null }, + query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"), + decode: decodePullRequestSearchJson, + }).pipe( + Effect.map((batch) => ({ + items: batch.items.slice(0, input.limit), + truncated: batch.rawCount > input.limit || batch.hasNextPage, + })), + ); + }, + + listPullRequestStats: (input) => { + const chunks: Array> = + []; + for (let start = 0; start < input.changeRequests.length; start += STAT_ALIASES_PER_REQUEST) { + chunks.push(input.changeRequests.slice(start, start + STAT_ALIASES_PER_REQUEST)); + } + return Effect.forEach( + chunks, + (chunk) => { + const query = buildPullRequestStatsGraphQlQuery(chunk); + if (query === null) { + return Effect.fail( + new GitHubRepositorySelectorError({ + cwd: input.cwd, + operation: "listPullRequestStats", + }), + ); + } + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listPullRequestStats", + query, + decode: decodePullRequestStatsJson, + }).pipe( + Effect.map((stats) => + chunk.flatMap((changeRequest, index) => { + const stat = stats.get(index); + return stat === undefined ? [] : [{ ...changeRequest, ...stat }]; + }), + ), + ); + }, + { concurrency: STAT_REQUEST_CONCURRENCY }, + ).pipe(Effect.map((results) => results.flat())); + }, + + getPullRequestSummary, + getPullRequestWatchFingerprint, + + getPullRequestDetail, + getPullRequestPreview: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestPreview", + variables: { owner, name, number: input.number }, + query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, + decode: decodePullRequestPreviewJson, + }); + }, + listWorkflowRunsRequiringApproval, + + getPullRequestStack: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return restRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestStack", + path: `repos/${owner}/${name}/stacks?pull_request=${input.number}`, + decode: decodePullRequestStacksJson, + }).pipe( + // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine. + Effect.flatMap((stack) => { + if (!input.includeDetails || stack === null) return Effect.succeed(stack); + return restRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestStack", + path: `repos/${owner}/${name}/stacks/${stack.number}`, + decode: (raw) => decodePullRequestStacksJson(`[${raw}]`), + }); + }), + // Hosts without the stacks preview return 404. Other failures must preserve the + // previously synced stack and let the caller retry. + Effect.catchTags({ + GitHubApiNotFoundError: () => Effect.succeed(null), + }), + ); + }, + + getPullRequestActivity: (input) => + Effect.gen(function* () { + const { owner, name } = parseRepositorySelector(input.repository); + let author: PullRequestActor | null = null; + let commits: ReadonlyArray = []; + const remarks: PullRequestComment[] = []; + let commentsAfter: string | null = null; + let reviewsAfter: string | null = null; + let withComments = true; + let withReviews = true; + // Both remark lists page on their own; a page asks only for the ones with more to give. + for (let page = 0; page < REVIEW_THREAD_PAGES && (withComments || withReviews); page++) { + const read: GitHubPullRequestActivityPage = yield* graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestActivity", + variables: { + owner, + name, + number: input.number, + head: page === 0, + withComments, + commentsAfter, + withReviews, + reviewsAfter, + }, + query: PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, + decode: decodePullRequestActivityJson, + }); + if (page === 0) { + author = read.author ?? null; + commits = read.commits ?? []; + } + remarks.push(...read.remarks); + commentsAfter = read.nextCommentsCursor; + reviewsAfter = read.nextReviewsCursor; + withComments = withComments && commentsAfter !== null; + withReviews = withReviews && reviewsAfter !== null; + } + return { + author, + comments: remarks.toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + commits, + } satisfies GitHubPullRequestActivity; + }), + + getPullRequestDiff: (input) => { + const filesPage = (page: number) => + diffFilesPage({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + page, + ...(input.commit === undefined ? {} : { commit: input.commit }), + }); + if (input.commit !== undefined && !isCommitSha(input.commit)) { + return Effect.fail(new GitHubDiffCommitError({ cwd: input.cwd })); + } + // A cursor only ever comes from the files walk, so a reader carrying one is already past + // the point where `gh pr diff` had anything to say. + if (input.cursor !== undefined) { + const page = diffCursorPage(input.cursor); + return page === null + ? Effect.fail(new GitHubDiffCursorError({ cwd: input.cwd })) + : filesPage(page); + } + // `gh pr diff` speaks for the whole pull request and has no way to name one commit of it. + if (input.commit !== undefined) { + return filesPage(1); + } + const { owner, name } = parseRepositorySelector(input.repository); + return api + .rest({ + host: input.host, + operation: "getPullRequestDiff", + path: `repos/${owner}/${name}/pulls/${input.number}`, + accept: "application/vnd.github.diff", + maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, + timeout: DIFF_TIMEOUT, + }) + .pipe( + Effect.flatMap((response) => + // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor + // something the reader can carry on from. The files API can serve the same change a + // whole number of files at a time, so an oversized patch takes that road as well. + response.truncated + ? filesPage(1) + : // One read served the whole patch, so there is no next slice to ask for. + Effect.succeed({ patch: response.body, truncated: false, nextCursor: null }), + ), + // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read + // from the files API instead, a page per call. Only once the direct read has failed: a + // pull request GitHub will serve a diff for must not pay for a second request. A + // fallback that fails too reports the original refusal, which is the one that explains + // the page. Narrowed to a request GitHub answered and refused: a missing credential or + // a rate limit fails the same way for every request. + Effect.catchTags({ + GitHubApiResponseError: (error) => filesPage(1).pipe(Effect.mapError(() => error)), + }), + ); + }, + + getPullRequestDiffFileContents, + + getReviewThreadComments: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getReviewThreadComments", + variables: { + owner, + name, + number: input.number, + threadId: input.threadId, + cursor: input.cursor, + }, + query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, + decode: decodeReviewThreadCommentsJson, + }).pipe( + Effect.flatMap(({ belongsToPullRequest, comments, nextCursor }) => + belongsToPullRequest + ? Effect.succeed({ comments, nextCursor }) + : Effect.fail( + new GitHubSubjectScopeError({ + cwd: input.cwd, + operation: "getReviewThreadComments", + }), + ), + ), + ); + }, + + listReviewThreadComments: (input) => + Effect.gen(function* () { + const { owner, name } = parseRepositorySelector(input.repository); + const threadPage = ( + cursor: string | null, + ): Effect.Effect => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listReviewThreadComments", + variables: { owner, name, number: input.number, cursor }, + query: REVIEW_THREADS_GRAPHQL_QUERY, + decode: decodeReviewThreadsJson, + }); + const entries: GitHubReviewThreadEntry[] = []; + const avatarsByLogin = new Map(); + const botLogins = new Set(); + const commitStats = new Map< + string, + { readonly additions: number; readonly deletions: number } + >(); + let reviewers: ReadonlyArray = []; + let reactions: GitHubReviewThreadPage["reactions"] = []; + const reactionsById = new Map>(); + const editedAtById = new Map(); + let commits: GitHubReviewThreadPage["commits"] = []; + let viewer: GitHubReviewThreadPage["viewer"] = { canUpdate: true, didAuthor: false }; + const dismissalsByReviewId = new Map(); + let dismissalCursor: string | null = null; + let cursor: string | null = null; + let page = 0; + do { + const read: GitHubReviewThreadPage = yield* threadPage(cursor); + entries.push(...read.threads); + for (const login of read.botLogins) botLogins.add(login); + for (const [login, avatarUrl] of read.avatarsByLogin) + avatarsByLogin.set(login, avatarUrl); + // The roster, the commits and the viewer's standing travel with every page, and the + // first one already carries all of them. + if (page === 0) { + reviewers = read.reviewers; + reactions = read.reactions; + for (const [id, entry] of read.reactionsById) reactionsById.set(id, entry); + for (const [id, editedAt] of read.editedAtById) editedAtById.set(id, editedAt); + commits = read.commits; + viewer = read.viewer; + for (const [id, message] of read.dismissalsByReviewId) + dismissalsByReviewId.set(id, message); + dismissalCursor = read.nextDismissalCursor; + for (const [oid, stat] of read.commitStats) commitStats.set(oid, stat); + } + cursor = read.nextCursor; + page += 1; + } while (cursor !== null && page < REVIEW_THREAD_PAGES); + + // Almost never entered: the embedded page already holds every dismissal a pull request + // ordinarily accrues. Followed so a review whose event fell past that page still finds + // its reason. + let dismissalPage = 0; + while (dismissalCursor !== null && dismissalPage < REVIEW_THREAD_PAGES) { + const read: { + readonly dismissalsByReviewId: ReadonlyMap; + readonly nextCursor: string | null; + } = yield* graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listReviewThreadComments", + variables: { owner, name, number: input.number, cursor: dismissalCursor }, + query: REVIEW_DISMISSALS_GRAPHQL_QUERY, + decode: decodeReviewDismissalsJson, + }); + for (const [id, message] of read.dismissalsByReviewId) + dismissalsByReviewId.set(id, message); + dismissalCursor = read.nextCursor; + dismissalPage += 1; + } + + const reviewThreads = entries.map((entry) => ({ + ...entry.thread, + commentCount: entry.commentCount, + ...(entry.nextCommentCursor === null + ? {} + : { nextCommentsCursor: entry.nextCommentCursor }), + })); + return { + comments: reviewThreadConversation(reviewThreads), + dismissalsByReviewId, + reviewThreads, + // GitHub's own count of each thread, so the number the page shows is the host's even + // where a bound kept some of the words on GitHub. + commentCount: entries.reduce((total, entry) => total + entry.commentCount, 0), + truncated: cursor !== null || entries.some((entry) => entry.nextCommentCursor !== null), + reviewThreadsTruncated: cursor !== null, + reactions, + reactionsById, + editedAtById, + reviewers, + avatarsByLogin, + botLogins, + commitStats, + commits, + viewer, + }; + }), + + listActorAvatars: (input) => { + if (input.ids.length === 0) { + return Effect.succeed(new Map()); + } + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listActorAvatars", + variables: { ids: input.ids }, + query: ACTOR_AVATARS_GRAPHQL_QUERY, + decode: decodeActorAvatarsJson, + }); + }, + + getViewerAccess: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getViewerAccess", + ...(input.allowReserve === true ? { allowReserve: true } : {}), + variables: { owner, name, number: input.number }, + query: VIEWER_PERMISSIONS_GRAPHQL_QUERY, + decode: decodeViewerPermissionsJson, + }); + }, + + listReviewerCandidates: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listReviewerCandidates", + allowReserve: true, + variables: { owner, name, number: input.number }, + query: REVIEWER_CANDIDATES_GRAPHQL_QUERY, + decode: decodeReviewerCandidatesJson, + }); + }, + + setReviewerRequest: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + // Posting to a login GitHub has already been asked about is what a re-request is, so + // there is nothing to say here about somebody who has reviewed once already. + return api + .rest({ + host: input.host, + operation: "setReviewerRequest", + method: input.requested ? "POST" : "DELETE", + path: `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`, + body: buildReviewerRequest(input.reviewers), + }) + .pipe(Effect.asVoid); + }, + + listLabelCandidates: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listLabelCandidates", + allowReserve: true, + variables: { owner, name, number: input.number }, + query: LABEL_CANDIDATES_GRAPHQL_QUERY, + decode: decodeLabelCandidatesJson, + }); + }, + + setLabels: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + // A pull request is an issue to the labels API. Adding posts a list and leaves what was + // already there; taking off is one delete per label, since the endpoint names one in its + // path. The name goes into the path encoded, because a label may carry a space or a slash. + const issue = `repos/${owner}/${name}/issues/${input.number}/labels`; + if (input.applied) { + return api + .rest({ + host: input.host, + operation: "setLabels", + method: "POST", + path: issue, + body: buildLabelRequest(input.labels), + }) + .pipe(Effect.asVoid); + } + return Effect.forEach( + input.labels, + (label) => + api.rest({ + host: input.host, + operation: "setLabels", + method: "DELETE", + path: `${issue}/${encodeURIComponent(label)}`, + }), + { concurrency: 1, discard: true }, + ); + }, + + runPullRequestAction: (input) => { + if (input.stackNumber !== undefined) + return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( + Effect.provideService(GitHubApi.GitHubApi, api), + Effect.provideService(VcsProcess.VcsProcess, vcsProcess), + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + if (input.action === "revert") { + return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( + Effect.flatMap((pullRequestId) => + graphql({ + host: input.host, + operation: "revertPullRequest", + query: REVERT_PULL_REQUEST_GRAPHQL_MUTATION, + variables: { pullRequestId }, + }), + ), + ); + } + if (input.action === "approve-workflows") { + const { owner, name } = parseRepositorySelector(input.repository); + return getPullRequestDetail(input).pipe( + Effect.flatMap((detail) => { + if (detail.isCrossRepository !== true) return Effect.void; + if (detail.headSha == null || detail.headRepositoryOwner == null) { + return Effect.fail( + new GitHubWorkflowApprovalHeadUnavailableError({ + cwd: input.cwd, + number: input.number, + }), + ); + } + const expectedHeadSha = detail.headSha; + const expectedHeadBranch = detail.headBranch; + const expectedHeadRepositoryOwner = detail.headRepositoryOwner; + return listWorkflowRunsRequiringApproval({ + ...input, + headSha: expectedHeadSha, + headBranch: expectedHeadBranch, + headRepositoryOwner: expectedHeadRepositoryOwner, + isCrossRepository: true, + }).pipe( + Effect.flatMap((runs) => + Effect.forEach( + runs, + (run) => + getPullRequestDetail(input).pipe( + Effect.flatMap((current) => { + if (current.headSha == null || current.headRepositoryOwner == null) { + return Effect.fail( + new GitHubWorkflowApprovalHeadUnavailableError({ + cwd: input.cwd, + number: input.number, + }), + ); + } + if ( + current.isCrossRepository !== true || + current.headSha !== expectedHeadSha || + current.headBranch !== expectedHeadBranch || + current.headRepositoryOwner.toLowerCase() !== + expectedHeadRepositoryOwner.toLowerCase() + ) { + return Effect.fail( + new GitHubWorkflowApprovalHeadChangedError({ + cwd: input.cwd, + number: input.number, + }), + ); + } + return listWorkflowRunsRequiringApproval({ + ...input, + headSha: current.headSha, + headBranch: current.headBranch, + headRepositoryOwner: current.headRepositoryOwner, + isCrossRepository: true, + }); + }), + Effect.flatMap((currentRuns) => + currentRuns.some((current) => current.id === run.id) + ? api + .rest({ + host: input.host, + operation: "approveWorkflowRun", + method: "POST", + path: `repos/${owner}/${name}/actions/runs/${run.id}/approve`, + }) + .pipe(Effect.asVoid) + : Effect.void, + ), + ), + { concurrency: 1, discard: true }, + ), + ), + ); + }), + ); + } + const action = input.action; + if (action in SIMPLE_ACTION_MUTATIONS) { + return pullRequestNodeId({ ...input, operation: "runPullRequestAction" }).pipe( + Effect.flatMap((pullRequestId) => + graphql({ + host: input.host, + operation: "runPullRequestAction", + query: SIMPLE_ACTION_MUTATIONS[action as keyof typeof SIMPLE_ACTION_MUTATIONS], + variables: { pullRequestId }, + }), + ), + ); + } + return Effect.gen(function* () { + const { owner, name } = parseRepositorySelector(input.repository); + // Read fresh rather than from the node id cache: merging and updating act on the head + // as it stands now, and the merge queue decides which mutation a merge is. + const state = yield* graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "runPullRequestAction", + allowReserve: true, + query: ACTION_STATE_GRAPHQL_QUERY, + variables: { + owner, + name, + number: input.number, + headRef: `refs/pull/${input.number}/head`, + }, + decode: decodeActionState, + }); + if (action === "update-branch") { + // Already current is done, the way `gh pr update-branch` reports it. + if (state.baseRef?.compare?.behindBy === 0) return; + // GitHub updates with a merge commit unless asked to rebase, which is its own default. + return yield* graphql({ + host: input.host, + operation: "runPullRequestAction", + query: UPDATE_BRANCH_GRAPHQL_MUTATION, + variables: { + pullRequestId: state.id, + expectedHeadOid: state.headRefOid, + updateMethod: input.updateMethod === "rebase" ? "REBASE" : "MERGE", + }, + }); + } + let body: string | undefined; + let expectedHead: string | undefined; + if (input.removeAgentCreditsOnMerge === true && input.mergeMethod !== "rebase") { + const message = yield* graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "runPullRequestAction", + allowReserve: true, + query: MERGE_MESSAGE_GRAPHQL_QUERY, + variables: { + owner, + name, + number: input.number, + method: input.mergeMethod === "squash" ? "SQUASH" : "MERGE", + }, + decode: decodeMergeMessage, + }); + // GitHub's merge queue chooses its own message and ignores custom text. + if (!message.isMergeQueueEnabled) { + const cleaned = removeAgentCredits(message.viewerMergeBodyText); + if (cleaned !== message.viewerMergeBodyText) { + body = cleaned; + expectedHead = message.headRefOid; + } + } + } + // A merge queue takes a pull request through auto-merge rather than a direct merge, and + // `--auto` on a pull request that is mergeable right now simply merges it, as `gh` does. + // GitHub stores the strategy with a standing instruction rather than choosing one at + // merge time, so arming still names it. + const auto = + state.isMergeQueueEnabled === true || + (action === "enable-auto-merge" && + !IMMEDIATELY_MERGEABLE.has(state.mergeStateStatus?.toUpperCase() ?? "")); + yield* graphql({ + host: input.host, + operation: "runPullRequestAction", + query: auto ? ENABLE_AUTO_MERGE_GRAPHQL_MUTATION : MERGE_PULL_REQUEST_GRAPHQL_MUTATION, + variables: { + input: { + pullRequestId: state.id, + mergeMethod: GRAPHQL_MERGE_METHODS[input.mergeMethod ?? "merge"], + ...(expectedHead === undefined ? {} : { expectedHeadOid: expectedHead }), + ...(body === undefined ? {} : { commitBody: body }), + }, + }, + }); + }); + }, + + commentOnPullRequest: (input) => + pullRequestNodeId({ ...input, operation: "commentOnPullRequest" }).pipe( + Effect.flatMap((subjectId) => + graphql({ + host: input.host, + operation: "commentOnPullRequest", + query: ADD_COMMENT_GRAPHQL_MUTATION, + variables: { subjectId, body: input.body }, + }), + ), + ), + + submitReview: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + // The whole review is one request, so nothing is visible to anyone else until the verdict + // is sent. + return api + .rest({ + host: input.host, + operation: "submitReview", + method: "POST", + path: `repos/${owner}/${name}/pulls/${input.number}/reviews`, + body: buildReviewSubmission({ + verdict: input.verdict, + body: input.body, + comments: input.comments, + }), + }) + .pipe(Effect.asVoid); + }, + + replyToReviewThread: (input) => + graphql({ + host: input.host, + operation: "replyToReviewThread", + query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, + variables: { threadId: input.threadId, body: input.body }, + }), + + getPullRequestFilesViewed: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + const read = ( + after: string | null, + collected: ReadonlyArray, + pagesLeft: number, + ): Effect.Effect => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestFilesViewed", + variables: { owner, name, number: input.number, after }, + query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, + decode: decodePullRequestFilesViewedJson, + }).pipe( + Effect.flatMap((page) => { + const files = [...collected, ...page.files]; + if (page.nextCursor === null) { + return Effect.succeed({ files, truncated: false }); + } + return pagesLeft <= 1 + ? Effect.succeed({ files, truncated: true }) + : read(page.nextCursor, files, pagesLeft - 1); + }), + ); + return read(null, [], FILES_VIEWED_MAX_PAGES); + }, + + setPullRequestFilesViewed: (input) => { + const mutation = buildSetFilesViewedGraphQlMutation(input.files); + if (mutation === null) return Effect.void; + return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe( + Effect.flatMap((pullRequestId) => + graphql({ + host: input.host, + operation: "setPullRequestFilesViewed", + query: mutation.query, + variables: { pullRequestId, ...mutation.variables }, + }), + ), + ); + }, + + setReviewThreadResolution: (input) => + graphql({ + host: input.host, + operation: "setReviewThreadResolution", + query: input.resolved + ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION + : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, + variables: { threadId: input.threadId }, + }), + + setReaction: (input) => { + const givenSubjectId = input.subjectId; + const subjectId = + givenSubjectId === undefined + ? pullRequestNodeId({ ...input, operation: "setReaction" }) + : subjectBelongsToPullRequest({ + ...input, + subjectId: givenSubjectId, + operation: "setReaction", + }).pipe( + Effect.flatMap((belongs) => + belongs + ? Effect.succeed(givenSubjectId) + : Effect.fail( + new GitHubSubjectScopeError({ + cwd: input.cwd, + operation: "setReaction", + }), + ), + ), + ); + return subjectId.pipe( + Effect.flatMap((subjectId) => + graphql({ + host: input.host, + operation: "setReaction", + query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION, + variables: { subjectId, content: gitHubReactionContent(input.content) }, + }), + ), + ); + }, + + updatePullRequest: (input) => + pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe( + Effect.flatMap((pullRequestId) => + graphql({ + host: input.host, + operation: "updatePullRequest", + query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, + // A field the caller did not name is left out of the request entirely, so GitHub + // keeps the words that are there rather than being asked for an empty one. + variables: { + pullRequestId, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + }, + }), + ), + ), + + updateComment: (input) => + subjectBelongsToPullRequest({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + subjectId: input.commentId, + operation: "updateComment", + }).pipe( + Effect.flatMap((belongs) => + belongs + ? Effect.succeed(input.commentId) + : Effect.fail( + new GitHubSubjectScopeError({ + cwd: input.cwd, + operation: "updateComment", + }), + ), + ), + Effect.flatMap((commentId) => + graphql({ + host: input.host, + operation: "updateComment", + query: + input.kind === "issue-comment" + ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION + : UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, + variables: { commentId, body: input.body }, + }), + ), + ), + }); +}); + +export const layer = Layer.effect(GitHubPullRequestApi, make); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts deleted file mode 100644 index 28aca8b6a9b37180b049b800942529c9c76f253d..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ /dev/null @@ -1,4545 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import { afterEach, assert, expect, it, vi } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Clock from "effect/Clock"; -import * as Deferred from "effect/Deferred"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; -import * as Redacted from "effect/Redacted"; - -import { HttpClient, HttpClientResponse } from "effect/http"; - -import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; -import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; -import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; - -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); - -const coreResponse = (pullRequest: Readonly> = {}) => ({ - data: { - repository: { - mergeCommitAllowed: true, - squashMergeAllowed: false, - rebaseMergeAllowed: true, - viewerPermission: "WRITE", - pullRequest: { - number: 7, - title: "Pull request 7", - url: "https://github.com/acme/web/pull/7", - headRefName: "feature", - baseRefName: "main", - headRefOid: "abc123", - state: "OPEN", - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - viewerCanUpdate: true, - viewerDidAuthor: false, - viewerCanUpdateBranch: true, - baseRef: { compare: { behindBy: 2 } }, - reviewRequests: { nodes: [] }, - labels: { nodes: [] }, - commits: { nodes: [] }, - ...pullRequest, - }, - }, - }, -}); - -/** One request the CLI made, as the transport received it. */ -type ApiCall = - | ({ readonly kind: "graphql" } & GitHubApi.GitHubGraphQlInput) - | ({ readonly kind: "rest" } & GitHubApi.GitHubRestInput); - -type ApiAnswer = Effect.Effect; - -const mockedExecute = vi.fn<(call: ApiCall) => ApiAnswer>(); -const defaultCredential = (host: string) => - Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:token` }); -const mockedCredential = vi.fn(defaultCredential); -const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() => - Effect.succeed(output('{"data":{}}')), -); - -/** - * A transport that answers from the mocks above but spends the real GraphQL budget, the way - * GitHubApi does, so the reserve and pause behaviour is still the module's to prove. - */ -const mockApi = Layer.effect( - GitHubApi.GitHubApi, - Effect.gen(function* () { - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - return GitHubApi.GitHubApi.of({ - graphql: (input) => - budget - .query(input.host, input.query, input.allowReserve ? { allowReserve: true } : undefined) - .pipe( - Effect.flatMap((query) => - (input.query.includes("query PullRequestStackMemberships") - ? mockedStackMemberships - : mockedExecute)({ kind: "graphql", ...input, query }), - ), - Effect.map((answer) => answer.body), - Effect.tap((body) => budget.observe(input.host, body)), - ), - rest: (input) => mockedExecute({ kind: "rest", ...input }), - credential: (host) => mockedCredential(host), - }); - }), -); - -const layer = it.layer( - GitHubPullRequestCli.layer.pipe( - Layer.provideMerge(mockApi), - Layer.provideMerge(GitHubGraphQlBudget.layer), - Layer.provide(NodeCrypto.layer), - Layer.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))), - ), -); - -/** A successful answer; GraphQL reads take its body. */ -function output( - body: string, - truncated = false, - invalidUtf8 = false, -): GitHubApi.GitHubRestResponse { - return { status: 200, headers: {}, body, truncated, invalidUtf8 }; -} - -/** - * Listing rows as GraphQL answers them. Overrides may use the flat shapes a test reads most - * easily: `reviewRequests` as `[{ login }]` or `[{ slug }]`, and `checks` as the rollup state. - */ -function rows( - count: number, - firstNumber: number, - overrides: (number: number) => Readonly> = () => ({}), -): ReadonlyArray> { - return Array.from({ length: count }, (_, index) => { - const number = firstNumber + index; - const { reviewRequests, checks, ...rest } = overrides(number) as { - reviewRequests?: ReadonlyArray>; - checks?: string; - } & Record; - return { - number, - title: `Pull request ${number}`, - url: `https://github.com/acme/web/pull/${number}`, - headRefName: "feat/page", - baseRefName: "main", - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - repository: { nameWithOwner: "acme/web" }, - ...(reviewRequests === undefined - ? {} - : { - reviewRequests: { - nodes: reviewRequests.map((requestedReviewer) => ({ requestedReviewer })), - }, - }), - ...(checks === undefined - ? {} - : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }), - ...rest, - }; - }); -} - -/** A page of the searched listing. */ -function pullRequests( - count: number, - firstNumber: number, - overrides?: (number: number) => Readonly>, -): string { - return encodeJson({ - data: { - search: { pageInfo: { hasNextPage: false }, nodes: rows(count, firstNumber, overrides) }, - }, - }); -} - -/** A page of a repository's own list, which is what the search-free fallback reads. */ -function listedPullRequests( - count: number, - firstNumber: number, - overrides?: (number: number) => Readonly>, -): string { - return encodeJson({ - data: { - repository: { - pullRequests: { - pageInfo: { hasNextPage: false }, - nodes: rows(count, firstNumber, overrides), - }, - }, - }, - }); -} - -/** A search that found nothing, which is also what GitHub says for a repository it does not index. */ -const emptySearch = () => output(pullRequests(0, 1)); -const emptyList = () => output(listedPullRequests(0, 1)); - -function pullRequestFiles(count: number, firstIndex: number): string { - return encodeJson( - Array.from({ length: count }, (_, index) => ({ - filename: `src/file${firstIndex + index}.ts`, - status: "modified", - patch: "@@ -1 +1 @@\n-old\n+new", - })), - ); -} - -/** One thread's comments as the GraphQL read returns them, cursor and all. */ -function threadComments( - ids: ReadonlyArray, - endCursor: string | null, - totalCount = ids.length, -) { - return { - totalCount, - pageInfo: { hasNextPage: endCursor !== null, endCursor }, - nodes: ids.map((id) => ({ id, body: id, createdAt: "2026-07-01T00:00:00Z" })), - }; -} - -function thread(id: string, ...commentIds: ReadonlyArray) { - return { - id, - path: "src/a.ts", - line: 1, - diffSide: "RIGHT", - isResolved: false, - isOutdated: false, - comments: threadComments(commentIds, null), - }; -} - -function reviewThreadsPage( - nodes: ReadonlyArray>, - endCursor: string | null, -): string { - return encodeJson({ - data: { - repository: { - pullRequest: { - reviewThreads: { - totalCount: nodes.length, - pageInfo: { hasNextPage: endCursor !== null, endCursor }, - nodes, - }, - }, - }, - }, - }); -} - -function threadCommentsPage( - ids: ReadonlyArray, - endCursor: string | null, - totalCount: number, - pullRequestId = "PR_7", -): string { - return encodeJson({ - data: { - repository: { pullRequest: { id: "PR_7" } }, - node: { - pullRequest: { id: pullRequestId }, - comments: threadComments(ids, endCursor, totalCount), - }, - }, - }); -} - -/** What GitHub answers for a pull request it will not serve a whole diff for. */ -const diffRefused = new GitHubApi.GitHubApiResponseError({ - host: "github.com", - operation: "getPullRequestDiff", - status: 406, -}); - -/** The nth request the CLI made. */ -function callAt(index: number): ApiCall { - const call = mockedExecute.mock.calls[index]; - assert.isDefined(call); - return call[0]; -} - -/** The GraphQL variables of the nth request. */ -function varsAt(index: number): Readonly> { - const call = callAt(index); - assert.strictEqual(call.kind, "graphql"); - return call.kind === "graphql" ? (call.variables ?? {}) : {}; -} - -/** The GraphQL document of the nth request. */ -function queryAt(index: number): string { - const call = callAt(index); - return call.kind === "graphql" ? call.query : ""; -} - -/** The REST path of the nth request. */ -function pathAt(index: number): string { - const call = callAt(index); - return call.kind === "rest" ? call.path : ""; -} - -/** The search the nth request carried, or undefined for a read that did not search. */ -function searchOfCall(index: number): string | undefined { - const q = callAt(index).kind === "graphql" ? varsAt(index)["q"] : undefined; - return typeof q === "string" ? q : undefined; -} - -/** One row as a search answers it, which is the listing's row one connection deeper. */ -function searchItem(number: number, repository: string, updatedAt: string) { - return { - number, - title: `Pull request ${number}`, - url: `https://github.com/${repository}/pull/${number}`, - author: { login: "octocat", avatarUrl: "https://avatars/octocat" }, - headRefName: "feat/page", - baseRefName: "main", - state: "OPEN", - isDraft: false, - mergeable: "MERGEABLE", - createdAt: "2026-07-01T00:00:00Z", - updatedAt, - repository: { nameWithOwner: repository }, - reviewRequests: { nodes: [{ requestedReviewer: { login: "hubot" } }] }, - labels: { nodes: [{ name: "bug", color: "ff0000" }] }, - }; -} - -function searchPage(nodes: ReadonlyArray, hasNextPage = false) { - return output(encodeJson({ data: { search: { pageInfo: { hasNextPage }, nodes } } })); -} - -const searchQueryOfCall = searchOfCall; - -/** - * Answers every request with the body of the first route whose needle its GraphQL document or - * REST path contains, and anything unrouted with an empty object, which a mutation never reads. - */ -function route(...routes: ReadonlyArray): void { - mockedExecute.mockImplementation((call) => { - const target = call.kind === "graphql" ? call.query : call.path; - const match = routes.find(([needle]) => target.includes(needle)); - return Effect.succeed(output(match === undefined ? "{}" : encodeJson(match[1]))); - }); -} - -/** The variables of every GraphQL request whose document contains `needle`, in order. */ -function variablesOf(needle: string): ReadonlyArray>> { - return mockedExecute.mock.calls.flatMap(([call]) => - call.kind === "graphql" && call.query.includes(needle) ? [call.variables ?? {}] : [], - ); -} - -/** Every REST request whose path contains `needle`, in order. */ -function restCallsTo(needle: string): ReadonlyArray { - return mockedExecute.mock.calls.flatMap(([call]) => - call.kind === "rest" && call.path.includes(needle) ? [call] : [], - ); -} - -/** - * The check that a client-given node hangs off the pull request it names. Routed before the node - * id lookup, whose document it contains. - */ -const SUBJECT_SCOPE_QUERY = "node(id: $subjectId)"; -const subjectScope = (subjectId: string, pullRequestId: string) => ({ - data: { - repository: { pullRequest: { id: pullRequestId } }, - node: { id: subjectId, pullRequest: { id: pullRequestId } }, - }, -}); - -/** One page of a head commit's check contexts, the read a rollup past one page is walked by. */ -const checkContextsPage = ( - nodes: ReadonlyArray>, - endCursor: string | null, - headRefOid = "abc123", -) => - encodeJson({ - data: { - repository: { - pullRequest: { - headRefOid, - commits: { - nodes: [ - { - commit: { - statusCheckRollup: { - contexts: { nodes, pageInfo: { hasNextPage: endCursor !== null, endCursor } }, - }, - }, - }, - ], - }, - }, - }, - }, - }); - -/** A pull request's base and head, as the REST read a file expansion starts from answers them. */ -const pullRequestRefs = encodeJson({ base: { sha: "a1b2c3d" }, head: { sha: "b1c2d3e" } }); - -/** A cross-repository pull request whose head waits on a maintainer to run its workflows. */ -const crossRepositoryDetail = (headRefOid = "abc123") => - coreResponse({ - headRefName: "feat/page", - headRefOid, - isCrossRepository: true, - headRepositoryOwner: { login: "octocat" }, - }); - -/** Open pull requests on one head branch, every one of them from the same fork head. */ -const heads = (numbers: ReadonlyArray) => ({ - data: { - repository: { - pullRequests: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: numbers.map((number) => ({ - number, - headRefOid: "abc123", - isCrossRepository: true, - headRepositoryOwner: { login: "octocat" }, - })), - }, - }, - }, -}); - -const workflowRuns = (ids: ReadonlyArray) => ({ - workflow_runs: ids.map((id) => ({ - id, - name: id === 10 ? "build" : `run ${id}`, - html_url: `https://example.com/${id}`, - })), -}); - -/** Answers the reads an approval makes: the detail, the heads, the runs; approvals return nothing. */ -function workflowApprovalRoutes( - detail: () => unknown, - headsAnswer: unknown, - runsAnswer: unknown, -): void { - mockedExecute.mockImplementation((call) => - Effect.sync(() => - output( - call.kind === "rest" - ? call.path.endsWith("/approve") - ? "" - : encodeJson(runsAnswer) - : call.query.includes("headRefName: $head") - ? encodeJson(headsAnswer) - : encodeJson(detail()), - ), - ), - ); -} - -/** The pull request node id lookup, which the layer caches for every test after the first. */ -const NODE_ID_QUERY = "pullRequest(number: $number) { id }"; -const nodeIdAnswer = (id: string) => ({ data: { repository: { pullRequest: { id } } } }); - -afterEach(() => { - mockedExecute.mockReset(); - mockedStackMemberships.mockReset(); - mockedCredential.mockReset(); - mockedCredential.mockImplementation(defaultCredential); -}); - -it.effect( - "keeps a verified credential through an auth switch and separates token fingerprints", - () => - Effect.gen(function* () { - let activeToken = "broad-credential"; - const requests: Array<{ readonly url: string; readonly authorization: string | undefined }> = - []; - const credentials = Layer.succeed( - GitHubCredentials.GitHubCredentials, - GitHubCredentials.GitHubCredentials.of({ - get: (host) => - Effect.sync(() => ({ - host, - token: Redacted.make(activeToken), - source: "gh" as const, - fingerprint: `${host}:${activeToken.length}${activeToken.at(-1)}`, - })), - invalidate: () => Effect.void, - }), - ); - const http = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.sync(() => { - requests.push({ url: request.url, authorization: request.headers["authorization"] }); - const body = request.url.endsWith("/user") - ? { id: 123, login: "same-account" } - : { data: { repository: { pullRequest: { id: "PR_1" } }, addComment: {} } }; - return HttpClientResponse.fromWeb(request, new Response(encodeJson(body))); - }), - ), - ); - const cli = yield* GitHubPullRequestCli.make.pipe( - Effect.provide( - GitHubApi.layer.pipe( - Layer.provide(Layer.mergeAll(credentials, http)), - Layer.provide(GitHubGraphQlBudget.layer), - Layer.provide(SourceControlRateLimit.layer), - Layer.merge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), - ), - ), - ); - const input = { cwd: "/repo", host: "github.com" }; - const first = yield* cli.withVerifiedCredential(input, (identity) => - Effect.gen(function* () { - activeToken = "restricted-credential"; - expect(yield* cli.getViewerLogin(input)).toBe("same-account"); - yield* cli.commentOnPullRequest({ - ...input, - repository: "owner/repo", - number: 1, - body: "comment", - }); - return identity; - }), - ); - const second = yield* cli.withVerifiedCredential(input, Effect.succeed); - expect(first.accountId).toBe(second.accountId); - expect(first.credentialFingerprint).not.toBe(second.credentialFingerprint); - expect(encodeJson([first, second])).not.toContain("broad-credential"); - expect(encodeJson([first, second])).not.toContain("restricted-credential"); - // The comment was written under the credential the page verified, not the switched one. - expect( - requests - .filter((request) => request.url.endsWith("/graphql")) - .map((request) => request.authorization), - ).toEqual(["Bearer broad-credential", "Bearer broad-credential"]); - expect( - requests - .filter((request) => request.url.endsWith("/user")) - .map((request) => request.authorization), - ).toEqual(["Bearer broad-credential", "Bearer restricted-credential"]); - expect(yield* cli.getRoutingIdentity(input)).toEqual({ - accountId: "123", - viewer: "same-account", - }); - }), -); - -layer("GitHubPullRequestCli.layer", (it) => { - it.effect("admits only one concurrent preview above the reserve and resumes after reset", () => - Effect.gen(function* () { - const now = yield* Clock.currentTimeMillis; - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - const resetAt = "2099-08-13T14:00:00Z"; - yield* budget.observe( - "preview-budget.example", - encodeJson({ data: { rateLimit: { cost: 1, limit: 5_000, remaining: 501, resetAt } } }), - ); - mockedExecute.mockReturnValue( - Effect.succeed( - output( - encodeJson({ - data: { - repository: { - pullRequest: { - number: 7, - title: "Fast previews", - url: "https://preview-budget.example/acme/web/pull/7", - state: "OPEN", - isDraft: false, - createdAt: "2026-07-01T00:00:00Z", - author: null, - }, - }, - rateLimit: { cost: 1, limit: 5_000, remaining: 500, resetAt }, - }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const input = { - cwd: "/w", - repository: "acme/web", - host: "preview-budget.example", - number: 7, - }; - const results = yield* Effect.all( - Array.from({ length: 20 }, (_, index) => - cli.getPullRequestPreview({ ...input, number: index + 1 }).pipe(Effect.result), - ), - { concurrency: "unbounded" }, - ); - expect(results.filter((result) => result._tag === "Success")).toHaveLength(1); - for (const result of results) { - if (result._tag === "Failure") { - expect(result.failure._tag).toBe("SourceControlRateLimitPausedError"); - } - } - expect(mockedExecute).toHaveBeenCalledTimes(1); - yield* TestClock.setTime(Date.parse(resetAt)); - yield* cli.getPullRequestPreview(input); - expect(mockedExecute).toHaveBeenCalledTimes(2); - yield* TestClock.setTime(now); - }), - ); - - it.effect("loads the complete hover card with one GraphQL request", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - encodeJson({ - data: { - repository: { - pullRequest: { - number: 7, - title: "Fast previews", - url: "https://github.example/acme/web/pull/7", - state: "MERGED", - isDraft: false, - createdAt: "2026-07-01T00:00:00Z", - author: { - login: "octocat", - name: "Octo Cat", - avatarUrl: "https://github.example/avatar.png", - }, - }, - }, - }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const preview = yield* cli.getPullRequestPreview({ - cwd: "/w", - repository: "acme/web", - host: "github.example", - number: 7, - }); - expect(preview).toEqual({ - number: 7, - title: "Fast previews", - url: "https://github.example/acme/web/pull/7", - state: "merged", - isDraft: false, - createdAt: "2026-07-01T00:00:00Z", - author: { - login: "octocat", - name: "Octo Cat", - avatarUrl: "https://github.example/avatar.png", - }, - }); - expect(mockedExecute).toHaveBeenCalledTimes(1); - expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.example" }); - }), - ); - - it.effect("coalesces concurrent identity verification for the same host and credential", () => - Effect.gen(function* () { - mockedExecute.mockImplementation(() => - Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const results = yield* Effect.all( - Array.from({ length: 4 }, () => - cli.getRoutingIdentity({ cwd: "/w", host: "github.identity-flight.test" }), - ), - { concurrency: 4 }, - ); - expect(results).toEqual( - Array.from({ length: 4 }, () => ({ accountId: "123", viewer: "viewer" })), - ); - expect(mockedExecute).toHaveBeenCalledTimes(1); - expect(pathAt(0)).toBe("user"); - }), - ); - - it.effect( - "lets another identity reader continue when the first verification is interrupted", - () => - Effect.gen(function* () { - const firstStarted = yield* Deferred.make(); - let verifications = 0; - mockedExecute.mockImplementation(() => - Effect.gen(function* () { - if (++verifications === 1) { - yield* Deferred.succeed(firstStarted, undefined); - return yield* Effect.never; - } - return output('{"id":123,"login":"viewer"}'); - }), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const input = { cwd: "/w", host: "github.identity-cancel.test" }; - const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); - yield* Deferred.await(firstStarted); - // The second reader is queued on the same credential's lock behind the first. - const second = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); - yield* Effect.yieldNow; - yield* Fiber.interrupt(first); - expect(yield* Fiber.join(second)).toEqual({ accountId: "123", viewer: "viewer" }); - expect(verifications).toBe(2); - }), - ); - - it.effect("reads linked pull requests on one host together, filed back by position", () => - Effect.gen(function* () { - const node = (number: number) => ({ - number, - title: `Pull request ${number}`, - url: `https://github.com/acme/web/pull/${number}`, - author: { __typename: "User", login: "octocat", name: "Octo Cat", avatarUrl: null }, - baseRefName: "main", - headRefName: `feat/${number}`, - state: "OPEN", - isDraft: false, - mergeable: "MERGEABLE", - reviewDecision: null, - latestReviews: { nodes: [{ state: "APPROVED", author: { login: "reviewer" } }] }, - additions: 12, - deletions: 3, - changedFiles: 2, - updatedAt: "2026-08-24T12:34:56.000Z", - mergedAt: null, - closedAt: null, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, - }); - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - encodeJson({ - data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const reads = yield* Effect.forEach( - [7, 8], - (number) => - cli.getPullRequestSummary({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number, - }), - { concurrency: "unbounded" }, - ).pipe(Effect.forkChild); - yield* TestClock.adjust("10 millis"); - const [seven, eight] = yield* Fiber.join(reads); - - assert.deepStrictEqual( - { - number: seven?.number, - state: seven?.state, - headBranch: seven?.headBranch, - author: seven?.author?.login, - changedFiles: seven?.changedFiles, - reviewDecision: seven?.reviewDecision, - checksState: seven?.checksState, - mergeability: seven?.mergeability, - }, - { - number: 7, - state: "open", - headBranch: "feat/7", - author: "octocat", - changedFiles: 2, - reviewDecision: "approved", - checksState: "passing", - mergeability: "mergeable", - }, - ); - assert.strictEqual(eight?.headBranch, "feat/8"); - expect(mockedExecute).toHaveBeenCalledOnce(); - const document = queryAt(0); - expect(document).toContain( - 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)', - ); - expect(document).toContain("pullRequest(number: 8)"); - }), - ); - - it.effect("fingerprints watched pull requests on one host in one read", () => - Effect.gen(function* () { - const node = (comments: number) => ({ - state: "OPEN", - mergeable: "MERGEABLE", - headRefOid: "abc123", - comments: { totalCount: comments, nodes: [] }, - reviews: { totalCount: 0, nodes: [] }, - reviewThreads: { totalCount: 0 }, - commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, - }); - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - JSON.stringify({ - data: { w0: { pullRequest: node(1) }, w1: { pullRequest: null } }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const reads = yield* Effect.forEach( - [7, 8], - (number) => - cli.getPullRequestWatchFingerprint({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number, - }), - { concurrency: "unbounded" }, - ).pipe(Effect.forkChild); - yield* TestClock.adjust("10 millis"); - const [seven, eight] = yield* Fiber.join(reads); - - expect(seven?.remarks.startsWith("1 ")).toBe(true); - // GitHub had no answer for #8, so its watch reads it in full. - expect(eight).toBeNull(); - expect(mockedExecute).toHaveBeenCalledOnce(); - const call = callAt(0); - expect(call.kind === "graphql" ? call.query : "").toContain( - 'w1: repository(owner: "acme", name: "web") { pullRequest(number: 8)', - ); - }), - ); - - it.effect("reads a pull request the batch said nothing about on its own", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce(Effect.succeed(output('{"data":{"s0":{"pullRequest":null}}}'))) - .mockReturnValueOnce( - Effect.succeed( - output( - encodeJson({ - data: { - s0: { - pullRequest: { - number: 7, - title: "Reuse the summary", - url: "https://github.com/acme/web/pull/7", - author: { login: "octocat", name: "Octo Cat" }, - baseRefName: "main", - headRefName: "feat/summary", - state: "OPEN", - isDraft: false, - mergeable: "MERGEABLE", - reviewDecision: "APPROVED", - additions: 12, - deletions: 3, - changedFiles: 2, - updatedAt: "2026-08-24T12:34:56.000Z", - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, - }, - }, - }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const read = yield* cli - .getPullRequestSummary({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }) - .pipe(Effect.forkChild); - yield* TestClock.adjust("10 millis"); - const summary = yield* Fiber.join(read); - - assert.strictEqual(summary.headBranch, "feat/summary"); - assert.strictEqual(summary.checksState, "passing"); - assert.strictEqual(mockedExecute.mock.calls.length, 2); - expect(varsAt(1)).toEqual({ owner: "acme", name: "web", number: 7 }); - }), - ); - - it.effect("reads the stack a pull request is in through the stacks preview, on its host", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - encodeJson([ - { - id: 42, - number: 3, - url: "https://api.github.com/repos/acme/web/stacks/3", - base: { ref: "main" }, - pull_requests: [ - { - number: 6, - head: { ref: "feat/one" }, - state: "closed", - merged_at: "2026-09-02", - }, - { number: 7, head: { ref: "feat/two" }, state: "open", merged_at: null }, - ], - }, - ]), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const stack = yield* cli.getPullRequestStack({ - cwd: "/w", - repository: "acme/web", - host: "ghe.example.com", - number: 7, - }); - - assert.deepStrictEqual(stack, { - id: "42", - number: 3, - url: "https://api.github.com/repos/acme/web/stacks/3", - base: "main", - layers: [ - { number: 6, headBranch: "feat/one", state: "merged" }, - { number: 7, headBranch: "feat/two", state: "open" }, - ], - }); - expect(callAt(0)).toMatchObject({ - kind: "rest", - host: "ghe.example.com", - path: "repos/acme/web/stacks?pull_request=7", - }); - }), - ); - - it.effect("fetches layer titles only when the caller asks for stack details", () => - Effect.gen(function* () { - const minimal = { - url: "https://api.github.com/repos/acme/web/stacks/3", - number: 3, - base: { ref: "main" }, - pull_requests: [ - { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null }, - ], - }; - mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson([minimal])))); - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - encodeJson({ - ...minimal, - pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }], - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const stack = yield* cli.getPullRequestStack({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - includeDetails: true, - }); - expect(stack?.layers[0]).toMatchObject({ - title: "Second layer", - headSha: "abc123", - isDraft: false, - }); - expect(pathAt(1)).toBe("repos/acme/web/stacks/3"); - }), - ); - - it.effect("reads an empty stacks listing as not stacked", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const stack = yield* cli.getPullRequestStack({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - assert.isNull(stack); - }), - ); - - it.effect("reads a host that refuses the stacks preview as not stacked", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.fail( - new GitHubApi.GitHubApiNotFoundError({ - host: "github.com", - operation: "getPullRequestStack", - }), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const stack = yield* cli.getPullRequestStack({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - assert.isNull(stack); - }), - ); - - it.effect("does not read a refused credential as an unstacked pull request", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.fail( - new GitHubApi.GitHubApiAuthenticationError({ - host: "github.com", - operation: "getPullRequestStack", - }), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const error = yield* Effect.flip( - cli.getPullRequestStack({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }), - ); - - assert.strictEqual(error._tag, "GitHubApiAuthenticationError"); - }), - ); - - it.effect("preserves transient stack failures instead of reporting no stack", () => - Effect.gen(function* () { - const failure = new GitHubApi.GitHubApiResponseError({ - host: "github.com", - operation: "getPullRequestStack", - status: 503, - }); - mockedExecute.mockReturnValueOnce(Effect.fail(failure)); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const error = yield* Effect.flip( - cli.getPullRequestStack({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }), - ); - assert.strictEqual(error, failure); - }), - ); - - it.effect("reports a stacks answer it cannot read against the stack read", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]'))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const error = yield* Effect.flip( - cli.getPullRequestStack({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }), - ); - - assert.strictEqual(error._tag, "GitHubPullRequestReadError"); - if (error._tag !== "GitHubPullRequestReadError") return; - assert.strictEqual(error.operation, "getPullRequestStack"); - }), - ); - - it.effect("asks for one row more than the page, to probe for a next page", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const batch = yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - }); - - assert.strictEqual(batch.items.length, 3); - assert.isFalse(batch.truncated); - expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.com" }); - expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); - expect(queryAt(0)).toContain("first: 11"); - }), - ); - - it.effect("reports truncation from the extra row, counted before decoding", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const batch = yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - }); - - assert.strictEqual(batch.items.length, 10); - assert.isTrue(batch.truncated); - }), - ); - - it.effect("excludes merged pull requests from the Closed tab", () => - Effect.gen(function* () { - mockedExecute.mockImplementation((call) => - Effect.succeed( - call.kind === "graphql" && call.query.includes("pullRequests(") - ? emptyList() - : emptySearch(), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "closed", - involvement: "all", - viewer: "bilal", - limit: 10, - }); - - // A closed pull request may also be a merged one, so the tab narrows through search. - expect(searchOfCall(0)).toBe("is:pr is:closed is:unmerged sort:updated-desc repo:acme/web"); - }), - ); - - it.effect("narrows to the author on the authored tab", () => - Effect.gen(function* () { - mockedExecute.mockImplementation((call) => - Effect.succeed( - call.kind === "graphql" && call.query.includes("pullRequests(") - ? emptyList() - : emptySearch(), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "authored", - viewer: "bilal", - limit: 10, - }); - - expect(searchOfCall(0)).toBe("is:pr is:open author:bilal sort:updated-desc repo:acme/web"); - }), - ); - - it.effect("narrows through search on the reviewing tab", () => - Effect.gen(function* () { - mockedExecute.mockImplementation((call) => - Effect.succeed( - call.kind === "graphql" && call.query.includes("pullRequests(") - ? emptyList() - : emptySearch(), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "reviewing", - viewer: "bilal", - limit: 10, - }); - - expect(searchOfCall(0)).toBe( - "is:pr is:open review-requested:bilal sort:updated-desc repo:acme/web", - ); - }), - ); - - it.effect("carries every repository and every qualifier into one search", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.searchPullRequests({ - cwd: "/w", - host: "github.com", - repositories: ["acme/web", "pingdotgg/t3code"], - state: "closed", - involvement: "reviewing", - viewer: "bilal", - limit: 10, - query: "pull requests page", - cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, - }); - - // One request for both repositories, carrying everything the per-repository read expresses - // as a flag: the tab, the involvement, the reader's words, where to carry on from, and the - // order the page reads in. - assert.strictEqual(mockedExecute.mock.calls.length, 1); - assert.strictEqual( - searchQueryOfCall(0), - 'is:pr is:closed is:unmerged review-requested:bilal "pull requests page" ' + - "updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web repo:pingdotgg/t3code", - ); - }), - ); - - it.effect("narrows a search to the author, and to merged on the merged tab", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.searchPullRequests({ - cwd: "/w", - host: "github.com", - repositories: ["acme/web"], - state: "merged", - involvement: "authored", - viewer: "bilal", - limit: 10, - }); - - assert.strictEqual( - searchQueryOfCall(0), - "is:pr is:merged author:bilal sort:updated-desc repo:acme/web", - ); - }), - ); - - it.effect("keeps a searched-for qualifier inside the phrase", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.searchPullRequests({ - cwd: "/w", - host: "github.com", - repositories: ["acme/web"], - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - query: 'x" is:merged repo:evil/repo', - }); - - // Quoted and escaped, so the words a reader typed narrow the listing rather than widening it. - assert.strictEqual( - searchQueryOfCall(0), - 'is:pr is:open "x\\" is:merged repo:evil/repo" sort:updated-desc repo:acme/web', - ); - }), - ); - - it.effect("refuses to search for a repository GitHub cannot address", () => - Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const failure = yield* Effect.flip( - cli.searchPullRequests({ - cwd: "/w", - host: "github.com", - repositories: ["acme/web", "acme/web is:merged"], - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - }), - ); - - // Nothing is sent: a name that could end its own qualifier is refused rather than escaped. - assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); - assert.strictEqual(mockedExecute.mock.calls.length, 0); - }), - ); - - it.effect("files each searched row under the repository it came from", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue( - Effect.succeed( - searchPage([ - searchItem(7, "acme/web", "2026-07-03T00:00:00Z"), - searchItem(9, "pingdotgg/t3code", "2026-07-02T00:00:00Z"), - // Not a pull request, which `is:pr` excludes and a decode skips rather than fails on. - {}, - ]), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const batch = yield* cli.searchPullRequests({ - cwd: "/w", - host: "github.com", - repositories: ["acme/web", "pingdotgg/t3code"], - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - }); - - assert.deepStrictEqual( - batch.items.map((item) => [item.repository, item.number, item.author?.avatarUrl]), - [ - ["acme/web", 7, "https://avatars/octocat"], - ["pingdotgg/t3code", 9, "https://avatars/octocat"], - ], - ); - // The listing leaves the line counts to a read of their own. - assert.deepStrictEqual( - batch.items.map((item) => [item.additions, item.deletions]), - [ - [0, 0], - [0, 0], - ], - ); - assert.isFalse(batch.truncated); - }), - ); - - it.effect("reports truncation from the extra row, and from a page GitHub says has more", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce( - Effect.succeed( - searchPage([ - searchItem(1, "acme/web", "2026-07-03T00:00:00Z"), - searchItem(2, "acme/web", "2026-07-02T00:00:00Z"), - searchItem(3, "acme/web", "2026-07-01T00:00:00Z"), - ]), - ), - ) - .mockReturnValueOnce( - Effect.succeed(searchPage([searchItem(1, "acme/web", "2026-07-03T00:00:00Z")], true)), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const read = () => - cli.searchPullRequests({ - cwd: "/w", - host: "github.com", - repositories: ["acme/web"], - state: "open", - involvement: "all", - viewer: "bilal", - limit: 2, - }); - - const overflowing = yield* read(); - const capped = yield* read(); - - // The extra row is the probe, and it is not handed on. - assert.strictEqual(overflowing.items.length, 2); - assert.isTrue(overflowing.truncated); - // A slice at GitHub's own ceiling has no extra row to probe with, so `hasNextPage` answers. - assert.isTrue(capped.truncated); - }), - ); - - it.effect("enriches only the visible fallback rows after filtering and widening", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(listedPullRequests(3, 1, () => ({ isDraft: true })))), - ); - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(listedPullRequests(6, 1, (number) => ({ isDraft: number < 4 })))), - ); - mockedStackMemberships.mockReturnValueOnce( - Effect.succeed( - output( - encodeJson({ - data: { - s0: { - pullRequest: { - stack: { number: 3, size: 2, baseRefName: "main" }, - stackEntry: { position: 1 }, - }, - }, - s1: { pullRequest: { stack: null, stackEntry: null } }, - }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const batch = yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 2, - filters: { draft: "hide" }, - }); - expect(batch.items.map((item) => item.number)).toEqual([4, 5]); - expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); - expect(batch.items[1]?.stack).toBeUndefined(); - expect(batch.truncated).toBe(true); - expect(batch.continues).toBe(false); - expect(mockedStackMemberships).toHaveBeenCalledTimes(1); - const membership = mockedStackMemberships.mock.calls[0]?.[0]; - const query = membership?.kind === "graphql" ? membership.query : ""; - expect(query).toContain("pullRequest(number: 4)"); - expect(query).toContain("pullRequest(number: 5)"); - expect(query).not.toContain("pullRequest(number: 1)"); - expect(query).not.toContain("pullRequest(number: 6)"); - }), - ); - - it.effect("batches membership reads and keeps successful rows when one chunk fails", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1)))); - mockedStackMemberships.mockImplementation((input) => - input.kind === "graphql" && input.query.includes("pullRequest(number: 26)") - ? Effect.fail( - new GitHubApi.GitHubApiResponseError({ - host: "github.com", - operation: "listPullRequestStackMemberships", - status: 502, - }), - ) - : Effect.succeed( - output( - encodeJson({ - data: { - s0: { - pullRequest: { - stack: { number: 3, size: 2, baseRefName: "main" }, - stackEntry: { position: 1 }, - }, - }, - }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const batch = yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 26, - }); - expect(batch.items.map((item) => item.number)).toEqual( - Array.from({ length: 26 }, (_, i) => i + 1), - ); - expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); - expect(batch.items[25]?.stack).toBeUndefined(); - expect(batch.truncated).toBe(true); - expect(batch.continues).toBe(true); - expect(mockedStackMemberships).toHaveBeenCalledTimes(2); - }), - ); - - it.effect("skips membership enrichment for empty pages and enterprise hosts", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); - mockedExecute.mockReturnValueOnce(Effect.succeed(emptyList())); - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const input = { - cwd: "/w", - repository: "acme/web", - state: "open" as const, - involvement: "all" as const, - viewer: "bilal", - limit: 2, - }; - const empty = yield* cli.listPullRequests({ ...input, host: "github.com" }); - const enterprise = yield* cli.listPullRequests({ ...input, host: "github.acme.test" }); - expect(empty.items).toEqual([]); - expect(enterprise.items.map((item) => item.number)).toEqual([7]); - expect(mockedStackMemberships).not.toHaveBeenCalled(); - }), - ); - - it.effect("reads the line counts in chunks, and files them back by position", () => - Effect.gen(function* () { - const changeRequests = Array.from({ length: 26 }, (_, index) => ({ - repository: "acme/web", - number: index + 1, - })); - mockedExecute.mockImplementation(() => - // Every chunk answers for its first alias only, so a row GitHub said nothing about is - // dropped rather than shown as a change of no size. - Effect.succeed( - output(encodeJson({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const stats = yield* cli.listPullRequestStats({ - cwd: "/w", - host: "github.com", - changeRequests, - }); - - // Twenty-five aliases a request, so twenty-six rows are two requests. - assert.strictEqual(mockedExecute.mock.calls.length, 2); - assert.deepStrictEqual(stats, [ - { repository: "acme/web", number: 1, additions: 4, deletions: 1 }, - { repository: "acme/web", number: 26, additions: 4, deletions: 1 }, - ]); - const document = queryAt(0); - expect(document).toContain('s0: repository(owner: "acme", name: "web")'); - expect(document).toContain("pullRequest(number: 25)"); - }), - ); - - it.effect("refuses to look up counts for a repository GitHub cannot address", () => - Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const failure = yield* Effect.flip( - cli.listPullRequestStats({ - cwd: "/w", - host: "github.com", - changeRequests: [{ repository: 'acme/web") { x } #', number: 1 }], - }), - ); - - assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); - assert.strictEqual(mockedExecute.mock.calls.length, 0); - }), - ); - - it.effect("hands a search to GitHub rather than to the rows already read", () => - Effect.gen(function* () { - mockedExecute.mockImplementation((call) => - Effect.succeed( - call.kind === "graphql" && call.query.includes("pullRequests(") - ? emptyList() - : emptySearch(), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - query: "pull requests page", - }); - - // The recency qualifier rides along, because free text would otherwise reorder the page - // by relevance and truncation would drop the newest matches. - expect(searchOfCall(0)).toBe( - 'is:pr is:open "pull requests page" sort:updated-desc repo:acme/web', - ); - }), - ); - - it.effect("joins a search onto the tab's own qualifiers instead of replacing them", () => - Effect.gen(function* () { - mockedExecute.mockImplementation((call) => - Effect.succeed( - call.kind === "graphql" && call.query.includes("pullRequests(") - ? emptyList() - : emptySearch(), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "closed", - involvement: "reviewing", - viewer: "bilal", - limit: 10, - query: "page", - }); - - expect(searchOfCall(0)).toBe( - 'is:pr is:closed is:unmerged review-requested:bilal "page" sort:updated-desc repo:acme/web', - ); - }), - ); - - it.effect("carries the further narrowings into the search as qualifiers", () => - Effect.gen(function* () { - mockedExecute.mockImplementation((call) => - Effect.succeed( - call.kind === "graphql" && call.query.includes("pullRequests(") - ? emptyList() - : emptySearch(), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - filters: { - draft: "hide", - review: "changes-requested", - checks: "failing", - labels: [["needs design"], ['quo"te']], - excludedLabels: ["wip"], - author: "octocat", - }, - }); - - // Quotes around anything a reader typed, and the one character that could end a quoted - // value early dropped rather than escaped. - expect(searchOfCall(0)).toBe( - 'is:pr is:open label:"needs design" label:"quote" -label:"wip" author:"octocat" ' + - "draft:false review:changes_requested status:failure sort:updated-desc repo:acme/web", - ); - }), - ); - - it.effect('resolves an author filter of "me" to the viewer, not the literal word', () => - Effect.gen(function* () { - mockedExecute.mockImplementation((call) => - Effect.succeed( - call.kind === "graphql" && call.query.includes("pullRequests(") - ? emptyList() - : emptySearch(), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - filters: { author: "me" }, - }); - - expect(searchOfCall(0)).toBe('is:pr is:open author:"bilal" sort:updated-desc repo:acme/web'); - }), - ); - - it.effect("sends one label qualifier per group, its names joined the way GitHub ors them", () => - Effect.gen(function* () { - mockedExecute.mockImplementation((call) => - Effect.succeed( - call.kind === "graphql" && call.query.includes("pullRequests(") - ? emptyList() - : emptySearch(), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - filters: { labels: [["size:S", "size:XS"], ["bug"]] }, - }); - - // One qualifier satisfied by either size, and a second one that must hold as well. - expect(searchOfCall(0)).toBe( - 'is:pr is:open label:"size:S","size:XS" label:"bug" sort:updated-desc repo:acme/web', - ); - }), - ); - - it.effect( - "falls back for a repository the index does not cover under a checks filter, keeping only the matching rows", - () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - listedPullRequests(2, 1, (number) => ({ - checks: number === 1 ? "SUCCESS" : "FAILURE", - })), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const batch = yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - filters: { checks: "passing" }, - }); - - // The fallback's rows carry `checksState` exactly as a search's rows do, so `checks` is - // now a filter the fallback judges itself, the same as `draft`: an empty search answer - // under it is still ambiguous, and the row picked out afterwards is the one whose own - // `checksState` reads "passing". - expect(searchOfCall(1)).toBeUndefined(); - assert.deepStrictEqual( - batch.items.map((item) => item.number), - [1], - ); - }), - ); - - it.effect("fails a checks filter for a row whose checks are still pending", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(listedPullRequests(1, 1, () => ({ checks: "PENDING" })))), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const batch = yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - filters: { checks: "passing" }, - }); - - // Pending equals neither "passing" nor "failing", so it satisfies neither filter value — - // the same row would also be dropped by `checks: "failing"`. - assert.deepStrictEqual(batch.items, []); - }), - ); - - it.effect( - "falls back for a repository the index does not cover even under a judgeable filter", - () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(listedPullRequests(2, 1, (number) => ({ isDraft: number === 1 })))), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const batch = yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - filters: { draft: "hide" }, - }); - - // `draft` is a filter the fallback can judge over its own rows just as search judges it, - // so an empty search answer under it alone is still ambiguous between "nothing matches" - // and "this repository is not indexed" — and the fallback applies the filter itself, - // keeping only the non-draft row. - expect(searchOfCall(1)).toBeUndefined(); - expect(batch.items.map((item) => item.number)).toEqual([2]); - }), - ); - - it.effect("carries the further narrowings into a batched search", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.searchPullRequests({ - cwd: "/w", - host: "github.com", - repositories: ["acme/web"], - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - filters: { draft: "only", review: "none", labels: [["bug"]] }, - }); - - assert.strictEqual( - searchQueryOfCall(0), - 'is:pr is:open label:"bug" draft:true review:none sort:updated-desc repo:acme/web', - ); - }), - ); - - it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () => - Effect.gen(function* () { - mockedExecute.mockImplementation((call) => - Effect.succeed( - call.kind === "graphql" && call.query.includes("pullRequests(") - ? emptyList() - : emptySearch(), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - query: '-- is:merged label:secret "widen me"', - }); - - // Every word stays inside one phrase: nothing before it, nothing after it, and the - // leading dashes are text rather than the start of another argument. - expect(searchOfCall(0)).toBe( - String.raw`is:pr is:open "-- is:merged label:secret \"widen me\"" sort:updated-desc repo:acme/web`, - ); - }), - ); - - it.effect("escapes a backslash before the quote it would otherwise let out", () => - Effect.gen(function* () { - mockedExecute.mockImplementation((call) => - Effect.succeed( - call.kind === "graphql" && call.query.includes("pullRequests(") - ? emptyList() - : emptySearch(), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - query: String.raw`a\" is:merged`, - }); - - // GitHub reads `\\` as one backslash and `\"` as one quote, so the phrase ends where - // this says it does; escaping the quote alone would have closed it early. - expect(searchOfCall(0)).toBe( - String.raw`is:pr is:open "a\\\" is:merged" sort:updated-desc repo:acme/web`, - ); - }), - ); - - it.effect("asks for nothing but the order when the reader typed only spaces", () => - Effect.gen(function* () { - mockedExecute.mockImplementation((call) => - Effect.succeed( - call.kind === "graphql" && call.query.includes("pullRequests(") - ? emptyList() - : emptySearch(), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - query: " ", - }); - - // An empty phrase would match nothing rather than everything, so it is left out; the - // order the page reads rows in is asked for whether or not anything was typed. - expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); - }), - ); - - it.effect("carries on from the instant the last slice ended on", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output(pullRequests(3, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const batch = yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, - }); - - // Inclusive, so the rows already sent at that instant come back for the caller to drop — - // which is what keeps the ones beside them from being skipped. - expect(searchOfCall(0)).toBe( - "is:pr is:open updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web", - ); - assert.isTrue(batch.continues); - }), - ); - - it.effect("answers a search that found nothing with nothing, not with the whole repository", () => - Effect.gen(function* () { - // The fallback is for a repository the index does not cover. Under a text search an empty - // answer means the text matched nothing, and listing everything instead would fill the - // page with rows the reader did not search for. - mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const batch = yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - query: "fdsfklj", - }); - - assert.strictEqual(batch.items.length, 0); - assert.strictEqual(mockedExecute.mock.calls.length, 1); - }), - ); - - it.effect("reads a repository GitHub will not search from its own list", () => - Effect.gen(function* () { - // GitHub answers for a repository outside its search index with no rows and no error. - mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(listedPullRequests(3, 1, () => ({ state: "CLOSED" })))), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const batch = yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "closed", - involvement: "all", - viewer: "bilal", - limit: 10, - }); - - assert.strictEqual(batch.items.length, 3); - // The fallback itself uses no search, then narrows the decoded rows locally. They arrive - // newest-created first, so nothing can carry on from them. - expect(searchOfCall(1)).toBeUndefined(); - expect(varsAt(1)).toMatchObject({ owner: "acme", name: "web", states: ["CLOSED", "MERGED"] }); - assert.isFalse(batch.continues); - }), - ); - - it.effect("keeps state and involvement filters on the search-free fallback", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - listedPullRequests(4, 1, (number) => ({ - state: number === 4 ? "OPEN" : "CLOSED", - ...(number === 3 ? { mergedAt: "2026-07-03T00:00:00Z" } : {}), - reviewRequests: - number === 2 ? [{ slug: "platform", name: "Platform" }] : [{ login: "bilal" }], - })), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const batch = yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "closed", - involvement: "reviewing", - viewer: "bilal", - limit: 10, - }); - - // Individual requests for this viewer and team requests survive. The fallback cannot - // resolve team membership, so dropping team-routed reviews would hide legitimate work. - expect(batch.items.map((item) => item.number)).toEqual([1, 2]); - expect(searchOfCall(1)).toBeUndefined(); - assert.isFalse(batch.continues); - }), - ); - - it.effect("grows the search-free fallback until it fills the filtered page", () => - Effect.gen(function* () { - const unrelated = () => ({ reviewRequests: [{ login: "somebody-else" }] }); - mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(listedPullRequests(3, 1, unrelated))), - ); - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - listedPullRequests(4, 1, (number) => - number === 4 ? { reviewRequests: [{ login: "bilal" }] } : unrelated(), - ), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const batch = yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "reviewing", - viewer: "bilal", - limit: 2, - }); - - expect(batch.items.map((item) => item.number)).toEqual([4]); - expect(queryAt(1)).toContain("first: 3,"); - expect(queryAt(2)).toContain("first: 6,"); - assert.isFalse(batch.truncated); - }), - ); - - it.effect("bounds a sparse search-free fallback and reports the unread tail", () => - Effect.gen(function* () { - // A repository with far more pull requests than the bound, none of them for this reader. - let listed = 0; - mockedExecute.mockImplementation((call) => { - if (call.kind !== "graphql" || !call.query.includes("pullRequests(")) { - return Effect.succeed(emptySearch()); - } - const first = Number(/first: (\d+)/.exec(call.query)?.[1]); - const nodes = rows(first, listed + 1, () => ({ - reviewRequests: [{ login: "somebody-else" }], - })); - listed += first; - return Effect.succeed( - output( - encodeJson({ - data: { - repository: { - pullRequests: { pageInfo: { hasNextPage: true, endCursor: `c${listed}` }, nodes }, - }, - }, - }), - ), - ); - }); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const batch = yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "reviewing", - viewer: "bilal", - limit: 2, - }); - - // The last widening reads a thousand rows, a page of a hundred at a time, and stops there. - const last = mockedExecute.mock.calls.length - 1; - expect(varsAt(last)["after"]).toBe(`c${listed - 100}`); - expect(listed - (3 + 6 + 12 + 24 + 48 + 96 + 192 + 384 + 768)).toBe(1000); - assert.strictEqual(batch.items.length, 0); - assert.isTrue(batch.truncated); - }), - ); - - it.effect("takes an empty slice for a repository that has run out, not one to read again", () => - Effect.gen(function* () { - mockedExecute.mockImplementation((call) => - Effect.succeed( - call.kind === "graphql" && call.query.includes("pullRequests(") - ? emptyList() - : emptySearch(), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, - }); - - // A repository that answered the search once answers it again, so an empty slice under a - // cursor is the end of it rather than a repository search cannot reach. - assert.strictEqual(mockedExecute.mock.calls.length, 1); - }), - ); - - /** The state a merge or branch update reads first: behind its base and blocked on checks. */ - const actionState = (pullRequest: Readonly> = {}) => ({ - data: { - repository: { - pullRequest: { - id: "PR_7", - headRefOid: "abc123", - isMergeQueueEnabled: false, - mergeStateStatus: "BLOCKED", - baseRef: { compare: { behindBy: 2 } }, - ...pullRequest, - }, - }, - }, - }); - const mergeMessage = (body: string, isMergeQueueEnabled = false) => ({ - data: { - repository: { - pullRequest: { isMergeQueueEnabled, headRefOid: "abc123", viewerMergeBodyText: body }, - }, - }, - }); - - it.effect("updates a stale branch with a merge commit unless asked to rebase", () => - Effect.gen(function* () { - route(["query PullRequestActionState", actionState()]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.runPullRequestAction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - action: "update-branch", - }); - yield* cli.runPullRequestAction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - action: "update-branch", - updateMethod: "rebase", - }); - - // GitHub's own default: a merge commit unless asked to rebase, pinned to the head it read. - expect(variablesOf("updatePullRequestBranch(")).toEqual([ - { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "MERGE" }, - { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "REBASE" }, - ]); - }), - ); - - it.effect("merges with the strategy it was asked for", () => - Effect.gen(function* () { - route(["query PullRequestActionState", actionState()]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.runPullRequestAction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - action: "merge", - mergeMethod: "squash", - }); - - expect(variablesOf("mergePullRequest(")).toEqual([ - { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, - ]); - expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); - }), - ); - - it.effect.each(["merge", "enable-auto-merge"] as const)( - "removes agent credits from the proposed message for %s", - (action) => - Effect.gen(function* () { - route( - [ - "query PullRequestMergeMessage", - mergeMessage( - "Details\n\nCo-authored-by: Alice \nCo-authored-by: Claude ", - ), - ], - ["query PullRequestActionState", actionState()], - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - yield* cli.runPullRequestAction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - action, - mergeMethod: "squash", - removeAgentCreditsOnMerge: true, - }); - expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("SQUASH"); - expect( - variablesOf(action === "merge" ? "mergePullRequest(" : "enablePullRequestAutoMerge("), - ).toEqual([ - { - input: { - pullRequestId: "PR_7", - mergeMethod: "SQUASH", - // Pinned to the head the message was read from, so it cannot describe other commits. - expectedHeadOid: "abc123", - commitBody: "Details\n\nCo-authored-by: Alice ", - }, - }, - ]); - }), - ); - - it.effect.each([ - { - description: "an unchanged message", - body: "Details\n\nCo-authored-by: Alice ", - queued: false, - }, - { - description: "a merge queue", - body: "Co-authored-by: Claude ", - queued: true, - }, - ] as const)("keeps GitHub's default message for $description", ({ body, queued }) => - Effect.gen(function* () { - route( - ["query PullRequestMergeMessage", mergeMessage(body, queued)], - ["query PullRequestActionState", actionState({ isMergeQueueEnabled: queued })], - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - yield* cli.runPullRequestAction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - action: "merge", - removeAgentCreditsOnMerge: true, - }); - expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("MERGE"); - // A merge queue takes the pull request through auto-merge, with its own message. - expect(variablesOf(queued ? "enablePullRequestAutoMerge(" : "mergePullRequest(")).toEqual([ - { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, - ]); - }), - ); - - it.effect("passes an explicitly empty body when the proposed message only credits an agent", () => - Effect.gen(function* () { - route( - [ - "query PullRequestMergeMessage", - mergeMessage("Co-authored-by: Claude "), - ], - ["query PullRequestActionState", actionState()], - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - yield* cli.runPullRequestAction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - action: "merge", - removeAgentCreditsOnMerge: true, - }); - expect(variablesOf("mergePullRequest(")).toEqual([ - { - input: { - pullRequestId: "PR_7", - mergeMethod: "MERGE", - expectedHeadOid: "abc123", - commitBody: "", - }, - }, - ]); - }), - ); - - it.effect("does not fetch a message for rebase merges", () => - Effect.gen(function* () { - route(["query PullRequestActionState", actionState()]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - yield* cli.runPullRequestAction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - action: "merge", - mergeMethod: "rebase", - removeAgentCreditsOnMerge: true, - }); - expect(variablesOf("query PullRequestMergeMessage")).toEqual([]); - expect(variablesOf("mergePullRequest(")).toEqual([ - { input: { pullRequestId: "PR_7", mergeMethod: "REBASE" } }, - ]); - }), - ); - - it.effect("refuses to merge when the proposed message cannot be read", () => - Effect.gen(function* () { - route( - ["query PullRequestMergeMessage", { data: { repository: { pullRequest: null } } }], - ["query PullRequestActionState", actionState()], - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const result = yield* Effect.result( - cli.runPullRequestAction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - action: "merge", - removeAgentCreditsOnMerge: true, - }), - ); - expect(result._tag).toBe("Failure"); - expect(variablesOf("mergePullRequest(")).toEqual([]); - }), - ); - - it.effect("arms auto-merge with the same strategy a merge would have used", () => - Effect.gen(function* () { - route(["query PullRequestActionState", actionState()]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.runPullRequestAction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - action: "enable-auto-merge", - mergeMethod: "squash", - }); - // No strategy asked for is GitHub's own default, exactly as it is for a merge now. - yield* cli.runPullRequestAction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - action: "enable-auto-merge", - }); - - expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([ - { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, - { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, - ]); - expect(variablesOf("mergePullRequest(")).toEqual([]); - }), - ); - - it.effect("merges at once when auto-merge is asked of a pull request that is ready now", () => - Effect.gen(function* () { - route(["query PullRequestActionState", actionState({ mergeStateStatus: "CLEAN" })]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.runPullRequestAction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - action: "enable-auto-merge", - mergeMethod: "squash", - }); - - expect(variablesOf("mergePullRequest(")).toEqual([ - { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, - ]); - expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); - }), - ); - - it.effect("takes auto-merge back off without naming a strategy", () => - Effect.gen(function* () { - route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.runPullRequestAction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - action: "disable-auto-merge", - mergeMethod: "squash", - }); - - expect(variablesOf("disablePullRequestAutoMerge(")).toEqual([{ pullRequestId: "PR_7" }]); - }), - ); - - it.effect("opens a pull request that reverts a merged pull request", () => - Effect.gen(function* () { - route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.runPullRequestAction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - action: "revert", - }); - - expect(variablesOf("revertPullRequest(")).toEqual([{ pullRequestId: "PR_7" }]); - }), - ); - - it.effect("does not approve action-required runs for a same-repository pull request", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - encodeJson( - coreResponse({ - number: 7, - title: "Pull request 7", - url: "https://github.com/acme/web/pull/7", - headRefName: "feat/page", - headRefOid: "abc123", - isCrossRepository: false, - headRepositoryOwner: { login: "acme" }, - baseRefName: "main", - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - }), - ), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.runPullRequestAction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - action: "approve-workflows", - }); - - expect(mockedExecute).toHaveBeenCalledTimes(1); - }), - ); - - it.effect("finds and approves every workflow waiting on a maintainer", () => - Effect.gen(function* () { - workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([10, 11])); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.runPullRequestAction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - action: "approve-workflows", - }); - - // Open pull requests on this head branch, and runs scoped to this exact head. - expect(variablesOf("headRefName: $head")[0]).toEqual({ - owner: "acme", - name: "web", - head: "feat/page", - after: null, - }); - expect(restCallsTo("actions/runs?")[0]?.path).toBe( - "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fpage&event=pull_request&status=action_required&per_page=100&page=1", - ); - // Each run is approved only after the head is read again and still lists it. - expect(restCallsTo("/approve").map((call) => [call.method, call.path])).toEqual([ - ["POST", "repos/acme/web/actions/runs/10/approve"], - ["POST", "repos/acme/web/actions/runs/11/approve"], - ]); - assert.strictEqual(restCallsTo("actions/runs?").length, 3); - }), - ); - - it.effect("counts revalidated runs that wait on a maintainer as GitHub reports them", () => - Effect.gen(function* () { - workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([])); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const runs = yield* cli - .listWorkflowRunsRequiringApproval({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - headSha: "abc123", - headBranch: "feat/page", - headRepositoryOwner: "octocat", - isCrossRepository: true, - }) - .pipe( - Effect.provideService(KnownWorkflowRuns, { - headSha: "abc123", - runs: [ - // Live shape: waiting on approval is completed + action_required. - { - id: 10, - status: "completed", - conclusion: "action_required", - head_branch: "feat/page", - }, - { id: 11, status: "completed", conclusion: "success", head_branch: "feat/page" }, - ], - }), - ); - - expect(runs.map((run) => run.id)).toEqual([10]); - assert.strictEqual(restCallsTo("actions/runs?").length, 0); - }), - ); - - it.effect("refuses a stale workflow approval after the pull request head changes", () => - Effect.gen(function* () { - let detailReads = 0; - workflowApprovalRoutes( - () => crossRepositoryDetail(++detailReads === 1 ? "abc123" : "def456"), - heads([7]), - workflowRuns([10]), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const error = yield* Effect.flip( - cli.runPullRequestAction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - action: "approve-workflows", - }), - ); - - expect(error).toMatchObject({ - _tag: "GitHubWorkflowApprovalHeadChangedError", - number: 7, - }); - assert.strictEqual(detailReads, 2); - expect(restCallsTo("/approve")).toEqual([]); - }), - ); - - it.effect("reads workflow runs and their pull request scope concurrently", () => - Effect.gen(function* () { - const headsStarted = yield* Deferred.make(); - const runsStarted = yield* Deferred.make(); - mockedExecute.mockImplementation((call) => - call.kind === "graphql" - ? Deferred.succeed(headsStarted, undefined).pipe( - Effect.andThen(Deferred.await(runsStarted)), - Effect.as(output(encodeJson(heads([7])))), - ) - : Deferred.succeed(runsStarted, undefined).pipe( - Effect.andThen(Deferred.await(headsStarted)), - Effect.as(output(encodeJson(workflowRuns([10])))), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const runs = yield* cli.listWorkflowRunsRequiringApproval({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - headSha: "abc123", - headBranch: "feat/page", - headRepositoryOwner: "octocat", - isCrossRepository: true, - }); - - expect(runs).toEqual([{ id: 10, name: "build", url: "https://example.com/10" }]); - }), - ); - - it.effect("refuses workflow approval when one head belongs to several pull requests", () => - Effect.gen(function* () { - workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7, 8]), workflowRuns([])); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const error = yield* Effect.flip( - cli.listWorkflowRunsRequiringApproval({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - headSha: "abc123", - headBranch: "feat/page", - headRepositoryOwner: "octocat", - isCrossRepository: true, - }), - ); - - expect(error).toMatchObject({ - _tag: "GitHubWorkflowApprovalRefusedError", - reason: "head-not-unique", - number: 7, - observedCount: 2, - limit: 1_000, - }); - expect(error.message).toContain("instead of uniquely matching #7"); - }), - ); - - it.effect("refuses workflow approval when GitHub omits the head repository", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - encodeJson( - coreResponse({ - number: 7, - title: "Pull request 7", - url: "https://github.com/acme/web/pull/7", - headRefName: "feat/page", - headRefOid: "abc123", - isCrossRepository: true, - headRepositoryOwner: null, - baseRefName: "main", - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - }), - ), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const error = yield* Effect.flip( - cli.runPullRequestAction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - action: "approve-workflows", - }), - ); - - expect(error).toMatchObject({ - _tag: "GitHubWorkflowApprovalHeadUnavailableError", - number: 7, - }); - expect(mockedExecute).toHaveBeenCalledTimes(1); - }), - ); - - it.effect("surfaces a workflow run list beyond the safe approval bound", () => - Effect.gen(function* () { - let runPages = 0; - mockedExecute.mockImplementation((call) => { - if (call.kind === "graphql") return Effect.succeed(output(encodeJson(heads([7])))); - const page = runPages++; - return Effect.succeed( - output( - encodeJson( - workflowRuns(Array.from({ length: 100 }, (_, index) => page * 100 + index + 1)), - ), - ), - ); - }); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const error = yield* Effect.flip( - cli.listWorkflowRunsRequiringApproval({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - headSha: "abc123", - headBranch: "feat/page", - headRepositoryOwner: "octocat", - isCrossRepository: true, - }), - ); - - // Paged a hundred at a time, it stops at the first page past the bound. - expect(error).toMatchObject({ - _tag: "GitHubWorkflowApprovalRefusedError", - reason: "run-list-truncated", - number: 7, - observedCount: 1_100, - limit: 1_000, - }); - expect(error.message).toContain("more than 1000 workflow runs"); - assert.strictEqual(runPages, 11); - }), - ); - - it.effect("returns a pull request to draft by converting it", () => - Effect.gen(function* () { - route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.runPullRequestAction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - action: "draft", - }); - - expect(variablesOf("convertPullRequestToDraft(")).toEqual([{ pullRequestId: "PR_7" }]); - expect(variablesOf("markPullRequestReadyForReview(")).toEqual([]); - }), - ); - - it.effect("sends a comment body as a variable, never inside the document", () => - Effect.gen(function* () { - route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.commentOnPullRequest({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - body: "Looks good.", - }); - - expect(variablesOf("addComment(")).toEqual([{ subjectId: "PR_7", body: "Looks good." }]); - expect(queryAt(mockedExecute.mock.calls.length - 1)).not.toContain("Looks good."); - }), - ); - - it.effect("names the host on every repository it addresses", () => - Effect.gen(function* () { - mockedExecute.mockImplementation((call) => - Effect.succeed( - call.kind === "graphql" && call.query.includes("pullRequests(") - ? emptyList() - : emptySearch(), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.listPullRequests({ - cwd: "/w", - repository: "acme/web", - host: "github.acme.dev", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - }); - - // A request sent to github.com would read a different repository of the same name. - assert.isAbove(mockedExecute.mock.calls.length, 0); - expect(new Set(mockedExecute.mock.calls.map(([call]) => call.host))).toEqual( - new Set(["github.acme.dev"]), - ); - expect(searchOfCall(0)).toContain("repo:acme/web"); - }), - ); - - it.effect("asks a GitHub Enterprise host for its own review threads", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - encodeJson({ - data: { - repository: { pullRequest: { reviewThreads: { totalCount: 0, nodes: [] } } }, - }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.listReviewThreadComments({ - cwd: "/w", - repository: "acme/web", - host: "github.acme.dev", - number: 7, - }); - - expect(callAt(0).host).toBe("github.acme.dev"); - expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); - }), - ); - - it.effect("serves a diff GitHub hands over whole in one request, with no next slice", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("diff --git a/a b/a"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const diff = yield* cli.getPullRequestDiff({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - assert.strictEqual(diff.patch, "diff --git a/a b/a"); - assert.isNull(diff.nextCursor); - assert.isFalse(diff.truncated); - // The common case pays for one request and not the files API on top of it. - assert.strictEqual(mockedExecute.mock.calls.length, 1); - // GitHub's combined pull-request diff: one section per changed file, not one per commit. - const call = callAt(0); - assert.strictEqual(call.kind, "rest"); - if (call.kind === "rest") { - assert.strictEqual(call.path, "repos/acme/web/pulls/7"); - assert.strictEqual(call.accept, "application/vnd.github.diff"); - } - }), - ); - - it.effect("reads one files page when GitHub refuses the diff, and says it is the last", () => - Effect.gen(function* () { - // GitHub answers 406 rather than a diff past 300 changed files. - mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const diff = yield* cli.getPullRequestDiff({ - cwd: "/w", - repository: "acme/web", - host: "github.acme.dev", - number: 7, - }); - - assert.isFalse(diff.truncated); - // A short page is the end of the change set, so there is nothing to carry on from. - assert.isNull(diff.nextCursor); - expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); - expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts"); - assert.strictEqual(callAt(1).host, "github.acme.dev"); - assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); - }), - ); - - it.effect("hands back a cursor for the next page rather than walking on by itself", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const diff = yield* cli.getPullRequestDiff({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - // A full page means more files, which the reader asks for; it is not a truncated slice. - assert.isFalse(diff.truncated); - assert.isNotNull(diff.nextCursor); - assert.strictEqual(mockedExecute.mock.calls.length, 2); - }), - ); - - it.effect("carries on from a cursor without asking for the whole diff again", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const target = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }; - - const first = yield* cli.getPullRequestDiff(target); - assert.isNotNull(first.nextCursor); - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(4, 100)))); - const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); - - assert.isNull(second.nextCursor); - expect(second.patch).toContain("diff --git a/src/file100.ts b/src/file100.ts"); - // The second slice is one request: the cursor already says where to read. - assert.strictEqual(mockedExecute.mock.calls.length, 3); - assert.strictEqual(pathAt(2), "repos/acme/web/pulls/7/files?per_page=100&page=2"); - }), - ); - - it.effect("refuses a cursor it never handed out rather than reading it into a request", () => - Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const error = yield* Effect.flip( - cli.getPullRequestDiff({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - cursor: "1&per_page=1", - }), - ); - - assert.strictEqual(error._tag, "GitHubDiffCursorError"); - assert.strictEqual(mockedExecute.mock.calls.length, 0); - }), - ); - - it.effect("reads a named commit from the commit endpoint rather than the whole diff", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(`{"files":${pullRequestFiles(2, 1)}}`)), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const diff = yield* cli.getPullRequestDiff({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", - }); - - // One request: the commit's own changes never take the whole-diff road. - assert.strictEqual(mockedExecute.mock.calls.length, 1); - assert.isNull(diff.nextCursor); - // The commit endpoint wraps its files in an object, which the decoder unwraps. - expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); - assert.strictEqual( - pathAt(0), - "repos/acme/web/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0?per_page=100&page=1", - ); - }), - ); - - it.effect("pages inside a commit the way it pages the pull request's own files", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(`{"files":${pullRequestFiles(100, 0)}}`)), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const target = { - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - commit: "a1b2c3d", - }; - - const first = yield* cli.getPullRequestDiff(target); - assert.isNotNull(first.nextCursor); - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(`{"files":${pullRequestFiles(4, 100)}}`)), - ); - const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); - - assert.isNull(second.nextCursor); - expect(second.patch).toContain("src/file100.ts"); - assert.strictEqual(pathAt(1), "repos/acme/web/commits/a1b2c3d?per_page=100&page=2"); - }), - ); - - it.effect("refuses a commit that is not a sha rather than reading it into a request", () => - Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const error = yield* Effect.flip( - cli.getPullRequestDiff({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - commit: "../../pulls/8/files", - }), - ); - - assert.strictEqual(error._tag, "GitHubDiffCommitError"); - assert.strictEqual(mockedExecute.mock.calls.length, 0); - }), - ); - - it.effect("expands a new file from a root commit without requiring a parent", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(encodeJson({ sha: "a1b2c3d", parents: [] }))), - ); - mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const contents = yield* cli.getPullRequestDiffFileContents({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - commit: "a1b2c3d", - changeType: "new", - oldPath: "src/root.ts", - newPath: "src/root.ts", - }); - - expect(contents).toEqual({ oldContents: "", newContents: "root contents\n" }); - assert.strictEqual(mockedExecute.mock.calls.length, 2); - assert.strictEqual(pathAt(0), "repos/acme/web/commits/a1b2c3d"); - assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/root.ts?ref=a1b2c3d"); - }), - ); - - it.effect("reports unusable diff revisions as a structured error", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("not-a-sha\tstill-not-a-sha\n"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const error = yield* Effect.flip( - cli.getPullRequestDiffFileContents({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - commit: "a1b2c3d", - changeType: "change", - oldPath: "src/a.ts", - newPath: "src/a.ts", - }), - ); - - assert.strictEqual(error._tag, "GitHubDiffRevisionsUnavailableError"); - if (error._tag === "GitHubDiffRevisionsUnavailableError") { - assert.strictEqual(error.number, 7); - assert.strictEqual(error.commit, "a1b2c3d"); - } - }), - ); - - it.effect("reports an oversized diff file with its path and reason", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); - mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const error = yield* Effect.flip( - cli.getPullRequestDiffFileContents({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - changeType: "deleted", - oldPath: "src/large.ts", - newPath: "src/large.ts", - }), - ); - - assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError"); - if (error._tag === "GitHubDiffFileContentsUnavailableError") { - assert.strictEqual(error.path, "src/large.ts"); - assert.strictEqual(error.reason, "oversized"); - } - // A deleted file is read at the base revision only. - assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/large.ts?ref=a1b2c3d"); - }), - ); - - it.effect("reports undecodable diff file contents as binary", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); - mockedExecute.mockReturnValueOnce(Effect.succeed(output("binary�contents", false, true))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const error = yield* Effect.flip( - cli.getPullRequestDiffFileContents({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - changeType: "deleted", - oldPath: "assets/logo.png", - newPath: "assets/logo.png", - }), - ); - - assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError"); - if (error._tag === "GitHubDiffFileContentsUnavailableError") { - assert.strictEqual(error.path, "assets/logo.png"); - assert.strictEqual(error.reason, "binary"); - } - }), - ); - - it.effect("returns valid text containing a literal replacement character", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); - mockedExecute.mockReturnValueOnce(Effect.succeed(output("before�after"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const contents = yield* cli.getPullRequestDiffFileContents({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - changeType: "deleted", - oldPath: "docs/encoding.md", - newPath: "docs/encoding.md", - }); - - assert.strictEqual(contents.oldContents, "before�after"); - }), - ); - - it.effect("ends the diff on a page with no files rather than asking for it again", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const diff = yield* cli.getPullRequestDiff({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - cursor: "4", - }); - - assert.strictEqual(diff.patch, ""); - assert.isNull(diff.nextCursor); - }), - ); - - it.effect("reports the refused diff when the files API cannot answer either", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); - mockedExecute.mockReturnValueOnce(Effect.succeed(output("not json"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const error = yield* Effect.flip( - cli.getPullRequestDiff({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }), - ); - - assert.strictEqual(error, diffRefused); - }), - ); - - it.effect("skips the avatar lookup when a listing named nobody", () => - Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const avatars = yield* cli.listActorAvatars({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - ids: [], - }); - - assert.strictEqual(avatars.size, 0); - assert.strictEqual(mockedExecute.mock.calls.length, 0); - }), - ); - - it.effect("accounts for the avatar lookup in the GraphQL budget", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - encodeJson({ - data: { - nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }], - rateLimit: { - cost: 1, - limit: 5_000, - remaining: 4_999, - resetAt: "2099-08-13T14:00:00Z", - }, - }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const avatars = yield* cli.listActorAvatars({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - ids: ["MDQ6VXNlcjE="], - }); - - expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] }); - expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); - expect(avatars.get("octocat")).toBe("https://avatars/octocat"); - }), - ); - - it.effect("fails when the authenticated account has no login", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(" "))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w", host: "github.com" })); - - assert.strictEqual(error._tag, "GitHubViewerLoginUnavailableError"); - }), - ); - - it.effect("looks up the authenticated account on the requested enterprise host", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed(output('{"id":456,"login":"enterprise-user"}')), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" }); - - expect(login).toBe("enterprise-user"); - expect(mockedCredential).toHaveBeenCalledWith("github.acme.com"); - expect(callAt(0)).toMatchObject({ kind: "rest", host: "github.acme.com", path: "user" }); - }), - ); - - it.effect("reuses verified credentials offline and refuses an unverified replacement", () => - Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const input = { cwd: "/w", host: "github.identity-cache.test" }; - mockedCredential.mockImplementation((host) => - Effect.succeed({ token: Redacted.make("test-credential-a"), fingerprint: `${host}:a` }), - ); - mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}'))); - expect(yield* cli.getRoutingIdentity(input)).toEqual({ - accountId: "123", - viewer: "maria-rcks", - }); - - // The same credential again is answered from what was verified, without asking GitHub. - expect(yield* cli.getRoutingIdentity(input)).toEqual({ - accountId: "123", - viewer: "maria-rcks", - }); - expect(mockedExecute).toHaveBeenCalledTimes(1); - - // A switched credential is not trusted on the strength of the old one's answer. - mockedCredential.mockImplementation((host) => - Effect.succeed({ token: Redacted.make("test-credential-b"), fingerprint: `${host}:b` }), - ); - mockedExecute.mockReturnValueOnce( - Effect.fail( - new GitHubApi.GitHubApiResponseError({ - host: "github.com", - operation: "getRoutingIdentity", - status: 502, - }), - ), - ); - // GitHub's own refusal is reported as itself, so the page can say what went wrong. - const failure = yield* cli.getRoutingIdentity(input).pipe(Effect.flip); - expect(failure._tag).toBe("GitHubApiResponseError"); - expect(String(failure)).not.toContain("test-credential-b"); - expect(mockedExecute).toHaveBeenCalledTimes(2); - - mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}'))); - expect(yield* cli.getRoutingIdentity(input)).toEqual({ - accountId: "456", - viewer: "maria-rcks", - }); - }), - ); - - it.effect("sends a whole review as one request", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.submitReview({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - verdict: "approve", - body: "Looks right.", - comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], - }); - - // One request, so nothing is on the pull request until the verdict is. - assert.strictEqual(mockedExecute.mock.calls.length, 1); - expect(restCallsTo("/reviews")).toMatchObject([ - { - method: "POST", - path: "repos/acme/web/pulls/7/reviews", - body: { - event: "APPROVE", - body: "Looks right.", - comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }], - }, - }, - ]); - }), - ); - - it.effect("sends a reply body as a variable, never inside the document", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.replyToReviewThread({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - threadId: "PRRT_1", - body: "Fixed in 42ff8ec.", - }); - - expect(variablesOf("addPullRequestReviewThreadReply(")).toEqual([ - { threadId: "PRRT_1", body: "Fixed in 42ff8ec." }, - ]); - expect(queryAt(0)).not.toContain("Fixed in 42ff8ec."); - }), - ); - - it.effect("resolves and unresolves through the mutation each one needs", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.setReviewThreadResolution({ - cwd: "/w", - repository: "acme/web", - host: "github.acme.dev", - threadId: "PRRT_1", - resolved: true, - }); - yield* cli.setReviewThreadResolution({ - cwd: "/w", - repository: "acme/web", - host: "github.acme.dev", - threadId: "PRRT_1", - resolved: false, - }); - - expect(queryAt(0)).toContain("resolveReviewThread("); - expect(queryAt(0)).not.toContain("unresolveReviewThread("); - expect(queryAt(1)).toContain("unresolveReviewThread("); - expect([varsAt(0), varsAt(1)]).toEqual([{ threadId: "PRRT_1" }, { threadId: "PRRT_1" }]); - // A GitHub Enterprise thread is resolved on its own host, not on github.com. - expect([callAt(0).host, callAt(1).host]).toEqual(["github.acme.dev", "github.acme.dev"]); - }), - ); - - it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () => - Effect.gen(function* () { - route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.setReaction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - subjectId: "IC_1", - content: "heart", - reacted: true, - }); - - assert.strictEqual(mockedExecute.mock.calls.length, 2); - expect(variablesOf(SUBJECT_SCOPE_QUERY)).toEqual([ - { owner: "acme", name: "web", number: 7, subjectId: "IC_1" }, - ]); - expect(variablesOf("addReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); - }), - ); - - it.effect("refuses a given subject that belongs to a different pull request", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - encodeJson({ - data: { - repository: { pullRequest: { id: "PR_thisOne" } }, - // A comment on pull request #99 of a different repository, named as though it - // belonged to #7 here. - node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, - }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const error = yield* Effect.flip( - cli.setReaction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - subjectId: "IC_99", - content: "heart", - reacted: true, - }), - ); - - assert.strictEqual(error._tag, "GitHubSubjectScopeError"); - // Refused before any mutation was sent. - assert.strictEqual(mockedExecute.mock.calls.length, 1); - }), - ); - - it.effect("looks up the pull request's own node id when no subject was given", () => - Effect.gen(function* () { - route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - // Its own pull request: a node id looked up once is remembered for the life of the service. - yield* cli.setReaction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 21, - content: "rocket", - reacted: true, - }); - - assert.strictEqual(mockedExecute.mock.calls.length, 2); - expect(variablesOf(NODE_ID_QUERY)).toEqual([{ owner: "acme", name: "web", number: 21 }]); - expect(variablesOf("addReaction(")).toEqual([{ subjectId: "PR_kwDOA", content: "ROCKET" }]); - }), - ); - - it.effect("takes a reaction back through the remove mutation", () => - Effect.gen(function* () { - route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.setReaction({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - subjectId: "IC_1", - content: "heart", - reacted: false, - }); - - expect(variablesOf("removeReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); - expect(variablesOf("addReaction(")).toEqual([]); - }), - ); - - it.effect("rewrites only the words a request named", () => - Effect.gen(function* () { - route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const rewrite = (fields: { readonly title?: string; readonly body?: string }) => - cli.updatePullRequest({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 22, - ...fields, - }); - - yield* rewrite({ title: "A better title" }); - yield* rewrite({ body: "A better description." }); - yield* rewrite({ title: "Both", body: "at once." }); - - // One node id lookup for the pull request, then a mutation per rewrite. - assert.strictEqual(variablesOf(NODE_ID_QUERY).length, 1); - expect(variablesOf("updatePullRequest(")).toEqual([ - { pullRequestId: "PR_kwDOA", title: "A better title" }, - { pullRequestId: "PR_kwDOA", body: "A better description." }, - { pullRequestId: "PR_kwDOA", title: "Both", body: "at once." }, - ]); - }), - ); - - it.effect("rewrites a remark through the mutation its kind needs", () => - Effect.gen(function* () { - route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const rewrite = (kind: "issue-comment" | "review-comment") => - cli.updateComment({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - commentId: "IC_1", - kind, - body: "Reworded.", - }); - - yield* rewrite("issue-comment"); - yield* rewrite("review-comment"); - - expect(variablesOf(SUBJECT_SCOPE_QUERY).map((variables) => variables["subjectId"])).toEqual([ - "IC_1", - "IC_1", - ]); - expect(variablesOf("updateIssueComment(")).toEqual([ - { commentId: "IC_1", body: "Reworded." }, - ]); - expect(variablesOf("updatePullRequestReviewComment(")).toEqual([ - { commentId: "IC_1", body: "Reworded." }, - ]); - }), - ); - - it.effect("refuses a comment that belongs to a different pull request", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - encodeJson({ - data: { - repository: { pullRequest: { id: "PR_thisOne" } }, - node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, - }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const error = yield* Effect.flip( - cli.updateComment({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - commentId: "IC_99", - kind: "issue-comment", - body: "Reworded.", - }), - ); - - assert.strictEqual(error._tag, "GitHubSubjectScopeError"); - if (error._tag === "GitHubSubjectScopeError") - assert.strictEqual(error.operation, "updateComment"); - // Refused before any mutation was sent. - assert.strictEqual(mockedExecute.mock.calls.length, 1); - }), - ); - - it.effect("fails the read when gh returns something unreadable", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const error = yield* Effect.flip( - cli.getPullRequestDetail({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }), - ); - - assert.strictEqual(error._tag, "GitHubPullRequestReadError"); - }), - ); - - it.effect("keeps the core detail read separate from conversation activity", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - encodeJson( - coreResponse({ - title: "Progressive detail", - author: { login: "octocat" }, - body: "Core body", - changedFiles: 2, - }), - ), - ), - ), - ); - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - encodeJson({ - data: { - repository: { - pullRequest: { - author: { __typename: "User", login: "octocat", avatarUrl: "https://a/o" }, - commits: { nodes: [] }, - comments: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, - reviews: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, - }, - }, - }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const input = { - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - } as const; - - const detail = yield* cli.getPullRequestDetail(input); - const activity = yield* cli.getPullRequestActivity(input); - - expect(detail.body).toBe("Core body"); - expect(activity.author?.login).toBe("octocat"); - expect(varsAt(0)).toMatchObject({ number: 7, headRef: "refs/pull/7/head" }); - expect(queryAt(0)).toContain("viewerCanUpdateBranch"); - expect(queryAt(0)).not.toContain("reviews("); - expect(detail.viewerAccess.mergeCapabilities).toEqual({ - merge: true, - squash: false, - rebase: true, - }); - expect(detail.comparison).toEqual({ behindBy: 2, viewerCanUpdate: true }); - // Conversation activity is its own read, and asks for the head of the conversation once. - expect(queryAt(1)).toContain("reviews("); - expect(varsAt(1)).toMatchObject({ head: true, withComments: true, withReviews: true }); - assert.strictEqual(mockedExecute.mock.calls.length, 2); - }), - ); - - it.effect("decodes reviewers, labels and workflow checks without another detail read", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - encodeJson( - coreResponse({ - baseRef: null, - reviewRequests: { - nodes: [ - { requestedReviewer: { login: "reviewer" } }, - { requestedReviewer: { slug: "maintainers", name: "Maintainers" } }, - ], - }, - labels: { nodes: [{ name: "bug", color: "ff0000" }] }, - commits: { - nodes: [ - { - commit: { - statusCheckRollup: { - contexts: { - nodes: [ - { - __typename: "CheckRun", - name: "build", - status: "COMPLETED", - conclusion: "SUCCESS", - checkSuite: { workflowRun: { workflow: { name: "linux" } } }, - }, - { - __typename: "CheckRun", - name: "build", - status: "COMPLETED", - conclusion: "FAILURE", - checkSuite: { workflowRun: { workflow: { name: "windows" } } }, - }, - ], - pageInfo: { hasNextPage: false }, - }, - }, - }, - }, - ], - }, - }), - ), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const detail = yield* cli.getPullRequestDetail({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - expect(mockedExecute).toHaveBeenCalledOnce(); - expect(detail.comparison).toBeNull(); - expect(detail.reviewRequestLogins).toEqual(["reviewer"]); - expect(detail.hasTeamReviewRequest).toBe(true); - expect(detail.labels).toEqual([{ name: "bug", color: "ff0000" }]); - expect(detail.checks).toHaveLength(2); - expect(detail.checksState).toBe("failing"); - }), - ); - - it.effect("reads every check when the combined response has another page", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - encodeJson( - coreResponse({ - commits: { - nodes: [ - { - commit: { - statusCheckRollup: { - contexts: { - nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }], - pageInfo: { hasNextPage: true, endCursor: "c1" }, - }, - }, - }, - }, - ], - }, - }), - ), - ), - ), - ); - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - checkContextsPage( - [ - { name: "first", status: "COMPLETED", conclusion: "SUCCESS" }, - { name: "last", status: "COMPLETED", conclusion: "FAILURE" }, - ], - null, - ), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const detail = yield* cli.getPullRequestDetail({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - expect(detail.checks).toHaveLength(2); - expect(detail.checksState).toBe("failing"); - expect(detail.checksTruncated).toBe(false); - // The whole rollup is walked from its start, so no check is counted twice or skipped. - expect(varsAt(1)).toMatchObject({ number: 7, after: null }); - assert.strictEqual(mockedExecute.mock.calls.length, 2); - }), - ); - - it.effect("refuses to combine checks from different head revisions", () => - Effect.gen(function* () { - const response = coreResponse({ - commits: { - nodes: [ - { - commit: { - statusCheckRollup: { - contexts: { - nodes: [], - pageInfo: { hasNextPage: true }, - }, - }, - }, - }, - ], - }, - }); - mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response)))); - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - encodeJson({ - ...response.data.repository.pullRequest, - headRefOid: "new-head", - reviewRequests: [], - labels: [], - statusCheckRollup: [], - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const error = yield* Effect.flip( - cli.getPullRequestDetail({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }), - ); - expect(error._tag).toBe("GitHubPullRequestReadError"); - }), - ); - - it.effect("preserves the reserve for automatic detail reads and allows manual checks", () => - Effect.gen(function* () { - const response = coreResponse(); - mockedExecute.mockReturnValue( - Effect.succeed( - output( - encodeJson({ - ...response, - data: { - ...response.data, - rateLimit: { - cost: 1, - limit: 5000, - remaining: 500, - resetAt: "2099-08-13T14:00:00Z", - }, - }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const input = { - cwd: "/w", - repository: "acme/web", - host: "github.core-reserve.test", - number: 7, - }; - yield* cli.getPullRequestDetail(input); - const error = yield* Effect.flip(cli.getPullRequestDetail(input)); - expect(error._tag).toBe("SourceControlRateLimitPausedError"); - expect(mockedExecute).toHaveBeenCalledOnce(); - yield* cli.getPullRequestDetail(input).pipe(Effect.provideService(AllowGitHubReserve, true)); - expect(mockedExecute).toHaveBeenCalledTimes(2); - }), - ); - - it.effect("fails a files page too large to read rather than calling the diff whole", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1), true))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const error = yield* Effect.flip( - cli.getPullRequestDiff({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }), - ); - - // What matters is that it fails at all: an empty patch with no cursor would render as a - // change with no files and report the rest of it as already read. The refusal that sent - // the read down this road is the one reported, by design. - assert.strictEqual(error, diffRefused); - }), - ); - - it.effect("pages an oversized patch by file rather than handing back a severed one", () => - Effect.gen(function* () { - // The whole diff came back, but cut at a byte, which lands mid-file. - mockedExecute.mockReturnValueOnce( - Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)), - ); - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const slice = yield* cli.getPullRequestDiff({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - // The severed patch is thrown away; what comes back is assembled from whole files. - assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); - expect(slice.patch).toContain("src/file1.ts"); - expect(slice.patch).not.toContain("diff --git a/a b/a"); - assert.strictEqual(mockedExecute.mock.calls.length, 2); - }), - ); - - it.effect("follows the cursor to the review threads the first page left behind", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(reviewThreadsPage([thread("PRRT_1", "c1")], "Y3Vyc29yOjE"))), - ); - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(reviewThreadsPage([thread("PRRT_2", "c2")], null))), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const conversation = yield* cli.listReviewThreadComments({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - // The first page asks from the beginning; the next carries on from where it stopped. - expect(varsAt(0)["cursor"]).toBeNull(); - expect(varsAt(1)["cursor"]).toBe("Y3Vyc29yOjE"); - expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2"]); - assert.isFalse(conversation.truncated); - }), - ); - - it.effect("stops at the thread bound and says the conversation was cut short", () => - Effect.gen(function* () { - // A host that never runs out of pages: the walk has to end itself. - mockedExecute.mockReturnValue( - Effect.succeed( - output( - reviewThreadsPage( - [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }], - "Y3Vyc29yOjE", - ), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const conversation = yield* cli.listReviewThreadComments({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - assert.strictEqual(mockedExecute.mock.calls.length, 10); - assert.isTrue(conversation.truncated); - assert.isTrue(conversation.reviewThreadsTruncated); - }), - ); - - it.effect("leaves a long thread paged until the reader asks for more", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - reviewThreadsPage( - [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }], - null, - ), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const conversation = yield* cli.listReviewThreadComments({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - assert.strictEqual(mockedExecute.mock.calls.length, 1); - expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1"]); - expect(conversation.reviewThreads[0]).toMatchObject({ - commentCount: 3, - nextCommentsCursor: "Y3Vyc29yOjI", - }); - assert.isTrue(conversation.truncated); - assert.isFalse(conversation.reviewThreadsTruncated); - }), - ); - - it.effect("reads one requested page from a review thread cursor", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(threadCommentsPage(["c2", "c3"], null, 3))), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const page = yield* cli.getReviewThreadComments({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - threadId: "PRRT_1", - cursor: "Y3Vyc29yOjI", - }); - - expect(varsAt(0)).toMatchObject({ - owner: "acme", - name: "web", - number: 7, - threadId: "PRRT_1", - cursor: "Y3Vyc29yOjI", - }); - assert.strictEqual(mockedExecute.mock.calls.length, 1); - expect(page.comments.map((comment) => comment.id)).toEqual(["c2", "c3"]); - expect(page.nextCursor).toBeNull(); - }), - ); - - it.effect("refuses a review thread from another pull request", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(threadCommentsPage(["foreign"], null, 1, "PR_8"))), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const error = yield* Effect.flip( - cli.getReviewThreadComments({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - threadId: "PRRT_FOREIGN", - cursor: "Y3Vyc29yOjI", - }), - ); - - assert.strictEqual(error._tag, "GitHubSubjectScopeError"); - }), - ); - - it.effect( - "asks for the reader's standing on the repository and on the pull request at once", - () => - Effect.gen(function* () { - mockedExecute.mockReturnValue( - Effect.succeed( - output( - encodeJson({ - data: { - repository: { - mergeCommitAllowed: true, - squashMergeAllowed: false, - rebaseMergeAllowed: true, - viewerPermission: "READ", - pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, - }, - }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const access = yield* cli.getViewerAccess({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - // One request, because both answers hang off the same repository object. - assert.strictEqual(mockedExecute.mock.calls.length, 1); - expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); - expect(queryAt(0)).toContain("mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed"); - expect(access).toEqual({ - mergeCapabilities: { merge: true, squash: false, rebase: true }, - canWrite: false, - canTriage: false, - canUpdate: true, - didAuthor: true, - }); - }), - ); - - it.effect("stops GraphQL reads at the protected reserve until reset", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue( - Effect.succeed( - output( - encodeJson({ - data: { - repository: { - mergeCommitAllowed: true, - squashMergeAllowed: false, - rebaseMergeAllowed: true, - viewerPermission: "READ", - pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, - }, - rateLimit: { - cost: 1, - limit: 5_000, - remaining: 500, - resetAt: "2099-08-13T14:00:00Z", - }, - }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const input = { - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - } as const; - - yield* cli.getViewerAccess(input); - expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); - - const error = yield* Effect.flip(cli.getViewerAccess(input)); - - assert.strictEqual(error._tag, "SourceControlRateLimitPausedError"); - if (error._tag !== "SourceControlRateLimitPausedError") return; - assert.strictEqual(error.host, "github.com"); - assert.strictEqual(error.retryAt, Date.parse("2099-08-13T14:00:00Z")); - assert.strictEqual(mockedExecute.mock.calls.length, 1); - yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z")); - }), - ); - - it.effect("lets an interactive permission read use the protected reserve", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce( - Effect.succeed( - output( - encodeJson({ - data: { - repository: { - mergeCommitAllowed: true, - squashMergeAllowed: false, - rebaseMergeAllowed: true, - viewerPermission: "READ", - pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, - }, - rateLimit: { - cost: 1, - limit: 5_000, - remaining: 500, - resetAt: "2099-08-13T14:00:00Z", - }, - }, - }), - ), - ), - ) - .mockReturnValueOnce( - Effect.succeed( - output( - encodeJson({ - data: { - repository: { - mergeCommitAllowed: true, - squashMergeAllowed: false, - rebaseMergeAllowed: true, - viewerPermission: "READ", - pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, - }, - }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.getViewerAccess({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - const access = yield* cli.getViewerAccess({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - allowReserve: true, - }); - - assert.strictEqual(mockedExecute.mock.calls.length, 2); - expect(access).toEqual({ - mergeCapabilities: { merge: true, squash: false, rebase: true }, - canWrite: false, - canTriage: false, - canUpdate: true, - didAuthor: true, - }); - yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z")); - }), - ); - - it.effect("asks GitHub to review, naming the collection a request is added to", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.setReviewerRequest({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - reviewers: [ - { id: "octocat", kind: "user" }, - { id: "reviewers", kind: "team" }, - ], - requested: true, - }); - - expect(callAt(0)).toMatchObject({ - kind: "rest", - method: "POST", - path: "repos/acme/web/pulls/7/requested_reviewers", - body: { reviewers: ["octocat"], team_reviewers: ["reviewers"] }, - }); - }), - ); - - it.effect("takes a request back by deleting from the same collection it was added to", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.setReviewerRequest({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - reviewers: [{ id: "octocat", kind: "user" }], - requested: false, - }); - - expect(callAt(0)).toMatchObject({ - kind: "rest", - method: "DELETE", - path: "repos/acme/web/pulls/7/requested_reviewers", - body: { reviewers: ["octocat"], team_reviewers: [] }, - }); - }), - ); - - it.effect("reads who may review and who already has in one request", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue( - Effect.succeed( - output( - encodeJson({ - data: { - repository: { - assignableUsers: { - pageInfo: { hasNextPage: false }, - nodes: [{ login: "bilal" }, { login: "octocat" }, { login: "hubot" }], - }, - pullRequest: { - author: { login: "bilal" }, - reviewRequests: { nodes: [{ requestedReviewer: { login: "octocat" } }] }, - }, - }, - }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const list = yield* cli.listReviewerCandidates({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - // The people, who has been asked and who opened the pull request all hang off the same - // repository object, so the menu costs one request. - assert.strictEqual(mockedExecute.mock.calls.length, 1); - expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); - expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ - ["octocat", true], - ["hubot", false], - ]); - }), - ); - - it.effect("puts labels on by posting to the issue's own collection, all at once", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.setLabels({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - labels: ["bug", "size:XL"], - applied: true, - }); - - assert.strictEqual(mockedExecute.mock.calls.length, 1); - expect(callAt(0)).toMatchObject({ - kind: "rest", - method: "POST", - path: "repos/acme/web/issues/7/labels", - body: { labels: ["bug", "size:XL"] }, - }); - }), - ); - - it.effect("takes labels off one at a time, naming each in the path encoded", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.setLabels({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - labels: ["good first issue", "area/web"], - applied: false, - }); - - assert.strictEqual(mockedExecute.mock.calls.length, 2); - expect(callAt(0)).toMatchObject({ - method: "DELETE", - path: "repos/acme/web/issues/7/labels/good%20first%20issue", - }); - expect(callAt(1)).toMatchObject({ - method: "DELETE", - path: "repos/acme/web/issues/7/labels/area%2Fweb", - }); - }), - ); - - it.effect("reads every page of viewed files, and says so when there are too many", () => - Effect.gen(function* () { - const page = (index: number, hasNextPage: boolean) => - Effect.succeed( - output( - encodeJson({ - data: { - repository: { - pullRequest: { - files: { - pageInfo: { hasNextPage, endCursor: `cursor-${index}` }, - nodes: [ - { path: `src/file${index}.ts`, viewerViewedState: "VIEWED" }, - { path: `src/other${index}.ts`, viewerViewedState: "UNVIEWED" }, - ], - }, - }, - }, - }, - }), - ), - ); - mockedExecute - .mockReturnValueOnce(page(0, true)) - .mockReturnValueOnce(page(1, true)) - .mockReturnValueOnce(page(2, false)); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const viewed = yield* cli.getPullRequestFilesViewed({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - assert.strictEqual(mockedExecute.mock.calls.length, 3); - // The first page asks from the start; each one after it carries the cursor before it. - expect([varsAt(0)["after"], varsAt(1)["after"], varsAt(2)["after"]]).toEqual([ - null, - "cursor-0", - "cursor-1", - ]); - assert.isFalse(viewed.truncated); - expect(viewed.files.map((file) => [file.path, file.state])).toEqual([ - ["src/file0.ts", "viewed"], - ["src/other0.ts", "unviewed"], - ["src/file1.ts", "viewed"], - ["src/other1.ts", "unviewed"], - ["src/file2.ts", "viewed"], - ["src/other2.ts", "unviewed"], - ]); - }), - ); - - it.effect("stops paging viewed files rather than following a change without end", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue( - Effect.succeed( - output( - encodeJson({ - data: { - repository: { - pullRequest: { - files: { - pageInfo: { hasNextPage: true, endCursor: "cursor" }, - nodes: [{ path: "src/file.ts", viewerViewedState: "VIEWED" }], - }, - }, - }, - }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const viewed = yield* cli.getPullRequestFilesViewed({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - assert.strictEqual(mockedExecute.mock.calls.length, 5); - assert.isTrue(viewed.truncated); - assert.strictEqual(viewed.files.length, 5); - }), - ); - - it.effect("clears and restores a burst of files in one request", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce( - Effect.succeed( - output(encodeJson({ data: { repository: { pullRequest: { id: "PR_1" } } } })), - ), - ) - .mockReturnValueOnce(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.setPullRequestFilesViewed({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 23, - files: [ - { path: "src/a.ts", viewed: true }, - { path: "src/b.ts", viewed: false }, - ], - }); - - // One request to learn the pull request's node id, one for every press together. - assert.strictEqual(mockedExecute.mock.calls.length, 2); - expect(queryAt(1)).toContain("f0: markFileAsViewed"); - expect(queryAt(1)).toContain("f1: unmarkFileAsViewed"); - expect(varsAt(1)).toEqual({ - pullRequestId: "PR_1", - path0: "src/a.ts", - path1: "src/b.ts", - }); - }), - ); - - it.effect("asks the host nothing when nothing was pressed", () => - Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - yield* cli.setPullRequestFilesViewed({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - files: [], - }); - - assert.strictEqual(mockedExecute.mock.calls.length, 0); - }), - ); - - it.effect("looks a pull request's node id up once, however often it is written to", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce( - Effect.succeed( - output(encodeJson({ data: { repository: { pullRequest: { id: "PR_24" } } } })), - ), - ) - .mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const pullRequest = { cwd: "/w", repository: "acme/web", host: "github.com", number: 24 }; - - yield* cli.setPullRequestFilesViewed({ - ...pullRequest, - files: [{ path: "src/a.ts", viewed: true }], - }); - yield* cli.setPullRequestFilesViewed({ - ...pullRequest, - files: [{ path: "src/b.ts", viewed: true }], - }); - yield* cli.updatePullRequest({ ...pullRequest, title: "Ticked through" }); - - // One lookup, then a mutation per write, every one of them addressed by the id it answered. - assert.strictEqual(mockedExecute.mock.calls.length, 4); - expect(varsAt(0)).toEqual({ owner: "acme", name: "web", number: 24 }); - expect([1, 2, 3].map((index) => varsAt(index)["pullRequestId"])).toEqual([ - "PR_24", - "PR_24", - "PR_24", - ]); - }), - ); - - it.effect("does not remember a node id lookup that failed", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))) - .mockReturnValueOnce( - Effect.succeed( - output(encodeJson({ data: { repository: { pullRequest: { id: "PR_25" } } } })), - ), - ) - .mockReturnValueOnce(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const write = () => - cli.setPullRequestFilesViewed({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 25, - files: [{ path: "src/a.ts", viewed: true }], - }); - - const error = yield* Effect.flip(write()); - assert.strictEqual(error._tag, "GitHubPullRequestReadError"); - - yield* write(); - - assert.strictEqual(mockedExecute.mock.calls.length, 3); - expect(varsAt(2)["pullRequestId"]).toEqual("PR_25"); - }), - ); - it.effect("keeps the pull request being ticked through, not the one looked up first", () => - Effect.gen(function* () { - // Ordered by insertion alone, a hit does not renew its entry, so the review the reader is - // working down is the first thing evicted once a listing has walked a cache's worth of cold - // pull requests, and every press after that pays a round trip again. - // This block shares one cache, so these numbers are its own and it runs last. - const HOT = 9_000; - const lookupsOf = new Map(); - mockedExecute.mockImplementation((input) => { - if (input.kind !== "graphql" || !input.query.includes(NODE_ID_QUERY)) { - return Effect.succeed(output("{}")); - } - const number = Number(input.variables?.["number"]); - lookupsOf.set(number, (lookupsOf.get(number) ?? 0) + 1); - return Effect.succeed( - output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })), - ); - }); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const tick = (number: number) => - cli.setPullRequestFilesViewed({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number, - files: [{ path: "src/a.ts", viewed: true }], - }); - - yield* tick(HOT); - // A cache's worth of cold pull requests, with the open one pressed in between each of them. - for (let filled = 0; filled < GitHubPullRequestCli.NODE_ID_CACHE_CAPACITY; filled += 1) { - yield* tick(HOT + 1 + filled); - yield* tick(HOT); - } - - assert.strictEqual(lookupsOf.get(HOT), 1); - }), - ); -}); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts deleted file mode 100644 index ce944b931aa86acae146524d0f99596075435bbc..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ /dev/null @@ -1,2874 +0,0 @@ -import { removeAgentCredits } from "./mergeMessage.ts"; -import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; -import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; -import * as Cause from "effect/Cause"; -import * as Context from "effect/Context"; -import * as Clock from "effect/Clock"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; -import * as Request from "effect/Request"; -import * as RequestResolver from "effect/RequestResolver"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; -import * as Semaphore from "effect/Semaphore"; -import { - resolvePullRequestAuthorFilter, - PositiveInt, - TrimmedNonEmptyString, - type PullRequestAction, - type PullRequestStackHead, - type PullRequestActor, - type PullRequestComment, - type PullRequestCommit, - type PullRequestFileViewed, - type PullRequestInvolvement, - type PullRequestListFilters, - type PullRequestListState, - type PullRequestMergeMethod, - type PullRequestOmittedFileStat, - type PullRequestReaction, - type PullRequestReactionContent, - type PullRequestReviewCommentDraft, - type PullRequestReviewVerdict, - type PullRequestReviewerCandidateList, - type PullRequestReviewerKind, - type PullRequestLabelCandidateList, - type PullRequestThreadCommentsResult, - type PullRequestUpdateMethod, - type PullRequestPreview, -} from "@t3tools/contracts"; - -import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; -import { - ACTOR_AVATARS_GRAPHQL_QUERY, - ADD_REACTION_GRAPHQL_MUTATION, - buildReviewSubmission, - buildReviewerRequest, - buildSetFilesViewedGraphQlMutation, - decodeActorAvatarsJson, - decodePullRequestActivityJson, - decodePullRequestCheckContextsJson, - decodePullRequestCoreJson, - decodeCommitFilesJson, - pullRequestCheckContextsGraphQlQuery, - pullRequestChecksFromContexts, - pullRequestListGraphQlQuery, - pullRequestSummaryGraphQlQuery, - PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, - PULL_REQUEST_HEADS_GRAPHQL_QUERY, - decodeWorkflowRunsJson, - type GitHubCheckContext, - pullRequestCoreGraphQlQuery, - type GitHubPullRequestCore, - type GitHubPullRequestSummary, - type GitHubPullRequestWatchFingerprint, - decodePullRequestPreviewJson, - PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, - decodePullRequestFilesJson, - decodePullRequestFilesViewedJson, - decodePullRequestHeadsJson, - decodePullRequestListJson, - decodePullRequestNodeIdJson, - decodePullRequestSearchJson, - decodePullRequestStacksJson, - decodePullRequestStatsJson, - decodePullRequestSummariesJson, - decodePullRequestWatchFingerprintsJson, - decodeReactionSubjectScopeJson, - decodeReviewerCandidatesJson, - decodeLabelCandidatesJson, - buildLabelRequest, - LABEL_CANDIDATES_GRAPHQL_QUERY, - decodeReviewDismissalsJson, - decodeReviewThreadCommentsJson, - decodeReviewThreadsJson, - buildPullRequestStatsGraphQlQuery, - buildPullRequestSummariesGraphQlQuery, - buildPullRequestWatchFingerprintsGraphQlQuery, - buildPullRequestStackMembershipsGraphQlQuery, - decodePullRequestStackMembershipsJson, - pullRequestSearchGraphQlQuery, - PULL_REQUEST_SEARCH_MAX_ROWS, - PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, - PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, - REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, - REMOVE_REACTION_GRAPHQL_MUTATION, - REVERT_PULL_REQUEST_GRAPHQL_MUTATION, - gitHubReactionContent, - RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, - REVIEWER_CANDIDATES_GRAPHQL_QUERY, - REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, - REVIEW_DISMISSALS_GRAPHQL_QUERY, - REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, - REVIEW_THREADS_GRAPHQL_QUERY, - reviewThreadConversation, - UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, - UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION, - UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, - UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, - VIEWER_PERMISSIONS_GRAPHQL_QUERY, - decodeViewerPermissionsJson, - type GitHubPullRequestActivity, - type GitHubPullRequestActivityPage, - type GitHubWorkflowRunPage, - type GitHubPullRequestHead, - type GitHubPullRequestListItem, - type GitHubPullRequestSearchItem, - type GitHubPullRequestStack, - type GitHubReviewThreadComments, - type GitHubRepositoryAccess, - type GitHubWorkflowRunApproval, - type GitHubReviewThreadEntry, - type GitHubReviewThreadPage, - type GitHubViewerAccess, -} from "./gitHubPullRequestJson.ts"; -import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullRequestProvider.ts"; - -/** - * Names the read that produced unusable output, so a failure reports the call it came from - * rather than borrowing another operation's message. - */ -export class GitHubPullRequestReadError extends Schema.TaggedError()( - "GitHubPullRequestReadError", - { - command: Schema.Literal("gh"), - cwd: Schema.String, - operation: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `GitHub returned an unreadable ${this.operation} response.`; - } -} - -/** Not a decode failure: gh answered, the account it answered for just has no login. */ -export class GitHubViewerLoginUnavailableError extends Schema.TaggedError()( - "GitHubViewerLoginUnavailableError", - { - command: Schema.Literal("gh"), - cwd: Schema.String, - }, -) { - override get message(): string { - return "GitHub returned no login for the authenticated account."; - } -} - -/** Not a decode failure: gh answered, but the pull request carried no update time. */ -export class GitHubPullRequestUpdatedAtUnavailableError extends Schema.TaggedError()( - "GitHubPullRequestUpdatedAtUnavailableError", - { - command: Schema.Literal("gh"), - cwd: Schema.String, - repository: Schema.String, - number: Schema.Int, - }, -) { - override get message(): string { - return `Pull request ${this.repository}#${this.number} reported no update time.`; - } -} - -/** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */ -export class GitHubDiffCursorError extends Schema.TaggedError()( - "GitHubDiffCursorError", - { - command: Schema.Literal("gh"), - cwd: Schema.String, - }, -) { - override get message(): string { - return "The diff cursor was not one this pull request handed out."; - } -} - -/** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */ -export class GitHubDiffCommitError extends Schema.TaggedError()( - "GitHubDiffCommitError", - { - command: Schema.Literal("gh"), - cwd: Schema.String, - }, -) { - override get message(): string { - return "The named commit was not a commit sha."; - } -} - -/** The revisions read successfully, but cannot name both sides this file needs. */ -export class GitHubDiffRevisionsUnavailableError extends Schema.TaggedError()( - "GitHubDiffRevisionsUnavailableError", - { - command: Schema.Literal("gh"), - cwd: Schema.String, - number: Schema.Int, - commit: Schema.optional(Schema.String), - }, -) { - override get message(): string { - return this.commit === undefined - ? `Pull request #${this.number} reported no usable base and head revisions.` - : `Commit ${this.commit} reported no usable revisions for this file.`; - } -} - -/** A blob exists, but expanding it would be unsafe or would not produce text. */ -export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedError()( - "GitHubDiffFileContentsUnavailableError", - { - command: Schema.Literal("gh"), - cwd: Schema.String, - path: Schema.String, - reason: Schema.Literals(["oversized", "binary"]), - }, -) { - override get message(): string { - return this.reason === "oversized" - ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.` - : `The diff file '${this.path}' is binary.`; - } -} - -/** - * Not a decode failure: a repository was named that cannot go into a search or into a GraphQL - * document as itself. Every qualifier and every alias below is composed from `owner/name`, so a - * name that is not one is refused here rather than escaped into something GitHub might read as a - * qualifier of its own. - */ -export class GitHubRepositorySelectorError extends Schema.TaggedError()( - "GitHubRepositorySelectorError", - { - command: Schema.Literal("gh"), - cwd: Schema.String, - operation: Schema.String, - }, -) { - override get message(): string { - return "A repository was named that GitHub cannot address."; - } -} - -/** Not a decode failure: the reader named a subject this pull request never handed out. */ -export class GitHubSubjectScopeError extends Schema.TaggedError()( - "GitHubSubjectScopeError", - { - command: Schema.Literal("gh"), - cwd: Schema.String, - operation: Schema.String, - }, -) { - override get message(): string { - return "The named subject did not belong to the named pull request."; - } -} - -/** GitHub answered successfully, but approving every returned workflow would be unsafe. */ -export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError()( - "GitHubWorkflowApprovalRefusedError", - { - command: Schema.Literal("gh"), - cwd: Schema.String, - number: Schema.Int, - reason: Schema.Literals(["head-list-truncated", "head-not-unique", "run-list-truncated"]), - observedCount: Schema.Int, - limit: Schema.Int, - }, -) { - override get message(): string { - if (this.reason === "head-list-truncated") { - return `GitHub returned more than ${this.limit} pull requests for this head branch.`; - } - if (this.reason === "head-not-unique") { - return `The head revision matched ${this.observedCount} pull requests instead of uniquely matching #${this.number}.`; - } - return `GitHub returned more than ${this.limit} workflow runs awaiting approval.`; - } -} - -/** GitHub omitted the immutable head identity needed to scope an approval safely. */ -export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedError()( - "GitHubWorkflowApprovalHeadUnavailableError", - { - command: Schema.Literal("gh"), - cwd: Schema.String, - number: Schema.Int, - }, -) { - override get message(): string { - return `GitHub did not report a complete head revision for #${this.number}.`; - } -} - -/** The pull request moved after its approval candidates were read. */ -export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError()( - "GitHubWorkflowApprovalHeadChangedError", - { - command: Schema.Literal("gh"), - cwd: Schema.String, - number: Schema.Int, - }, -) { - override get message(): string { - return `The head revision of #${this.number} changed before its workflows could be approved.`; - } -} - -export type GitHubPullRequestCliError = - | GitHubStackActionError - | GitHubApi.GitHubApiError - | GitHubPullRequestReadError - | GitHubDiffCursorError - | GitHubDiffCommitError - | GitHubDiffRevisionsUnavailableError - | GitHubDiffFileContentsUnavailableError - | GitHubRepositorySelectorError - | GitHubSubjectScopeError - | GitHubWorkflowApprovalRefusedError - | GitHubWorkflowApprovalHeadUnavailableError - | GitHubWorkflowApprovalHeadChangedError - | SourceControlRateLimit.SourceControlRateLimitPausedError - | GitHubViewerLoginUnavailableError - | GitHubPullRequestUpdatedAtUnavailableError; - -/** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ -const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; -const DIFF_TIMEOUT = Duration.seconds(60); -/** Pierre expansion is for source files, not blobs large enough to stall a review surface. */ -const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; - -/** What `gh pr list --state` asked a repository's own list for, without search. */ -const LIST_STATES: Record> = { - all: ["OPEN", "CLOSED", "MERGED"], - open: ["OPEN"], - // Closed includes merged here, the way GitHub's own list reads it; merged rows are filtered out - // locally by `matchesUnsortedListing`. - closed: ["CLOSED", "MERGED"], - merged: ["MERGED"], -}; - -interface GitHubPullRequestListPage { - readonly items: ReadonlyArray; - readonly rawCount: number; - readonly endCursor: string | null; -} - -/** A search-free fallback may scan older rows for local filters, but never the whole repository. */ -const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; - -/** What the files API serves at most in one response, which is what one slice is made of. */ -const DIFF_FILES_PAGE_SIZE = 100; -/** - * How many hundred-file pages of viewed state one read will walk. A point of the hourly GraphQL - * budget per page, against a change request nobody reviews in one sitting past the first few - * hundred files: beyond this the read stops and says it was cut short. - */ -const FILES_VIEWED_MAX_PAGES = 5; - -/** - * How many pull requests' node ids are remembered at once. A long-lived server sees far more of - * them than a reader ever has open, and least recently used rather than first in: a listing - * walking cold pull requests must not evict the review being ticked through. - */ -export const NODE_ID_CACHE_CAPACITY = 128; - -/** - * Pages of review threads to follow before the conversation is reported as truncated. GitHub - * serves a hundred threads a page, so this is a thousand threads — past anything a pull request - * a person is reading has, and short of walking a repository-sized conversation forever. - */ -const REVIEW_THREAD_PAGES = 10; - -/** Pages of a hundred check contexts to walk for one head before the rollup says it was cut. */ -const CHECK_CONTEXT_PAGES = 10; - -export interface GitHubPullRequestListBatch { - readonly items: ReadonlyArray; - readonly truncated: boolean; - /** False for a page GitHub would not search, which came back in `gh`'s own order instead. */ - readonly continues: boolean; -} - -export interface GitHubPullRequestStat { - readonly repository: string; - readonly number: number; - readonly additions: number; - readonly deletions: number; -} - -/** - * Aliased lookups per request, and requests at once. Measured over a hundred rows: one request - * carrying all hundred takes ~5.2s, four of twenty-five in parallel ~2.1s. - */ -const STAT_ALIASES_PER_REQUEST = 25; -const STAT_REQUEST_CONCURRENCY = 4; -/** - * How long a summary read waits for company. The background sync asks for every linked pull - * request at once, and each read reaches the resolver after its own cache check, so a batch - * needs a moment longer than one scheduler tick to gather them. - */ -const SUMMARY_BATCH_WINDOW = "10 millis"; - -class PullRequestSummaryRead extends Request.Class< - { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - }, - ProviderChangeRequestSummary, - GitHubPullRequestCliError -> {} - -class PullRequestWatchFingerprintRead extends Request.Class< - { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - }, - GitHubPullRequestWatchFingerprint | null, - GitHubPullRequestCliError -> {} - -export interface GitHubPullRequestSearchBatch { - /** Rows across every repository asked for, newest update first, each naming its own. */ - readonly items: ReadonlyArray; - readonly truncated: boolean; -} - -export interface GitHubPullRequestDiffSlice { - readonly patch: string; - /** Files in this slice had their hunks withheld, as opposed to there being more slices. */ - readonly truncated: boolean; - /** Where the next slice starts, or null once the patch is whole. */ - readonly nextCursor: string | null; - /** GitHub's own counts for the files whose hunks it withheld from this slice. */ - readonly omittedFileStats?: ReadonlyArray; -} - -export interface GitHubPullRequestFilesViewed { - readonly files: ReadonlyArray; - /** GitHub had more files than the page budget below would read. */ - readonly truncated: boolean; -} - -export class GitHubPullRequestCli extends Context.Service< - GitHubPullRequestCli, - { - readonly withVerifiedCredential: ( - input: { readonly cwd: string; readonly host: string }, - use: (identity: { - readonly accountId: string; - readonly viewer: string; - readonly credentialFingerprint: string; - }) => Effect.Effect, - ) => Effect.Effect; - readonly getRoutingIdentity: (input: { - readonly cwd: string; - readonly host: string; - }) => Effect.Effect< - { readonly accountId: string; readonly viewer: string }, - GitHubPullRequestCliError - >; - readonly getViewerLogin: (input: { - readonly cwd: string; - readonly host: string; - }) => Effect.Effect; - - readonly listPullRequests: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly state: PullRequestListState; - readonly involvement: PullRequestInvolvement; - readonly viewer: string; - readonly limit: number; - /** Free text for `--search`, matched as one literal phrase. */ - readonly query?: string | undefined; - /** Where to carry on from, as a `updated:` qualifier on the same search. */ - readonly cursor?: ProviderListCursor | undefined; - /** Further narrowings, as qualifiers on the search and as a local pass on the fallback. */ - readonly filters?: PullRequestListFilters | undefined; - }) => Effect.Effect; - - /** - * The same listing for a whole host in one search. `limit` is the size of the slice across - * all of the repositories rather than per repository, because that is what a search answers: - * the newest rows of the lot, which is exactly the page. - */ - readonly searchPullRequests: (input: { - /** Any checkout on the host; the search names its repositories itself. */ - readonly cwd: string; - readonly host: string; - readonly repositories: ReadonlyArray; - readonly state: PullRequestListState; - readonly involvement: PullRequestInvolvement; - readonly viewer: string; - readonly limit: number; - readonly query?: string | undefined; - readonly cursor?: ProviderListCursor | undefined; - readonly filters?: PullRequestListFilters | undefined; - }) => Effect.Effect; - - /** The line counts the search leaves out, for rows already on the page. */ - readonly listPullRequestStats: (input: { - readonly cwd: string; - readonly host: string; - readonly changeRequests: ReadonlyArray<{ - readonly repository: string; - readonly number: number; - }>; - }) => Effect.Effect, GitHubPullRequestCliError>; - - readonly getPullRequestSummary: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - }) => Effect.Effect; - /** - * What a watch compares between passes, batched like summaries. Null when GitHub gave no - * answer for this pull request, so the watch reads it in full instead. - */ - readonly getPullRequestWatchFingerprint: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - }) => Effect.Effect; - - readonly revalidateChecks: Effect.Success; - - readonly getPullRequestDetail: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - }) => Effect.Effect; - - readonly getPullRequestPreview: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - }) => Effect.Effect< - Omit, - GitHubPullRequestCliError - >; - - readonly listWorkflowRunsRequiringApproval: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - readonly headSha: string; - readonly headBranch: string; - readonly headRepositoryOwner: string; - readonly isCrossRepository: true; - }) => Effect.Effect, GitHubPullRequestCliError>; - - /** - * The host-native stack this pull request is in, or null when it is in none — which is also - * the answer for a host that refuses the stacks preview altogether. - */ - readonly getPullRequestStack: (input: { - readonly cwd: string; - readonly includeDetails?: boolean; - readonly repository: string; - readonly host: string; - readonly number: number; - }) => Effect.Effect; - - readonly getPullRequestActivity: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - }) => Effect.Effect; - - readonly getPullRequestDiff: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - /** Absent asks for the first slice; anything else is a cursor a slice handed back. */ - readonly cursor?: string | undefined; - /** One commit's own changes, rather than everything the pull request carries. */ - readonly commit?: string | undefined; - }) => Effect.Effect; - - readonly getPullRequestDiffFileContents: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - readonly commit?: string | undefined; - readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; - readonly oldPath: string; - readonly newPath: string; - }) => Effect.Effect< - { readonly oldContents: string; readonly newContents: string }, - GitHubPullRequestCliError - >; - - /** - * Which files of the pull request the signed-in account has cleared, and which of those have - * been pushed to since. Read apart from the patch because GitHub only reports it over GraphQL. - */ - readonly getPullRequestFilesViewed: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - }) => Effect.Effect; - - /** - * Clears files, or puts them back, as one request. GitHub takes a single path per mutation, - * so a burst is batched with aliases into one document rather than one subprocess per press. - */ - readonly setPullRequestFilesViewed: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>; - }) => Effect.Effect; - - readonly listReviewThreadComments: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - }) => Effect.Effect; - - /** One request for a listing's authors, since no `gh` JSON field reports an avatar. */ - readonly listActorAvatars: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly ids: ReadonlyArray; - }) => Effect.Effect, GitHubPullRequestCliError>; - - readonly getReviewThreadComments: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - readonly threadId: string; - readonly cursor: string; - }) => Effect.Effect; - - /** The viewer's standing on its own, for deciding a write without reading the whole detail. */ - readonly getViewerAccess: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - /** Manual action checks may use the quota held back from automatic reads. */ - readonly allowReserve?: boolean | undefined; - }) => Effect.Effect; - - /** Who this pull request may be sent to, and who it has already been sent to. */ - readonly listReviewerCandidates: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - }) => Effect.Effect; - - readonly setReviewerRequest: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - readonly reviewers: ReadonlyArray<{ - readonly id: string; - readonly kind: PullRequestReviewerKind; - }>; - /** False deletes the same collection a request posts to, which takes the request back. */ - readonly requested: boolean; - }) => Effect.Effect; - - /** The repository's labels, and which of them this pull request already wears. */ - readonly listLabelCandidates: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - }) => Effect.Effect; - - readonly setLabels: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - readonly labels: ReadonlyArray; - /** False takes each label off; true adds each to whatever is already there. */ - readonly applied: boolean; - }) => Effect.Effect; - - readonly runPullRequestAction: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - readonly action: PullRequestAction; - readonly stackNumber?: number; - readonly expectedStackHeads?: ReadonlyArray; - readonly removeAgentCreditsOnMerge?: boolean; - readonly mergeMethod?: PullRequestMergeMethod; - readonly updateMethod?: PullRequestUpdateMethod; - }) => Effect.Effect; - - readonly commentOnPullRequest: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - readonly body: string; - }) => Effect.Effect; - - readonly submitReview: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - readonly verdict: PullRequestReviewVerdict; - readonly body: string; - readonly comments: ReadonlyArray; - }) => Effect.Effect; - - readonly replyToReviewThread: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly threadId: string; - readonly body: string; - }) => Effect.Effect; - - readonly setReviewThreadResolution: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly threadId: string; - readonly resolved: boolean; - }) => Effect.Effect; - - /** - * Adds a reaction to a remark, or takes it back. `subjectId` is any node GitHub calls - * reactable — a comment, a review, or the pull request itself, which is looked up here - * because nothing in the conversation names it. A given `subjectId` is confirmed to belong - * to this pull request before the mutation runs, since nothing else ties the two together. - */ - readonly setReaction: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - readonly subjectId?: string | undefined; - readonly content: PullRequestReactionContent; - readonly reacted: boolean; - }) => Effect.Effect; - - /** Rewrites the pull request's own words, leaving whichever of the two was not given. */ - readonly updatePullRequest: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - readonly title?: string | undefined; - readonly body?: string | undefined; - }) => Effect.Effect; - - /** - * Rewrites a remark. `commentId` is trusted to be whatever node it names, so it is confirmed - * to belong to this pull request before the mutation runs, the way a reaction subject is. - * Whether the remark is the reader's to rewrite is GitHub's own answer, not one asked here. - */ - readonly updateComment: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - readonly commentId: string; - readonly kind: "issue-comment" | "review-comment"; - readonly body: string; - }) => Effect.Effect; - } ->()("t3/pullRequest/GitHubPullRequestCli") {} - -/** - * The GraphQL API takes owner and name as separate arguments, so `owner/repo` is split here. - * The host is not read off the identity: it travels alongside it, because the identity a - * project records is the path below its host and never names the host itself. - */ -function parseRepositorySelector(value: string): { - readonly owner: string; - readonly name: string; -} { - const parts = value.trim().split("/").filter(Boolean); - return { name: parts.at(-1) ?? "", owner: parts.at(-2) ?? "" }; -} - -/** - * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor - * arrives from the reader as a string and goes straight into a request path, so it is parsed - * rather than trusted; the length bound keeps a page number out of exponential notation. - */ -function diffCursorPage(cursor: string): number | null { - return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null; -} - -/** - * A commit sha arrives from the reader and goes straight into a request path, so it is checked - * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a - * whole sha. - */ -function isCommitSha(value: string): boolean { - return /^[0-9a-f]{7,64}$/i.test(value); -} - -/** - * The reader's own words as one literal phrase of a GitHub search query. Quoting is the whole - * defence: outside quotes GitHub reads `is:merged` as a qualifier and `label:x` as another, so - * text typed into a search box could widen the very listing it is meant to narrow — inside them - * it is only text. The two characters that could end the phrase early are therefore escaped - * first, which GitHub reads back as themselves; an unbalanced quote is dropped instead, which - * would let everything after it out of the phrase. - * - * The phrase is one argv element, so nothing in it can become a flag of its own either. - */ -function searchPhrase(query: string): string { - return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; -} - -/** GitHub's own spelling of a review state, which is not the contract's. */ -const REVIEW_QUALIFIERS = { - approved: "approved", - "changes-requested": "changes_requested", - "review-required": "required", - none: "none", -} as const; - -/** - * The extra narrowings as GitHub search qualifiers. Values a reader typed are quoted, and the - * one character that could end the quoted value early is dropped rather than escaped: no GitHub - * label or login holds a double quote, so there is nothing to preserve and everything to lose. - */ -function qualifierValue(value: string): string { - return `"${value.replaceAll('"', "").trim()}"`; -} - -function filterQualifiers( - filters: PullRequestListFilters | undefined, - viewer: string, -): ReadonlyArray { - if (filters === undefined) return []; - return [ - // One qualifier per group, its names joined by commas — GitHub's own OR. - ...(filters.labels ?? []).flatMap((group) => - group.length === 0 ? [] : [`label:${group.map(qualifierValue).join(",")}`], - ), - ...(filters.excludedLabels ?? []).map((label) => `-label:${qualifierValue(label)}`), - ...(filters.author === undefined - ? [] - : [`author:${qualifierValue(resolvePullRequestAuthorFilter(filters.author, viewer))}`]), - ...(filters.draft === undefined ? [] : [`draft:${filters.draft === "only"}`]), - ...(filters.review === undefined ? [] : [`review:${REVIEW_QUALIFIERS[filters.review]}`]), - ...(filters.checks === undefined - ? [] - : [`status:${filters.checks === "passing" ? "success" : "failure"}`]), - ]; -} - -/** - * The same narrowings over a row that has already arrived, for the search-free fallback. Every - * listed row now carries its own `checksState`, so `checks` is judged the way `review` is: by - * equality against the row's field. Unlike `review`, `checks` has no `"none"` value to catch an - * absent state on purpose — a row with no checks configured, or whose checks are still `pending`, - * equals neither `"passing"` nor `"failing"` and so fails both, the same as a row search would - * not have surfaced for `status:success` or `status:failure`. - */ -function matchesFilters( - item: GitHubPullRequestListItem, - filters: PullRequestListFilters | undefined, - viewer: string, -): boolean { - if (filters === undefined) return true; - const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase())); - const holds = (label: string) => labels.has(label.trim().toLowerCase()); - return ( - (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && - (filters.review === undefined || - (filters.review === "none" - ? item.reviewDecision === null - : item.reviewDecision === filters.review)) && - (filters.checks === undefined || item.checksState === filters.checks) && - (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) && - (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) && - (filters.author === undefined || - item.author?.login.toLowerCase() === - resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase()) - ); -} - -/** The search-free fallback is wider than the request, so narrow its decoded rows locally. */ -function matchesUnsortedListing( - item: GitHubPullRequestListItem, - input: { - readonly state: PullRequestListState; - readonly involvement: PullRequestInvolvement; - readonly viewer: string; - readonly filters?: PullRequestListFilters | undefined; - }, -): boolean { - const matchesState = input.state === "all" || item.state === input.state; - const viewer = input.viewer.toLowerCase(); - const matchesInvolvement = - input.involvement === "all" || - (input.involvement === "authored" - ? item.author?.login.toLowerCase() === viewer - : item.hasTeamReviewRequest || - item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer)); - return matchesState && matchesInvolvement && matchesFilters(item, input.filters, input.viewer); -} - -/** What a repository selector may hold before it goes into a search as itself. */ -const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; - -/** - * A listing as one GitHub search, across one repository or several: the only way to read a - * whole host in one request, and the only order (`updated`) a continuation can carry on from. - * - * `--state closed` in GitHub's own list includes merged pull requests, so the Closed tab is - * `is:closed is:unmerged` here. - * - * Null where a repository is not `owner/name`. A name is written into the query as itself, and a - * name holding a space could otherwise end the `repo:` qualifier and start a qualifier of its - * own — so an unaddressable one refuses the whole read rather than being escaped into something - * GitHub might still read. - */ -function searchQuery(input: { - readonly repositories: ReadonlyArray; - readonly state: PullRequestListState; - readonly involvement: PullRequestInvolvement; - readonly viewer: string; - readonly query?: string | undefined; - readonly cursor?: ProviderListCursor | undefined; - readonly filters?: PullRequestListFilters | undefined; -}): string | null { - if (input.repositories.length === 0) return null; - const repositories = input.repositories.map((repository) => repository.trim()); - if (!repositories.every((repository) => SEARCH_REPOSITORY.test(repository))) return null; - const query = input.query?.trim() ?? ""; - return [ - "is:pr", - // "all" is every state, which `is:pr` already is. - ...(input.state === "open" ? ["is:open"] : []), - ...(input.state === "closed" ? ["is:closed", "is:unmerged"] : []), - ...(input.state === "merged" ? ["is:merged"] : []), - ...(input.involvement === "authored" ? [`author:${input.viewer}`] : []), - ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []), - ...(query.length === 0 ? [] : [searchPhrase(query)]), - // Inclusive, and de-duplicated by the caller, for the reason the per-repository read gives. - ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]), - ...filterQualifiers(input.filters, input.viewer), - // The order the page reads its rows in, and the only order a continuation can carry on from. - "sort:updated-desc", - ...repositories.map((repository) => `repo:${repository}`), - ].join(" "); -} - -const MERGE_MESSAGE_GRAPHQL_QUERY = ` -query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - isMergeQueueEnabled - headRefOid - viewerMergeBodyText(mergeType: $method) - } - } -}`; - -/** The two revisions a file is expanded between: a pull request's base and head, or a commit's parent and itself. */ -const decodeRevisionRefs = Schema.decodeUnknownOption( - Schema.fromJsonString( - Schema.Struct({ - sha: Schema.optional(Schema.String), - parents: Schema.optional(Schema.Array(Schema.Struct({ sha: Schema.String }))), - base: Schema.optional(Schema.Struct({ sha: Schema.String })), - head: Schema.optional(Schema.Struct({ sha: Schema.String })), - }), - ), -); - -const decodeMergeMessageResponse = Schema.decodeUnknownResult( - Schema.fromJsonString( - Schema.Struct({ - data: Schema.Struct({ - repository: Schema.Struct({ - pullRequest: Schema.Struct({ - isMergeQueueEnabled: Schema.Boolean, - headRefOid: Schema.String, - viewerMergeBodyText: Schema.String, - }), - }), - }), - }), - ), -); -const decodeMergeMessage = (raw: string) => - Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest); - -/** What `gh pr merge` and `gh pr update-branch` read before they act. */ -const ACTION_STATE_GRAPHQL_QUERY = ` -query PullRequestActionState($owner: String!, $name: String!, $number: Int!, $headRef: String!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - id - headRefOid - isMergeQueueEnabled - mergeStateStatus - baseRef { compare(headRef: $headRef) { behindBy } } - } - } -}`; - -const decodeActionStateResponse = Schema.decodeUnknownResult( - Schema.fromJsonString( - Schema.Struct({ - data: Schema.Struct({ - repository: Schema.Struct({ - pullRequest: Schema.Struct({ - id: Schema.String, - headRefOid: Schema.String, - isMergeQueueEnabled: Schema.optional(Schema.Boolean), - mergeStateStatus: Schema.optional(Schema.NullOr(Schema.String)), - baseRef: Schema.optional( - Schema.NullOr( - Schema.Struct({ compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })) }), - ), - ), - }), - }), - }), - }), - ), -); -const decodeActionState = (raw: string) => - Result.map(decodeActionStateResponse(raw), (response) => response.data.repository.pullRequest); - -const MERGE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($input: MergePullRequestInput!) { - mergePullRequest(input: $input) { clientMutationId } -}`; -const ENABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($input: EnablePullRequestAutoMergeInput!) { - enablePullRequestAutoMerge(input: $input) { clientMutationId } -}`; -const DISABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { - disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { clientMutationId } -}`; -const UPDATE_BRANCH_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!, $updateMethod: PullRequestBranchUpdateMethod!) { - updatePullRequestBranch(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid, updateMethod: $updateMethod }) { clientMutationId } -}`; -const READY_FOR_REVIEW_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { - markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { clientMutationId } -}`; -const CONVERT_TO_DRAFT_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { - convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { clientMutationId } -}`; -const CLOSE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { - closePullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } -}`; -const REOPEN_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { - reopenPullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } -}`; -const ADD_COMMENT_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $body: String!) { - addComment(input: { subjectId: $subjectId, body: $body }) { clientMutationId } -}`; - -const GRAPHQL_MERGE_METHODS = { - merge: "MERGE", - squash: "SQUASH", - rebase: "REBASE", -} as const satisfies Record; - -/** States in which `gh pr merge --auto` merges at once instead of arming auto-merge. */ -const IMMEDIATELY_MERGEABLE = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]); - -/** The mutations that only need the pull request's node id. */ -const SIMPLE_ACTION_MUTATIONS = { - "disable-auto-merge": DISABLE_AUTO_MERGE_GRAPHQL_MUTATION, - ready: READY_FOR_REVIEW_GRAPHQL_MUTATION, - draft: CONVERT_TO_DRAFT_GRAPHQL_MUTATION, - close: CLOSE_PULL_REQUEST_GRAPHQL_MUTATION, - reopen: REOPEN_PULL_REQUEST_GRAPHQL_MUTATION, -} as const satisfies Partial>; - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const api = yield* GitHubApi.GitHubApi; - const vcsProcess = yield* VcsProcess.VcsProcess; - const fileSystem = yield* FileSystem.FileSystem; - const revalidateChecks = yield* makeChecksRevalidator; - const routingIdentities = new Map< - string, - { - at: number; - value: { accountId: string; viewer: string }; - } - >(); - const identityLocks = new Map(); - const decodeRoutingIdentity = Schema.decodeUnknownEffect( - Schema.fromJsonString( - Schema.Struct({ - id: PositiveInt, - login: TrimmedNonEmptyString, - }), - ), - ); - const captureVerifiedCredential = Effect.fn("GitHubPullRequestCli.captureVerifiedCredential")( - function* (input: { readonly cwd: string; readonly host: string }) { - const unavailable = () => - new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd }); - const host = input.host.toLowerCase(); - // A missing or signed-out credential keeps its own error, so the page can say which. - const { token, fingerprint: key } = yield* api.credential(host); - const credential = { host, token, credentialFingerprint: key }; - // A cold page may ask several times. Wait per credential and check again after the - // first verification; cancellation releases the next waiter without losing its request. - return yield* Effect.acquireUseRelease( - Effect.sync(() => { - const lock = identityLocks.get(key) ?? { gate: Semaphore.makeUnsafe(1), users: 0 }; - lock.users++; - identityLocks.set(key, lock); - return lock; - }), - (lock) => - lock.gate.withPermit( - Effect.gen(function* () { - const now = yield* Clock.currentTimeMillis; - const cached = routingIdentities.get(key); - if (cached !== undefined && now - cached.at < 10 * 60_000) - return { ...credential, ...cached.value }; - // Pin this read so an auth switch cannot poison its cache entry. - const response = yield* api - .rest({ host, operation: "getRoutingIdentity", path: "user", allowReserve: true }) - .pipe( - Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), - Effect.provideService(SourceControlRateLimit.CredentialScope, key), - ); - const identity = yield* decodeRoutingIdentity(response.body).pipe( - Effect.mapError(unavailable), - ); - const value = { accountId: String(identity.id), viewer: identity.login }; - if (routingIdentities.size >= 128) - routingIdentities.delete(routingIdentities.keys().next().value!); - routingIdentities.set(key, { at: now, value }); - return { ...credential, ...value }; - }), - ), - (lock) => - Effect.sync(() => { - lock.users--; - if (lock.users === 0) identityLocks.delete(key); - }), - ); - }, - ); - const withVerifiedCredential: GitHubPullRequestCli["Service"]["withVerifiedCredential"] = ( - input, - use, - ) => - captureVerifiedCredential(input).pipe( - Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) => - use({ accountId, viewer, credentialFingerprint }).pipe( - Effect.provideService(SourceControlRateLimit.CredentialScope, credentialFingerprint), - Effect.provideService(GitHubApi.PinnedGitHubCredential, { - host, - token, - credentialFingerprint, - }), - ), - ), - ); - const getRoutingIdentity: GitHubPullRequestCli["Service"]["getRoutingIdentity"] = (input) => - captureVerifiedCredential(input).pipe( - Effect.map(({ accountId, viewer }) => ({ accountId, viewer })), - ); - - /** - * The pull request's own node id, which is what a mutation against the pull request itself is - * addressed by: a reaction on its description, or a rewrite of its words. - * - * A pull request keeps its node id for life, so it is remembered rather than re-read: a reader - * ticking files viewed would otherwise pay a GraphQL round trip per press. - */ - const nodeIds = new Map(); - - const pullRequestNodeId = (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - readonly operation: string; - }): Effect.Effect => { - const { owner, name } = parseRepositorySelector(input.repository); - const key = `${input.host} ${owner}/${name} ${input.number}`; - const held = nodeIds.get(key); - if (held !== undefined) { - // Put back at the end on every hit, so what falls out is the pull request nobody has looked - // at rather than the one being ticked through: a run of cold reads would otherwise evict the - // open review and make it pay a round trip per press. - nodeIds.delete(key); - nodeIds.set(key, held); - return Effect.succeed(held); - } - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: input.operation, - allowReserve: true, - variables: { owner, name, number: input.number }, - query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, - decode: decodePullRequestNodeIdJson, - }).pipe( - Effect.tap((nodeId) => - Effect.sync(() => { - if (nodeIds.size >= NODE_ID_CACHE_CAPACITY) { - const oldest = nodeIds.keys().next().value; - if (oldest !== undefined) nodeIds.delete(oldest); - } - nodeIds.set(key, nodeId); - }), - ), - ); - }; - - /** - * Whether a client-given subject actually belongs to the pull request the request names. A - * subject id is trusted to be whatever node it names, and that node can hang off any pull - * request on the host — so the mutation itself would write wherever the id actually belongs, - * not wherever the request says it does, unless this confirms the two agree first. - */ - const subjectBelongsToPullRequest = (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - readonly subjectId: string; - readonly operation: string; - }) => { - const { owner, name } = parseRepositorySelector(input.repository); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: input.operation, - allowReserve: true, - variables: { owner, name, number: input.number, subjectId: input.subjectId }, - query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, - decode: decodeReactionSubjectScopeJson, - }); - }; - - /** A GraphQL mutation whose answer is not read back; a GraphQL error fails it. */ - const graphql = (input: { - readonly host: string; - readonly operation: string; - readonly query: string; - readonly variables: Readonly>; - }) => api.graphql(input).pipe(Effect.asVoid); - - const readError = (cwd: string, operation: string, cause: unknown) => - new GitHubPullRequestReadError({ command: "gh", cwd, operation, cause }); - - /** Decodes an answer, reporting a failure against the read that made it. */ - const decodeWith = ( - cwd: string, - operation: string, - decode: (raw: string) => Result.Result, - raw: string, - ): Effect.Effect => { - const decoded = decode(raw.trim()); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail(readError(cwd, operation, decoded.failure)); - }; - - /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */ - const graphqlRead = (input: { - readonly cwd: string; - readonly host: string; - readonly operation: string; - readonly allowReserve?: boolean | undefined; - readonly variables?: Readonly>; - readonly query: string; - readonly decode: (raw: string) => Result.Result; - }): Effect.Effect => - api - .graphql({ - host: input.host, - operation: input.operation, - query: input.query, - ...(input.variables === undefined ? {} : { variables: input.variables }), - ...(input.allowReserve === true ? { allowReserve: true } : {}), - }) - .pipe(Effect.flatMap((raw) => decodeWith(input.cwd, input.operation, input.decode, raw))); - - /** A REST read whose JSON answer is decoded the same way. */ - const restRead = (input: { - readonly cwd: string; - readonly host: string; - readonly operation: string; - readonly path: string; - readonly decode: (raw: string) => Result.Result; - }): Effect.Effect => - api - .rest({ host: input.host, operation: input.operation, path: input.path }) - .pipe( - Effect.flatMap((response) => - decodeWith(input.cwd, input.operation, input.decode, response.body), - ), - ); - - /** - * One page of the patch, read from the files API. GitHub refuses a whole diff past 300 changed - * files, and still serves those files' hunks here. - * - * A page is a whole number of files, so each one parses on its own; the caller carries on from - * `nextCursor` for as long as GitHub keeps handing pages back. - * - * A named commit is read from the commit endpoint, which lists the same file entries and pages - * them the same way, only wrapped in an object. - */ - const diffFilesPage = (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - readonly page: number; - readonly commit?: string | undefined; - }): Effect.Effect => { - const { owner, name } = parseRepositorySelector(input.repository); - const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`; - return api - .rest({ - host: input.host, - operation: "getPullRequestDiff", - path: - input.commit === undefined - ? `repos/${owner}/${name}/pulls/${input.number}/files?${paging}` - : `repos/${owner}/${name}/commits/${input.commit}?${paging}`, - maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, - timeout: DIFF_TIMEOUT, - }) - .pipe( - Effect.flatMap((response) => { - // Checked before decoding: a byte-truncated response is a JSON prefix, which would - // fail to parse. Nothing of this page can be shown, and an empty patch would render - // as a change with no files rather than as the failure it is; slices already handed - // over stay with the reader either way. - if (response.truncated) { - return Effect.fail( - readError( - input.cwd, - "getPullRequestDiff", - new Error(`Page ${input.page} of the changed files was too large to read.`), - ), - ); - } - return decodeWith( - input.cwd, - "getPullRequestDiff", - input.commit === undefined ? decodePullRequestFilesJson : decodeCommitFilesJson, - response.body, - ); - }), - Effect.map((files) => { - // Counted before decoding, so a page whose files all failed to decode still moves on - // rather than pointing the reader back at the page it just read. - const morePages = files.rawCount >= DIFF_FILES_PAGE_SIZE; - return { - patch: files.patch, - truncated: files.truncated, - nextCursor: morePages ? String(input.page + 1) : null, - ...(files.omittedFileStats.length === 0 - ? {} - : { omittedFileStats: files.omittedFileStats }), - }; - }), - ); - }; - - const getPullRequestDiffFileContents: GitHubPullRequestCli["Service"]["getPullRequestDiffFileContents"] = - (input) => - Effect.gen(function* () { - if (input.commit !== undefined && !isCommitSha(input.commit)) { - return yield* new GitHubDiffCommitError({ command: "gh", cwd: input.cwd }); - } - const { owner, name } = parseRepositorySelector(input.repository); - const refsResponse = yield* api.rest({ - host: input.host, - operation: "getPullRequestDiffFileContents", - path: - input.commit === undefined - ? `repos/${owner}/${name}/pulls/${input.number}` - : `repos/${owner}/${name}/commits/${input.commit}`, - timeout: DIFF_TIMEOUT, - }); - const refs = decodeRevisionRefs(refsResponse.body); - // A root commit has no parent, which is an absent old revision. Every file in it is new, - // so that is a usable answer whenever the caller does not need the old side. - const baseRef = Option.isSome(refs) - ? input.commit === undefined - ? refs.value.base?.sha - : (refs.value.parents?.[0]?.sha ?? "") - : undefined; - const headRef = Option.isSome(refs) - ? input.commit === undefined - ? refs.value.head?.sha - : refs.value.sha - : undefined; - const rootCommitNewFile = - input.commit !== undefined && input.changeType === "new" && baseRef === ""; - if ( - headRef === undefined || - (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) || - !isCommitSha(headRef) - ) { - return yield* new GitHubDiffRevisionsUnavailableError({ - command: "gh", - cwd: input.cwd, - number: input.number, - ...(input.commit === undefined ? {} : { commit: input.commit }), - }); - } - - const readFile = (revision: string, filePath: string) => - api - .rest({ - host: input.host, - operation: "getPullRequestDiffFileContents", - accept: "application/vnd.github.raw+json", - path: `repos/${owner}/${name}/contents/${filePath - .split("/") - .map(encodeURIComponent) - .join("/")}?ref=${encodeURIComponent(revision)}`, - maxResponseBytes: DIFF_FILE_MAX_OUTPUT_BYTES, - timeout: DIFF_TIMEOUT, - }) - .pipe( - Effect.flatMap((response) => - response.truncated || response.body.includes("\0") || response.invalidUtf8 - ? Effect.fail( - new GitHubDiffFileContentsUnavailableError({ - command: "gh", - cwd: input.cwd, - path: filePath, - reason: response.truncated ? "oversized" : "binary", - }), - ) - : Effect.succeed(response.body), - ), - ); - - const [oldContents, newContents] = yield* Effect.all( - [ - input.changeType === "new" - ? Effect.succeed("") - : readFile(baseRef ?? "", input.oldPath), - input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath), - ], - { concurrency: 2 }, - ); - return { oldContents, newContents }; - }); - - /** - * Every check context of the head commit, a page at a time, for a rollup the detail read cut at - * its first hundred. Each page names the head it read, so a push mid-walk fails rather than - * mixing two revisions' checks. - */ - const readAllCheckContexts = ( - input: Parameters[0], - allowReserve: boolean, - ) => - Effect.gen(function* () { - const { owner, name } = parseRepositorySelector(input.repository); - const contexts: GitHubCheckContext[] = []; - let headSha: string | null = null; - let after: string | null = null; - for (let page = 0; page < CHECK_CONTEXT_PAGES; page++) { - const read: { - readonly headSha: string; - readonly contexts: ReadonlyArray; - readonly nextCursor: string | null; - } = yield* graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestDetail", - allowReserve, - variables: { owner, name, number: input.number, after }, - query: pullRequestCheckContextsGraphQlQuery(input.host), - decode: decodePullRequestCheckContextsJson, - }); - if (headSha !== null && read.headSha !== headSha) { - return yield* readError( - input.cwd, - "getPullRequestDetail", - new Error("Pull request head changed while reading checks."), - ); - } - headSha = read.headSha; - contexts.push(...read.contexts); - after = read.nextCursor; - if (after === null) break; - } - return { headSha, contexts, truncated: after !== null }; - }); - - const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - return AllowGitHubReserve.pipe( - Effect.flatMap((allowReserve) => - graphqlRead({ - allowReserve, - cwd: input.cwd, - host: input.host, - operation: "getPullRequestDetail", - variables: { - owner, - name, - number: input.number, - headRef: `refs/pull/${input.number}/head`, - }, - query: pullRequestCoreGraphQlQuery(input.host), - decode: decodePullRequestCoreJson, - }).pipe( - // Past a hundred checks the first page would let the rest imply success, so the whole - // rollup is walked instead. - Effect.filterOrElse( - (core) => !core.checksTruncated, - (core) => - readAllCheckContexts(input, allowReserve).pipe( - Effect.filterOrFail( - (all) => all.headSha === core.headSha, - () => - readError( - input.cwd, - "getPullRequestDetail", - new Error("Pull request head changed while reading checks."), - ), - ), - Effect.map((all) => ({ - ...core, - ...pullRequestChecksFromContexts(all.contexts), - checksTruncated: all.truncated, - })), - ), - ), - ), - ), - ); - }; - - const workflowApprovalLimit = 1_000; - const workflowApprovalReadError = (cwd: string, cause: unknown) => - readError(cwd, "listWorkflowRunsRequiringApproval", cause); - - /** Every open pull request whose head branch carries this name, up to one past the limit. */ - const listHeadsByBranch = (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly headBranch: string; - }) => - Effect.gen(function* () { - const { owner, name } = parseRepositorySelector(input.repository); - const heads: GitHubPullRequestHead[] = []; - let after: string | null = null; - do { - const page: { - readonly heads: ReadonlyArray; - readonly nextCursor: string | null; - } = yield* graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listWorkflowRunsRequiringApproval", - allowReserve: true, - variables: { owner, name, head: input.headBranch, after }, - query: PULL_REQUEST_HEADS_GRAPHQL_QUERY, - decode: decodePullRequestHeadsJson, - }); - heads.push(...page.heads); - after = page.nextCursor; - } while (after !== null && heads.length <= workflowApprovalLimit); - return heads; - }); - - /** - * The runs waiting on a maintainer for this exact head, up to one past the limit. `head_sha` is - * what scopes them; `head_branch` is checked as well, the way `gh run list --branch` did. - */ - const listActionRequiredRuns = (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly headSha: string; - readonly headBranch: string; - }) => - Effect.gen(function* () { - // The checks revalidator may have just confirmed every run of this head as current. - const known = yield* KnownWorkflowRuns; - if (known !== null && known.headSha === input.headSha) { - return known.runs.flatMap((run) => - // A run waiting on a maintainer reports `completed` with an `action_required` - // conclusion; the `status=action_required` query matches either. - (run.conclusion === "action_required" || run.status === "action_required") && - run.head_branch === input.headBranch - ? [ - { - id: run.id, - name: run.name?.trim() || `Workflow run ${run.id}`, - url: run.html_url?.trim() || null, - }, - ] - : [], - ); - } - const { owner, name } = parseRepositorySelector(input.repository); - const runs: GitHubWorkflowRunApproval[] = []; - for (let page = 1; runs.length <= workflowApprovalLimit; page++) { - const read: GitHubWorkflowRunPage = yield* restRead({ - cwd: input.cwd, - host: input.host, - operation: "listWorkflowRunsRequiringApproval", - 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}`, - decode: decodeWorkflowRunsJson, - }).pipe(Effect.mapError((error) => workflowApprovalReadError(input.cwd, error))); - runs.push(...read.runs); - if (read.rawCount < 100) break; - } - return runs; - }); - - const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] = - (input) => - Effect.all( - [ - listHeadsByBranch(input).pipe( - Effect.flatMap( - ( - heads, - ): Effect.Effect< - GitHubPullRequestHead, - GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError - > => { - const exactHeads = heads.filter( - (pullRequest) => - pullRequest.headSha === input.headSha && - pullRequest.isCrossRepository === true && - pullRequest.headRepositoryOwner?.toLowerCase() === - input.headRepositoryOwner.toLowerCase(), - ); - if (heads.length > workflowApprovalLimit) { - return Effect.fail( - new GitHubWorkflowApprovalRefusedError({ - command: "gh", - cwd: input.cwd, - number: input.number, - reason: "head-list-truncated", - observedCount: heads.length, - limit: workflowApprovalLimit, - }), - ); - } - if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { - return Effect.fail( - new GitHubWorkflowApprovalRefusedError({ - command: "gh", - cwd: input.cwd, - number: input.number, - reason: "head-not-unique", - observedCount: exactHeads.length, - limit: workflowApprovalLimit, - }), - ); - } - return Effect.succeed(exactHeads[0]); - }, - ), - ), - listActionRequiredRuns(input).pipe( - Effect.flatMap((runs) => - runs.length > workflowApprovalLimit - ? Effect.fail( - new GitHubWorkflowApprovalRefusedError({ - command: "gh", - cwd: input.cwd, - number: input.number, - reason: "run-list-truncated", - observedCount: runs.length, - limit: workflowApprovalLimit, - }), - ) - : Effect.succeed(runs), - ), - ), - ], - { concurrency: 2 }, - ).pipe(Effect.map(([, runs]) => runs)); - - /** One pull request's summary, through the same aliased query the batch uses. */ - const viewPullRequestSummary = (input: PullRequestSummaryRead) => { - const { owner, name } = parseRepositorySelector(input.repository); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestSummary", - variables: { owner, name, number: input.number }, - query: pullRequestSummaryGraphQlQuery(input.host === "github.com"), - decode: decodePullRequestSummariesJson, - }).pipe( - Effect.flatMap((summaries) => { - const summary = summaries.get(0); - return summary === undefined - ? Effect.fail( - readError( - input.cwd, - "getPullRequestSummary", - new Error(`GitHub answered nothing for ${input.repository}#${input.number}.`), - ), - ) - : Effect.succeed(summary); - }), - ); - }; - - /** - * Summaries asked for together, on one host under one credential, share aliased GraphQL reads - * of twenty-five: the background sync reads every linked pull request each minute, and one - * `gh pr view` apiece is most of what it spends. Whatever the batch cannot answer — a selector - * GraphQL cannot address, a pull request GitHub returned nothing for — is read on its own. - */ - const summaryResolver = RequestResolver.makeGrouped({ - key: ({ request, context }) => - JSON.stringify([ - request.host.toLowerCase(), - Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) - ?.credentialFingerprint ?? null, - Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), - ]), - resolver: (entries) => { - const [first] = entries; - const batchable = entries.filter( - (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null, - ); - // Stack membership rides along where GitHub serves stacks, so the background sync can skip - // the REST stack read for pull requests that are in none. - const query = buildPullRequestSummariesGraphQlQuery( - batchable.map((entry) => entry.request), - first.request.host === "github.com", - ); - const batched = - query === null - ? Effect.succeed(new Map()) - : graphqlRead({ - cwd: first.request.cwd, - host: first.request.host, - operation: "getPullRequestSummary", - query, - decode: decodePullRequestSummariesJson, - }); - return batched.pipe( - // A GraphQL error anywhere fails the whole document — one repository gone or out of - // reach — so a batch that could not be read leaves every entry to its own read. A paused - // budget is the exception: reading one at a time would only spend what is being saved. - Effect.catchCauseIf( - (cause) => - !Cause.hasInterruptsOnly(cause) && - !Cause.findErrorOption(cause).pipe( - Option.exists((error) => error._tag === "SourceControlRateLimitPausedError"), - ), - (cause) => - Effect.logDebug("batched pull request summary read failed", { cause }).pipe( - Effect.as(new Map()), - ), - ), - Effect.flatMap((summaries) => { - const unanswered = entries.filter((entry) => { - const summary = summaries.get(batchable.indexOf(entry)); - if (summary === undefined) return true; - entry.completeUnsafe(Exit.succeed(summary)); - return false; - }); - return Effect.forEach( - unanswered, - (entry) => - viewPullRequestSummary(entry.request).pipe( - Effect.exit, - Effect.map((exit) => entry.completeUnsafe(exit)), - ), - { concurrency: STAT_REQUEST_CONCURRENCY, discard: true }, - ); - }), - Effect.catchCause((cause) => - Effect.sync(() => { - for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); - }), - ), - ); - }, - }).pipe( - RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), - RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), - ); - const getPullRequestSummary: GitHubPullRequestCli["Service"]["getPullRequestSummary"] = (input) => - Effect.request(new PullRequestSummaryRead(input), summaryResolver); - - // Every watched pull request on a host in one read per pass. A pull request the batch has no - // answer for gets null, and its watch reads it in full; a failed batch fails every entry. - const watchFingerprintResolver = RequestResolver.makeGrouped< - PullRequestWatchFingerprintRead, - string - >({ - key: ({ request, context }) => - JSON.stringify([ - request.host.toLowerCase(), - Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) - ?.credentialFingerprint ?? null, - Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), - ]), - resolver: (entries) => { - const [first] = entries; - const batchable = entries.filter( - (entry) => buildPullRequestWatchFingerprintsGraphQlQuery([entry.request]) !== null, - ); - const query = buildPullRequestWatchFingerprintsGraphQlQuery( - batchable.map((entry) => entry.request), - ); - const read = - query === null - ? Effect.succeed(new Map()) - : graphqlRead({ - cwd: first.request.cwd, - host: first.request.host, - operation: "getPullRequestWatchFingerprint", - query, - decode: decodePullRequestWatchFingerprintsJson, - }); - return read.pipe( - Effect.map((fingerprints) => { - for (const entry of entries) { - const index = batchable.indexOf(entry); - entry.completeUnsafe( - Exit.succeed(index === -1 ? null : (fingerprints.get(index) ?? null)), - ); - } - }), - Effect.catchCause((cause) => - Effect.sync(() => { - for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); - }), - ), - ); - }, - }).pipe( - RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), - RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), - ); - const getPullRequestWatchFingerprint: GitHubPullRequestCli["Service"]["getPullRequestWatchFingerprint"] = - (input) => Effect.request(new PullRequestWatchFingerprintRead(input), watchFingerprintResolver); - - return GitHubPullRequestCli.of({ - withVerifiedCredential, - revalidateChecks, - getRoutingIdentity, - getViewerLogin: (input) => - getRoutingIdentity(input).pipe(Effect.map((identity) => identity.viewer)), - - listPullRequests: (input) => { - const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS); - const { owner, name } = parseRepositorySelector(input.repository); - const query = searchQuery({ ...input, repositories: [input.repository] }); - if (query === null) { - return Effect.fail( - new GitHubRepositorySelectorError({ - command: "gh", - cwd: input.cwd, - operation: "listPullRequests", - }), - ); - } - /** Pages of up to a hundred until `rows` have arrived or GitHub has no more. */ - const collect = ( - rows: number, - page: ( - after: string | null, - rows: number, - ) => Effect.Effect, - ) => - Effect.gen(function* () { - const items: GitHubPullRequestListItem[] = []; - let rawCount = 0; - let after: string | null = null; - do { - const read: GitHubPullRequestListPage = yield* page(after, rows - rawCount); - items.push(...read.items); - rawCount += read.rawCount; - after = read.endCursor; - } while (after !== null && rawCount < rows); - return { items, rawCount }; - }); - const read = ( - continues: boolean, - requestedRows = input.limit + 1, - ): Effect.Effect => - collect(requestedRows, (after, rows) => - continues - ? graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listPullRequests", - // The reader's own words are in the query. - variables: { q: query, after }, - query: pullRequestSearchGraphQlQuery(rows, false, true), - decode: decodePullRequestSearchJson, - }) - : graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listPullRequests", - variables: { owner, name, states: LIST_STATES[input.state], after }, - query: pullRequestListGraphQlQuery(rows), - decode: decodePullRequestListJson, - }), - ).pipe( - Effect.flatMap(({ items: rawItems, rawCount }) => { - const items = continues - ? rawItems - : rawItems.filter((item) => matchesUnsortedListing(item, input)); - if ( - !continues && - items.length < input.limit && - rawCount >= requestedRows && - requestedRows < fallbackMaxRows - ) { - const nextRows = Math.min(requestedRows * 2, fallbackMaxRows); - if (nextRows > requestedRows) return read(false, nextRows); - } - return Effect.succeed({ - items: items.slice(0, input.limit), - // One row over the page size is the probe for a next page, and it is - // counted before decoding: a skipped malformed row must not end paging. - truncated: continues - ? rawCount > input.limit - : items.length > input.limit || rawCount >= requestedRows, - continues, - }); - }), - ); - // GitHub does not index every repository for search, and one it will not search answers - // with no rows rather than with an error — so an empty listing is read again from the - // repository's own list. Those rows come back newest-created first, an order no `updated:` - // qualifier can carry on from, so that page says it cannot be continued and the reader - // reaches the rest of it by asking for a larger page, as every listing used to. - // - // Only ever the first slice: a repository that answered the search once will answer it - // again, so an empty slice under a cursor is a repository that has run out. - // A text search that finds nothing has found nothing: falling back would answer it with the - // repository's whole list, which is every row the reader did not search for. The fallback - // is for a repository the index does not cover, and a listing with no text to match is the - // only place an empty answer can mean that. - // Every filter is a qualifier `matchesFilters` can judge over the fallback's own rows just - // as well as search judges them over its own, so carrying them into the fallback answers - // the same read rather than a wider one. Free text is the one thing the fallback cannot - // judge locally — it lists rows, it does not search their text — so a query still rules - // the fallback out: an empty answer under one is already the answer. - const hasQuery = (input.query?.trim().length ?? 0) > 0; - return read(true).pipe( - Effect.filterOrElse( - (batch) => batch.items.length > 0 || input.cursor !== undefined || hasQuery, - () => read(false), - ), - Effect.flatMap((batch) => { - // Match the search query's host support, and enrich only rows that survived paging. - if (input.host !== "github.com" || batch.items.length === 0) return Effect.succeed(batch); - const chunks: Array> = []; - for (let start = 0; start < batch.items.length; start += STAT_ALIASES_PER_REQUEST) { - chunks.push(batch.items.slice(start, start + STAT_ALIASES_PER_REQUEST)); - } - return Effect.forEach( - chunks, - (chunk) => { - const query = buildPullRequestStackMembershipsGraphQlQuery( - input.repository, - chunk.map((item) => item.number), - ); - if (query === null) return Effect.succeed(chunk); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listPullRequestStackMemberships", - query, - decode: decodePullRequestStackMembershipsJson, - }).pipe( - Effect.map((memberships) => - chunk.map((item, index) => { - const stack = memberships.get(index); - return stack === undefined ? item : { ...item, stack }; - }), - ), - // Optional badges must not take down a listing that already read successfully. - Effect.catch(() => - Effect.logWarning("Pull request stack membership enrichment failed", { - operation: "listPullRequestStackMemberships", - host: input.host, - rows: chunk.length, - }).pipe(Effect.as(chunk)), - ), - ); - }, - { concurrency: STAT_REQUEST_CONCURRENCY }, - ).pipe(Effect.map((chunks) => ({ ...batch, items: chunks.flat() }))); - }), - ); - }, - - searchPullRequests: (input) => { - const query = searchQuery(input); - if (query === null) { - return Effect.fail( - new GitHubRepositorySelectorError({ - command: "gh", - cwd: input.cwd, - operation: "searchPullRequests", - }), - ); - } - // One extra row reveals that the host has more than the slice shows, the way the - // per-repository read does — up to GitHub's own ceiling on a search page, past which - // `hasNextPage` is what says there is more. - const rows = Math.min(input.limit + 1, PULL_REQUEST_SEARCH_MAX_ROWS); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "searchPullRequests", - variables: { q: query, after: null }, - query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"), - decode: decodePullRequestSearchJson, - }).pipe( - Effect.map((batch) => ({ - items: batch.items.slice(0, input.limit), - truncated: batch.rawCount > input.limit || batch.hasNextPage, - })), - ); - }, - - listPullRequestStats: (input) => { - const chunks: Array> = - []; - for (let start = 0; start < input.changeRequests.length; start += STAT_ALIASES_PER_REQUEST) { - chunks.push(input.changeRequests.slice(start, start + STAT_ALIASES_PER_REQUEST)); - } - return Effect.forEach( - chunks, - (chunk) => { - const query = buildPullRequestStatsGraphQlQuery(chunk); - if (query === null) { - return Effect.fail( - new GitHubRepositorySelectorError({ - command: "gh", - cwd: input.cwd, - operation: "listPullRequestStats", - }), - ); - } - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listPullRequestStats", - query, - decode: decodePullRequestStatsJson, - }).pipe( - Effect.map((stats) => - chunk.flatMap((changeRequest, index) => { - const stat = stats.get(index); - return stat === undefined ? [] : [{ ...changeRequest, ...stat }]; - }), - ), - ); - }, - { concurrency: STAT_REQUEST_CONCURRENCY }, - ).pipe(Effect.map((results) => results.flat())); - }, - - getPullRequestSummary, - getPullRequestWatchFingerprint, - - getPullRequestDetail, - getPullRequestPreview: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestPreview", - variables: { owner, name, number: input.number }, - query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, - decode: decodePullRequestPreviewJson, - }); - }, - listWorkflowRunsRequiringApproval, - - getPullRequestStack: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - return restRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestStack", - path: `repos/${owner}/${name}/stacks?pull_request=${input.number}`, - decode: decodePullRequestStacksJson, - }).pipe( - // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine. - Effect.flatMap((stack) => { - if (!input.includeDetails || stack === null) return Effect.succeed(stack); - return restRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestStack", - path: `repos/${owner}/${name}/stacks/${stack.number}`, - decode: (raw) => decodePullRequestStacksJson(`[${raw}]`), - }); - }), - // Hosts without the stacks preview return 404. Other failures must preserve the - // previously synced stack and let the caller retry. - Effect.catchTags({ - GitHubApiNotFoundError: () => Effect.succeed(null), - }), - ); - }, - - getPullRequestActivity: (input) => - Effect.gen(function* () { - const { owner, name } = parseRepositorySelector(input.repository); - let author: PullRequestActor | null = null; - let commits: ReadonlyArray = []; - const remarks: PullRequestComment[] = []; - let commentsAfter: string | null = null; - let reviewsAfter: string | null = null; - let withComments = true; - let withReviews = true; - // Both remark lists page on their own; a page asks only for the ones with more to give. - for (let page = 0; page < REVIEW_THREAD_PAGES && (withComments || withReviews); page++) { - const read: GitHubPullRequestActivityPage = yield* graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestActivity", - variables: { - owner, - name, - number: input.number, - head: page === 0, - withComments, - commentsAfter, - withReviews, - reviewsAfter, - }, - query: PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, - decode: decodePullRequestActivityJson, - }); - if (page === 0) { - author = read.author ?? null; - commits = read.commits ?? []; - } - remarks.push(...read.remarks); - commentsAfter = read.nextCommentsCursor; - reviewsAfter = read.nextReviewsCursor; - withComments = withComments && commentsAfter !== null; - withReviews = withReviews && reviewsAfter !== null; - } - return { - author, - comments: remarks.toSorted((left, right) => - left.createdAt.localeCompare(right.createdAt), - ), - commits, - } satisfies GitHubPullRequestActivity; - }), - - getPullRequestDiff: (input) => { - const filesPage = (page: number) => - diffFilesPage({ - cwd: input.cwd, - repository: input.repository, - host: input.host, - number: input.number, - page, - ...(input.commit === undefined ? {} : { commit: input.commit }), - }); - if (input.commit !== undefined && !isCommitSha(input.commit)) { - return Effect.fail(new GitHubDiffCommitError({ command: "gh", cwd: input.cwd })); - } - // A cursor only ever comes from the files walk, so a reader carrying one is already past - // the point where `gh pr diff` had anything to say. - if (input.cursor !== undefined) { - const page = diffCursorPage(input.cursor); - return page === null - ? Effect.fail(new GitHubDiffCursorError({ command: "gh", cwd: input.cwd })) - : filesPage(page); - } - // `gh pr diff` speaks for the whole pull request and has no way to name one commit of it. - if (input.commit !== undefined) { - return filesPage(1); - } - const { owner, name } = parseRepositorySelector(input.repository); - return api - .rest({ - host: input.host, - operation: "getPullRequestDiff", - path: `repos/${owner}/${name}/pulls/${input.number}`, - accept: "application/vnd.github.diff", - maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, - timeout: DIFF_TIMEOUT, - }) - .pipe( - Effect.flatMap((response) => - // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor - // something the reader can carry on from. The files API can serve the same change a - // whole number of files at a time, so an oversized patch takes that road as well. - response.truncated - ? filesPage(1) - : // One read served the whole patch, so there is no next slice to ask for. - Effect.succeed({ patch: response.body, truncated: false, nextCursor: null }), - ), - // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read - // from the files API instead, a page per call. Only once the direct read has failed: a - // pull request GitHub will serve a diff for must not pay for a second request. A - // fallback that fails too reports the original refusal, which is the one that explains - // the page. Narrowed to a request GitHub answered and refused: a missing credential or - // a rate limit fails the same way for every request. - Effect.catchTags({ - GitHubApiResponseError: (error) => filesPage(1).pipe(Effect.mapError(() => error)), - }), - ); - }, - - getPullRequestDiffFileContents, - - getReviewThreadComments: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getReviewThreadComments", - variables: { - owner, - name, - number: input.number, - threadId: input.threadId, - cursor: input.cursor, - }, - query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, - decode: decodeReviewThreadCommentsJson, - }).pipe( - Effect.flatMap(({ belongsToPullRequest, comments, nextCursor }) => - belongsToPullRequest - ? Effect.succeed({ comments, nextCursor }) - : Effect.fail( - new GitHubSubjectScopeError({ - command: "gh", - cwd: input.cwd, - operation: "getReviewThreadComments", - }), - ), - ), - ); - }, - - listReviewThreadComments: (input) => - Effect.gen(function* () { - const { owner, name } = parseRepositorySelector(input.repository); - const threadPage = ( - cursor: string | null, - ): Effect.Effect => - graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listReviewThreadComments", - variables: { owner, name, number: input.number, cursor }, - query: REVIEW_THREADS_GRAPHQL_QUERY, - decode: decodeReviewThreadsJson, - }); - const entries: GitHubReviewThreadEntry[] = []; - const avatarsByLogin = new Map(); - const botLogins = new Set(); - const commitStats = new Map< - string, - { readonly additions: number; readonly deletions: number } - >(); - let reviewers: ReadonlyArray = []; - let reactions: GitHubReviewThreadPage["reactions"] = []; - const reactionsById = new Map>(); - const editedAtById = new Map(); - let commits: GitHubReviewThreadPage["commits"] = []; - let viewer: GitHubReviewThreadPage["viewer"] = { canUpdate: true, didAuthor: false }; - const dismissalsByReviewId = new Map(); - let dismissalCursor: string | null = null; - let cursor: string | null = null; - let page = 0; - do { - const read: GitHubReviewThreadPage = yield* threadPage(cursor); - entries.push(...read.threads); - for (const login of read.botLogins) botLogins.add(login); - for (const [login, avatarUrl] of read.avatarsByLogin) - avatarsByLogin.set(login, avatarUrl); - // The roster, the commits and the viewer's standing travel with every page, and the - // first one already carries all of them. - if (page === 0) { - reviewers = read.reviewers; - reactions = read.reactions; - for (const [id, entry] of read.reactionsById) reactionsById.set(id, entry); - for (const [id, editedAt] of read.editedAtById) editedAtById.set(id, editedAt); - commits = read.commits; - viewer = read.viewer; - for (const [id, message] of read.dismissalsByReviewId) - dismissalsByReviewId.set(id, message); - dismissalCursor = read.nextDismissalCursor; - for (const [oid, stat] of read.commitStats) commitStats.set(oid, stat); - } - cursor = read.nextCursor; - page += 1; - } while (cursor !== null && page < REVIEW_THREAD_PAGES); - - // Almost never entered: the embedded page already holds every dismissal a pull request - // ordinarily accrues. Followed so a review whose event fell past that page still finds - // its reason. - let dismissalPage = 0; - while (dismissalCursor !== null && dismissalPage < REVIEW_THREAD_PAGES) { - const read: { - readonly dismissalsByReviewId: ReadonlyMap; - readonly nextCursor: string | null; - } = yield* graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listReviewThreadComments", - variables: { owner, name, number: input.number, cursor: dismissalCursor }, - query: REVIEW_DISMISSALS_GRAPHQL_QUERY, - decode: decodeReviewDismissalsJson, - }); - for (const [id, message] of read.dismissalsByReviewId) - dismissalsByReviewId.set(id, message); - dismissalCursor = read.nextCursor; - dismissalPage += 1; - } - - const reviewThreads = entries.map((entry) => ({ - ...entry.thread, - commentCount: entry.commentCount, - ...(entry.nextCommentCursor === null - ? {} - : { nextCommentsCursor: entry.nextCommentCursor }), - })); - return { - comments: reviewThreadConversation(reviewThreads), - dismissalsByReviewId, - reviewThreads, - // GitHub's own count of each thread, so the number the page shows is the host's even - // where a bound kept some of the words on GitHub. - commentCount: entries.reduce((total, entry) => total + entry.commentCount, 0), - truncated: cursor !== null || entries.some((entry) => entry.nextCommentCursor !== null), - reviewThreadsTruncated: cursor !== null, - reactions, - reactionsById, - editedAtById, - reviewers, - avatarsByLogin, - botLogins, - commitStats, - commits, - viewer, - }; - }), - - listActorAvatars: (input) => { - if (input.ids.length === 0) { - return Effect.succeed(new Map()); - } - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listActorAvatars", - variables: { ids: input.ids }, - query: ACTOR_AVATARS_GRAPHQL_QUERY, - decode: decodeActorAvatarsJson, - }); - }, - - getViewerAccess: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getViewerAccess", - ...(input.allowReserve === true ? { allowReserve: true } : {}), - variables: { owner, name, number: input.number }, - query: VIEWER_PERMISSIONS_GRAPHQL_QUERY, - decode: decodeViewerPermissionsJson, - }); - }, - - listReviewerCandidates: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listReviewerCandidates", - allowReserve: true, - variables: { owner, name, number: input.number }, - query: REVIEWER_CANDIDATES_GRAPHQL_QUERY, - decode: decodeReviewerCandidatesJson, - }); - }, - - setReviewerRequest: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - // Posting to a login GitHub has already been asked about is what a re-request is, so - // there is nothing to say here about somebody who has reviewed once already. - return api - .rest({ - host: input.host, - operation: "setReviewerRequest", - method: input.requested ? "POST" : "DELETE", - path: `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`, - body: buildReviewerRequest(input.reviewers), - }) - .pipe(Effect.asVoid); - }, - - listLabelCandidates: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listLabelCandidates", - allowReserve: true, - variables: { owner, name, number: input.number }, - query: LABEL_CANDIDATES_GRAPHQL_QUERY, - decode: decodeLabelCandidatesJson, - }); - }, - - setLabels: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - // A pull request is an issue to the labels API. Adding posts a list and leaves what was - // already there; taking off is one delete per label, since the endpoint names one in its - // path. The name goes into the path encoded, because a label may carry a space or a slash. - const issue = `repos/${owner}/${name}/issues/${input.number}/labels`; - if (input.applied) { - return api - .rest({ - host: input.host, - operation: "setLabels", - method: "POST", - path: issue, - body: buildLabelRequest(input.labels), - }) - .pipe(Effect.asVoid); - } - return Effect.forEach( - input.labels, - (label) => - api.rest({ - host: input.host, - operation: "setLabels", - method: "DELETE", - path: `${issue}/${encodeURIComponent(label)}`, - }), - { concurrency: 1, discard: true }, - ); - }, - - runPullRequestAction: (input) => { - if (input.stackNumber !== undefined) - return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( - Effect.provideService(GitHubApi.GitHubApi, api), - Effect.provideService(VcsProcess.VcsProcess, vcsProcess), - Effect.provideService(FileSystem.FileSystem, fileSystem), - ); - if (input.action === "revert") { - return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( - Effect.flatMap((pullRequestId) => - graphql({ - host: input.host, - operation: "revertPullRequest", - query: REVERT_PULL_REQUEST_GRAPHQL_MUTATION, - variables: { pullRequestId }, - }), - ), - ); - } - if (input.action === "approve-workflows") { - const { owner, name } = parseRepositorySelector(input.repository); - return getPullRequestDetail(input).pipe( - Effect.flatMap((detail) => { - if (detail.isCrossRepository !== true) return Effect.void; - if (detail.headSha == null || detail.headRepositoryOwner == null) { - return Effect.fail( - new GitHubWorkflowApprovalHeadUnavailableError({ - command: "gh", - cwd: input.cwd, - number: input.number, - }), - ); - } - const expectedHeadSha = detail.headSha; - const expectedHeadBranch = detail.headBranch; - const expectedHeadRepositoryOwner = detail.headRepositoryOwner; - return listWorkflowRunsRequiringApproval({ - ...input, - headSha: expectedHeadSha, - headBranch: expectedHeadBranch, - headRepositoryOwner: expectedHeadRepositoryOwner, - isCrossRepository: true, - }).pipe( - Effect.flatMap((runs) => - Effect.forEach( - runs, - (run) => - getPullRequestDetail(input).pipe( - Effect.flatMap((current) => { - if (current.headSha == null || current.headRepositoryOwner == null) { - return Effect.fail( - new GitHubWorkflowApprovalHeadUnavailableError({ - command: "gh", - cwd: input.cwd, - number: input.number, - }), - ); - } - if ( - current.isCrossRepository !== true || - current.headSha !== expectedHeadSha || - current.headBranch !== expectedHeadBranch || - current.headRepositoryOwner.toLowerCase() !== - expectedHeadRepositoryOwner.toLowerCase() - ) { - return Effect.fail( - new GitHubWorkflowApprovalHeadChangedError({ - command: "gh", - cwd: input.cwd, - number: input.number, - }), - ); - } - return listWorkflowRunsRequiringApproval({ - ...input, - headSha: current.headSha, - headBranch: current.headBranch, - headRepositoryOwner: current.headRepositoryOwner, - isCrossRepository: true, - }); - }), - Effect.flatMap((currentRuns) => - currentRuns.some((current) => current.id === run.id) - ? api - .rest({ - host: input.host, - operation: "approveWorkflowRun", - method: "POST", - path: `repos/${owner}/${name}/actions/runs/${run.id}/approve`, - }) - .pipe(Effect.asVoid) - : Effect.void, - ), - ), - { concurrency: 1, discard: true }, - ), - ), - ); - }), - ); - } - const action = input.action; - if (action in SIMPLE_ACTION_MUTATIONS) { - return pullRequestNodeId({ ...input, operation: "runPullRequestAction" }).pipe( - Effect.flatMap((pullRequestId) => - graphql({ - host: input.host, - operation: "runPullRequestAction", - query: SIMPLE_ACTION_MUTATIONS[action as keyof typeof SIMPLE_ACTION_MUTATIONS], - variables: { pullRequestId }, - }), - ), - ); - } - return Effect.gen(function* () { - const { owner, name } = parseRepositorySelector(input.repository); - // Read fresh rather than from the node id cache: merging and updating act on the head - // as it stands now, and the merge queue decides which mutation a merge is. - const state = yield* graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "runPullRequestAction", - allowReserve: true, - query: ACTION_STATE_GRAPHQL_QUERY, - variables: { - owner, - name, - number: input.number, - headRef: `refs/pull/${input.number}/head`, - }, - decode: decodeActionState, - }); - if (action === "update-branch") { - // Already current is done, the way `gh pr update-branch` reports it. - if (state.baseRef?.compare?.behindBy === 0) return; - // GitHub updates with a merge commit unless asked to rebase, which is its own default. - return yield* graphql({ - host: input.host, - operation: "runPullRequestAction", - query: UPDATE_BRANCH_GRAPHQL_MUTATION, - variables: { - pullRequestId: state.id, - expectedHeadOid: state.headRefOid, - updateMethod: input.updateMethod === "rebase" ? "REBASE" : "MERGE", - }, - }); - } - let body: string | undefined; - let expectedHead: string | undefined; - if (input.removeAgentCreditsOnMerge === true && input.mergeMethod !== "rebase") { - const message = yield* graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "runPullRequestAction", - allowReserve: true, - query: MERGE_MESSAGE_GRAPHQL_QUERY, - variables: { - owner, - name, - number: input.number, - method: input.mergeMethod === "squash" ? "SQUASH" : "MERGE", - }, - decode: decodeMergeMessage, - }); - // GitHub's merge queue chooses its own message and ignores custom text. - if (!message.isMergeQueueEnabled) { - const cleaned = removeAgentCredits(message.viewerMergeBodyText); - if (cleaned !== message.viewerMergeBodyText) { - body = cleaned; - expectedHead = message.headRefOid; - } - } - } - // A merge queue takes a pull request through auto-merge rather than a direct merge, and - // `--auto` on a pull request that is mergeable right now simply merges it, as `gh` does. - // GitHub stores the strategy with a standing instruction rather than choosing one at - // merge time, so arming still names it. - const auto = - state.isMergeQueueEnabled === true || - (action === "enable-auto-merge" && - !IMMEDIATELY_MERGEABLE.has(state.mergeStateStatus?.toUpperCase() ?? "")); - yield* graphql({ - host: input.host, - operation: "runPullRequestAction", - query: auto ? ENABLE_AUTO_MERGE_GRAPHQL_MUTATION : MERGE_PULL_REQUEST_GRAPHQL_MUTATION, - variables: { - input: { - pullRequestId: state.id, - mergeMethod: GRAPHQL_MERGE_METHODS[input.mergeMethod ?? "merge"], - ...(expectedHead === undefined ? {} : { expectedHeadOid: expectedHead }), - ...(body === undefined ? {} : { commitBody: body }), - }, - }, - }); - }); - }, - - commentOnPullRequest: (input) => - pullRequestNodeId({ ...input, operation: "commentOnPullRequest" }).pipe( - Effect.flatMap((subjectId) => - graphql({ - host: input.host, - operation: "commentOnPullRequest", - query: ADD_COMMENT_GRAPHQL_MUTATION, - variables: { subjectId, body: input.body }, - }), - ), - ), - - submitReview: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - // The whole review is one request, so nothing is visible to anyone else until the verdict - // is sent. - return api - .rest({ - host: input.host, - operation: "submitReview", - method: "POST", - path: `repos/${owner}/${name}/pulls/${input.number}/reviews`, - body: buildReviewSubmission({ - verdict: input.verdict, - body: input.body, - comments: input.comments, - }), - }) - .pipe(Effect.asVoid); - }, - - replyToReviewThread: (input) => - graphql({ - host: input.host, - operation: "replyToReviewThread", - query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, - variables: { threadId: input.threadId, body: input.body }, - }), - - getPullRequestFilesViewed: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - const read = ( - after: string | null, - collected: ReadonlyArray, - pagesLeft: number, - ): Effect.Effect => - graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestFilesViewed", - variables: { owner, name, number: input.number, after }, - query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, - decode: decodePullRequestFilesViewedJson, - }).pipe( - Effect.flatMap((page) => { - const files = [...collected, ...page.files]; - if (page.nextCursor === null) { - return Effect.succeed({ files, truncated: false }); - } - return pagesLeft <= 1 - ? Effect.succeed({ files, truncated: true }) - : read(page.nextCursor, files, pagesLeft - 1); - }), - ); - return read(null, [], FILES_VIEWED_MAX_PAGES); - }, - - setPullRequestFilesViewed: (input) => { - const mutation = buildSetFilesViewedGraphQlMutation(input.files); - if (mutation === null) return Effect.void; - return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe( - Effect.flatMap((pullRequestId) => - graphql({ - host: input.host, - operation: "setPullRequestFilesViewed", - query: mutation.query, - variables: { pullRequestId, ...mutation.variables }, - }), - ), - ); - }, - - setReviewThreadResolution: (input) => - graphql({ - host: input.host, - operation: "setReviewThreadResolution", - query: input.resolved - ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION - : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, - variables: { threadId: input.threadId }, - }), - - setReaction: (input) => { - const givenSubjectId = input.subjectId; - const subjectId = - givenSubjectId === undefined - ? pullRequestNodeId({ ...input, operation: "setReaction" }) - : subjectBelongsToPullRequest({ - ...input, - subjectId: givenSubjectId, - operation: "setReaction", - }).pipe( - Effect.flatMap((belongs) => - belongs - ? Effect.succeed(givenSubjectId) - : Effect.fail( - new GitHubSubjectScopeError({ - command: "gh", - cwd: input.cwd, - operation: "setReaction", - }), - ), - ), - ); - return subjectId.pipe( - Effect.flatMap((subjectId) => - graphql({ - host: input.host, - operation: "setReaction", - query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION, - variables: { subjectId, content: gitHubReactionContent(input.content) }, - }), - ), - ); - }, - - updatePullRequest: (input) => - pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe( - Effect.flatMap((pullRequestId) => - graphql({ - host: input.host, - operation: "updatePullRequest", - query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, - // A field the caller did not name is left out of the request entirely, so GitHub - // keeps the words that are there rather than being asked for an empty one. - variables: { - pullRequestId, - ...(input.title === undefined ? {} : { title: input.title }), - ...(input.body === undefined ? {} : { body: input.body }), - }, - }), - ), - ), - - updateComment: (input) => - subjectBelongsToPullRequest({ - cwd: input.cwd, - repository: input.repository, - host: input.host, - number: input.number, - subjectId: input.commentId, - operation: "updateComment", - }).pipe( - Effect.flatMap((belongs) => - belongs - ? Effect.succeed(input.commentId) - : Effect.fail( - new GitHubSubjectScopeError({ - command: "gh", - cwd: input.cwd, - operation: "updateComment", - }), - ), - ), - Effect.flatMap((commentId) => - graphql({ - host: input.host, - operation: "updateComment", - query: - input.kind === "issue-comment" - ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION - : UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, - variables: { commentId, body: input.body }, - }), - ), - ), - }); -}); - -export const layer = Layer.effect(GitHubPullRequestCli, make); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 88079ebb94c7f8ae31e36f906eaaace6d96f2661..f00cbfaf5d3246a4aa9598c7508560f385a9415e 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -7,8 +7,8 @@ import type { PullRequestReaction } from "@t3tools/contracts"; import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; -import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts"; import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; @@ -31,13 +31,12 @@ it.effect("maps credential verification failures without relabeling operation fa let operations = 0; const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, withVerifiedCredential: (_input, use) => verificationFails ? Effect.fail( - new GitHubPullRequestCli.GitHubViewerLoginUnavailableError({ - command: "gh", + new GitHubPullRequestApi.GitHubViewerLoginUnavailableError({ cwd: "/w", }), ) @@ -76,7 +75,7 @@ it.effect("refreshes checks without permissions or comparison reads", () => ); const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.sync(() => { @@ -119,7 +118,7 @@ it.effect("uses one narrow read for a linked pull request summary", () => let summaryReads = 0; const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestSummary: () => Effect.sync(() => { @@ -169,7 +168,7 @@ it.effect("declares host-native stacks and passes the one the CLI reads through" }; const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestStack: (input) => Effect.succeed(input.number === 7 ? stack : null), }), @@ -189,12 +188,11 @@ it.effect("reports a failed stack read against its own operation", () => Effect.gen(function* () { const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestStack: () => Effect.fail( - new GitHubPullRequestCli.GitHubPullRequestReadError({ - command: "gh", + new GitHubPullRequestApi.GitHubPullRequestReadError({ cwd: "/w", operation: "getPullRequestStack", cause: new Error("unreadable"), @@ -343,7 +341,7 @@ describe("gitHubViewerPermissions", () => { } }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed({ @@ -437,7 +435,7 @@ describe("gitHubViewerPermissions", () => { ]); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed({ @@ -541,7 +539,7 @@ it.effect( Effect.gen(function* () { const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ getPullRequestDetail: () => Effect.succeed({ ...openDetail, comparison: { behindBy: 2, viewerCanUpdate: true } }), listWorkflowRunsRequiringApproval: () => @@ -582,7 +580,7 @@ it.effect("does not classify same-repository gates as fork workflow approvals", expect(detail.checks).toEqual([]); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }), listWorkflowRunsRequiringApproval: () => @@ -621,13 +619,12 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () => ]); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed(openDetail), listWorkflowRunsRequiringApproval: () => Effect.fail( - new GitHubPullRequestCli.GitHubWorkflowApprovalRefusedError({ - command: "gh", + new GitHubPullRequestApi.GitHubWorkflowApprovalRefusedError({ cwd: "/w", number: 7, reason: "head-not-unique", @@ -664,7 +661,7 @@ it.effect("propagates workflow discovery rate limits", () => expect(error.reason).toBe("rate-limited"); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed(openDetail), listWorkflowRunsRequiringApproval: () => @@ -713,7 +710,7 @@ describe("getViewerPermissions", () => { expect(permissions.actions).not.toContain("update-branch"); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.die("Unexpected detail read"), getViewerAccess: () => Effect.sync(() => (accessReads++, access)), @@ -723,13 +720,13 @@ describe("getViewerPermissions", () => { }); const layerWithDetail = ( - detail: Effect.Effect, + detail: Effect.Effect, onDetail: (allowReserve: boolean) => void = () => {}, ) => - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => - GitHubCli.AllowGitHubReserve.pipe( + GitHubRepositoryApi.AllowGitHubReserve.pipe( Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))), Effect.flatMap(() => detail), ), @@ -789,12 +786,11 @@ describe("getViewerPermissions", () => { expect(permissions.actions).not.toContain("update-branch"); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.fail( - new GitHubPullRequestCli.GitHubPullRequestReadError({ - command: "gh", + new GitHubPullRequestApi.GitHubPullRequestReadError({ cwd: "/w", operation: "getPullRequestDetail", cause: new Error("head changed"), @@ -869,7 +865,7 @@ describe("getChangeRequest commits", () => { }; const layerWith = (commits: GitHubReviewThreadComments["commits"]) => - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestActivity: () => Effect.succeed({ @@ -956,7 +952,7 @@ describe("getChangeRequestActivity dismissed reviews", () => { viewer: { canUpdate: true, didAuthor: false }, }; const layerFor = (body: string) => - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestActivity: () => Effect.succeed({ author: null, comments: [dismissedReview(body)], commits: [] }), @@ -1040,7 +1036,7 @@ describe("editing", () => { ]); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, updatePullRequest: (input) => Effect.sync(() => void rewrites.push(input)), updateComment: (input) => Effect.sync(() => void rewrites.push(input)), diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 74c5852a48a49eb331fd92d93334e3fb78895ac5..5e6f2420729949a87dd1e1762659c6c6232708d5 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -7,8 +7,8 @@ import type { PullRequestViewerPermissions, } from "@t3tools/contracts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; -import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import { PullRequestProviderError, type PullRequestProviderFailure, @@ -104,7 +104,7 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest /** The tags that mean GitHub is out of reach for this account, rather than one request failing. */ export function gitHubProviderFailure( - error: GitHubPullRequestCli.GitHubPullRequestCliError, + error: GitHubPullRequestApi.GitHubPullRequestApiError, ): PullRequestProviderFailure { switch (error._tag) { case "GitHubCliMissingError": @@ -197,9 +197,9 @@ const rendersEmpty = (body: string): boolean => body.replace(//g, "").trim().length === 0; export const make = Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; - const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) => + const fail = (operation: string) => (error: GitHubPullRequestApi.GitHubPullRequestApiError) => new PullRequestProviderError({ provider: "github", operation, @@ -526,7 +526,7 @@ export const make = Effect.gen(function* () { // comparison, so one read usually answers what used to take three. When that heavier read // fails, the light access read still answers, withholding only update-branch. return cli.getPullRequestDetail(input).pipe( - Effect.provideService(GitHubCli.AllowGitHubReserve, true), + Effect.provideService(GitHubRepositoryApi.AllowGitHubReserve, true), Effect.map((pullRequest) => gitHubViewerPermissions({ ...pullRequest.viewerAccess, diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 60de91d0e99af4b99ffce017b52b39365a2efcc2..efbe37f991eeecb69fbe623694739c05f5d6ca11 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -16,7 +16,7 @@ import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; -import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; @@ -61,11 +61,11 @@ export const make = Effect.map( export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( Layer.provide( - GitHubPullRequestCli.layer.pipe( + GitHubPullRequestApi.layer.pipe( Layer.provide( GitHubApi.layer.pipe( Layer.provide(GitHubCredentials.layer), - // The same layers GitHubCli merges, so both share one budget and one pause per host. + // The same layers GitHubRepositoryApi merges, so both share one budget and one pause per host. Layer.provide(GitHubGraphQlBudget.layer), Layer.provide(SourceControlRateLimit.layer), ), diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 3da0b9ba2e03b40d173e33858c6e3456294dff43..9e2b81d97d5f5545e4755c73bb665541b35831d3 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -77,7 +77,7 @@ import { import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; diff --git a/apps/server/src/pullRequest/gitHubConditionalChecks.ts b/apps/server/src/pullRequest/gitHubConditionalChecks.ts index 8172bf544074c6aa7776bfaf2a35869294caeebe..6975da251936bbab015b8544e033ece84e8f4452 100644 --- a/apps/server/src/pullRequest/gitHubConditionalChecks.ts +++ b/apps/server/src/pullRequest/gitHubConditionalChecks.ts @@ -9,7 +9,7 @@ import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts"; -import type { GitHubPullRequestCliError } from "./GitHubPullRequestCli.ts"; +import type { GitHubPullRequestApiError } from "./GitHubPullRequestApi.ts"; import type { ProviderRepositoryRef } from "./PullRequestProvider.ts"; const HeadSchema = Schema.Struct({ @@ -70,7 +70,7 @@ export const makeChecksRevalidator = Effect.gen(function* () { Pick & { workflowApprovalsRequired?: number; }, - GitHubPullRequestCliError + GitHubPullRequestApiError >, ) => Effect.gen(function* () { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ebbc73758a437b85e9c18318763a4f08e4e08feb..fdd7d6c7165f4e16ed52ec8e9add1d2694ecb0a0 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -50,7 +50,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubCli from "./sourceControl/GitHubCli.ts"; +import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; @@ -270,7 +270,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, - GitHubCli.layer, + GitHubRepositoryApi.layer, GitLabCli.layer, ForgejoCli.layer, ), @@ -565,7 +565,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(layerServerSettings), // The asset route uses the registry's GitHub credential for private PR media. Layer.provideMerge(layerSourceControlProviderRegistry), - Layer.provideMerge(GitHubCli.layer), + Layer.provideMerge(GitHubRepositoryApi.layer), Layer.provideMerge(layerGit), Layer.provideMerge(layerVcs), Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts deleted file mode 100644 index 3954617008a58cfbc94c7603ce29b84c57ac8370..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ /dev/null @@ -1,645 +0,0 @@ -import { assert, it, describe } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as TestClock from "effect/testing/TestClock"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import { ChildProcessSpawner } from "effect/process"; - -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; -import * as GitHubCredentials from "./GitHubCredentials.ts"; - -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); - -const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({ - exitCode: ChildProcessSpawner.ExitCode(exitCode), - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, -}); - -const remotesOutput = (...entries: ReadonlyArray) => - entries - .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) - .join("\n"); - -const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({ - status, - headers: {}, - body: body === undefined ? "" : encodeJson(body), - truncated: false, - invalidUtf8: false, -}); - -const node = (number: number, headRefName: string, owner = "acme") => ({ - number, - title: `PR ${number}`, - url: `https://github.com/acme/web/pull/${number}`, - baseRefName: "main", - headRefName, - state: "OPEN", - isCrossRepository: owner !== "acme", - updatedAt: "2026-01-02T00:00:00Z", - headRepository: { name: "web", nameWithOwner: `${owner}/web` }, - headRepositoryOwner: { login: owner }, -}); - -/** - * A GitHubCli over a mocked GitHubApi, git driver and process. `remotes` is what - * `git remote -v` prints; `git` records every driver call. - */ -function harness(input: { - readonly remotes: string; - readonly api: Partial; - readonly localBranches?: ReadonlyArray; -}) { - const git: Array = []; - const record = - (name: string, value: A) => - (args: unknown) => - Effect.sync(() => { - git.push([name, args]); - return value; - }); - const driver = Layer.mock(GitVcsDriver.GitVcsDriver)({ - execute: (args) => - Effect.sync(() => { - git.push(["execute", args.args]); - return processOutput(""); - }), - resolvePrimaryRemoteName: () => Effect.succeed("origin"), - readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), - ensureRemote: (args) => - Effect.sync(() => { - git.push(["ensureRemote", args]); - return args.preferredName; - }), - fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args), - setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args), - switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never, - listLocalBranchNames: () => Effect.succeed([...(input.localBranches ?? [])]), - resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), - }); - const process = Layer.mock(VcsProcess.VcsProcess)({ - run: (args) => - Effect.succeed( - args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), - ), - }); - const layer = Layer.effect(GitHubCli.GitHubCli, GitHubCli.make).pipe( - Layer.provide( - Layer.mergeAll( - driver, - process, - Layer.mock(GitHubApi.GitHubApi)(input.api), - NodeServices.layer, - ), - ), - ); - return { layer, git }; -} - -describe("selectGitHubBaseRepository", () => { - const remotes = (...entries: ReadonlyArray) => - entries - .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) - .join("\n"); - const select = (input: { remotes: string; resolved?: string }) => - GitHubCli.selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); - - it("picks the repository gh reads without a prompt", () => { - assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { - owner: "me", - name: "web", - }); - const fork = remotes( - ["origin", "git@github.com:me/web.git"], - ["upstream", "https://github.com/Acme/Web.git"], - ); - assert.deepStrictEqual(select({ remotes: fork }), { owner: "acme", name: "web" }); - assert.deepStrictEqual(select({ remotes: fork, resolved: "remote.origin.gh-resolved base" }), { - owner: "me", - name: "web", - }); - assert.deepStrictEqual( - select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), - { owner: "acme", name: "other" }, - ); - // gh ranks remote names in any case. - assert.deepStrictEqual( - select({ - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["Upstream", "git@github.com:acme/web.git"], - ), - }), - { owner: "acme", name: "web" }, - ); - }); - - it("leaves gh to choose when it might weigh the remotes differently", () => { - for (const input of [ - { remotes: "" }, - { - remotes: remotes(["a", "git@github.com:me/web.git"], ["b", "git@github.com:acme/web.git"]), - }, - { - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["mirror", "git@gitlab.com:me/web.git"], - ), - }, - { remotes: remotes(["origin", "git@github-work:me/web.git"]) }, - { - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["Origin", "git@github.com:acme/web.git"], - ), - }, - { - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["upstream", "git@github.com:acme/web.git"], - ), - resolved: "remote.origin.gh-resolved base\nremote.upstream.gh-resolved base", - }, - ]) { - assert.strictEqual(select(input), null); - } - }); -}); - -describe("GitHubCli repository resolution", () => { - it.effect("reads the repository gh would pick from the remotes", () => { - const paths: string[] = []; - const { layer } = harness({ - remotes: remotesOutput( - ["origin", "git@github.com:me/web.git"], - ["upstream", "https://github.com/acme/web.git"], - ), - api: { - rest: (input) => - Effect.sync(() => { - paths.push(`${input.host} ${input.path}`); - return restResponse({ - full_name: "acme/web", - html_url: "https://github.com/acme/web", - ssh_url: "git@github.com:acme/web.git", - default_branch: "trunk", - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); - assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("reads an SSH alias remote through github.com", () => { - const hosts: string[] = []; - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@github:acme/web.git"]), - api: { - rest: (input) => - Effect.sync(() => { - hosts.push(`${input.host} ${input.path}`); - return restResponse({ - full_name: "acme/web", - html_url: "https://github.com/acme/web", - ssh_url: "git@github.com:acme/web.git", - default_branch: "main", - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - yield* gh.getDefaultBranch({ cwd: "/repo" }); - // A provider's host hint for the same alias (`github` here) resolves the same way. - yield* gh.getDefaultBranch({ cwd: "/repo", rateLimitHost: "github" }); - assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); - assert.strictEqual( - GitHubCli.gitHubApiHostForRemote("git@github.example.com:a/b.git"), - "github.example.com", - ); - assert.strictEqual(GitHubCli.gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails clearly when no remote is on GitHub", () => { - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]), - api: {}, - }); - return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); - assert.strictEqual(error._tag, "GitHubCliCommandError"); - assert.include(String((error.cause as Error).message), "No GitHub repository"); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubCli.listPullRequestsByHead", () => { - const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); - - it.effect("reads a background sweep's staggered heads in one GraphQL document", () => { - const documents: Array = []; - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.sync(() => { - documents.push(input); - return encodeJson({ - data: { - repository: { - h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] }, - h1: { nodes: [] }, - }, - }, - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - const lookup = (headSelector: string) => - gh - .listPullRequestsByHead({ - cwd: "/repo", - headSelector, - state: "all", - limit: 100, - rateLimitHost: "github.com", - }) - .pipe(Effect.forkChild); - // Each branch's own git reads come first, so a sweep's lookups arrive spread out. - const firstLookup = yield* lookup("feature/a"); - yield* TestClock.adjust("200 millis"); - const secondLookup = yield* lookup("feature/b"); - yield* TestClock.adjust("300 millis"); - const first = yield* Fiber.join(firstLookup); - const second = yield* Fiber.join(secondLookup); - assert.deepStrictEqual( - first?.map((pr) => pr.number), - [7], - ); - assert.strictEqual(first?.[0]?.headSha, "a".repeat(40)); - assert.deepStrictEqual(second, []); - assert.strictEqual(documents.length, 1); - assert.include(documents[0]!.query, "headRefOid"); - assert.deepStrictEqual(documents[0]!.variables, { - owner: "acme", - name: "web", - h0: "feature/a", - s0: ["OPEN", "CLOSED", "MERGED"], - h1: "feature/b", - s1: ["OPEN", "CLOSED", "MERGED"], - }); - }).pipe(Effect.provide(layer)); - }); - - it.effect("caps a background document at twenty-five heads", () => { - const headCounts: Array = []; - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.sync(() => { - const heads = Object.keys(input.variables ?? {}).filter((key) => /^h\d+$/.test(key)); - headCounts.push(heads.length); - return encodeJson({ - data: { repository: Object.fromEntries(heads.map((key) => [key, { nodes: [] }])) }, - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - const lookups = yield* Effect.all( - Array.from({ length: 26 }, (_, index) => - gh.listPullRequestsByHead({ - cwd: "/repo", - headSelector: `feature/${index}`, - state: "all", - limit: 100, - rateLimitHost: "github.com", - }), - ), - { concurrency: "unbounded" }, - ).pipe(Effect.forkChild); - yield* TestClock.adjust("500 millis"); - yield* Fiber.join(lookups); - assert.deepStrictEqual( - headCounts.toSorted((a, b) => a - b), - [1, 25], - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect("matches an owner:branch selector on the head owner", () => { - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.succeed( - encodeJson({ - data: { - repository: { - h0: { - nodes: - input.variables?.h0 === "main" - ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")] - : [], - }, - }, - }, - }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - const open = yield* gh - .listOpenPullRequests({ cwd: "/repo", headSelector: "me:main", limit: 1 }) - .pipe(Effect.forkChild); - yield* TestClock.adjust("50 millis"); - assert.deepStrictEqual( - (yield* Fiber.join(open)).map((pr) => pr.number), - [8], - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect("maps API failures onto the errors callers handle", () => { - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.fail( - input.variables?.h0 === "missing" - ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" }) - : new GitHubApi.GitHubApiRateLimitError({ - host: "github.com", - operation: "x", - retryAt: 123, - }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - const read = (headSelector: string) => - gh - .listPullRequestsByHead({ cwd: "/repo", headSelector, state: "open", limit: 1 }) - .pipe(Effect.flip, Effect.forkChild); - const missing = yield* read("missing"); - yield* TestClock.adjust("500 millis"); - assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubCliUnavailableError"); - const limited = yield* read("limited"); - yield* TestClock.adjust("500 millis"); - const error = yield* Fiber.join(limited); - assert.strictEqual(error._tag, "GitHubCliRateLimitError"); - assert.propertyVal(error, "retryAt", 123); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubCli.getPullRequest", () => { - it.effect("reads a pull request by number, and by URL on its own repository", () => { - const variables: Array = []; - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - api: { - graphql: (input) => - Effect.sync(() => { - variables.push(input.variables); - return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - assert.strictEqual((yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" })).number, 42); - yield* gh.getPullRequest({ - cwd: "/repo", - reference: "https://github.com/other/thing/pull/42", - }); - assert.deepStrictEqual(variables, [ - { owner: "acme", name: "web", number: 42 }, - { owner: "other", name: "thing", number: 42 }, - ]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails a missing pull request as not found", () => { - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - api: { - graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - const error = yield* gh.getPullRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); - assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubCli writes", () => { - it.effect("creates a cross-repository pull request with an owner:branch head", () => { - const requests: Array = []; - const { layer } = harness({ - remotes: remotesOutput( - ["origin", "git@github.com:me/web.git"], - ["upstream", "git@github.com:acme/web.git"], - ), - api: { - rest: (input) => - Effect.sync(() => { - requests.push(input); - return restResponse({ number: 1 }, 201); - }), - }, - }); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); - yield* fs.writeFileString(bodyFile, "Body"); - const gh = yield* GitHubCli.GitHubCli; - yield* gh.createPullRequest({ - cwd: "/repo", - baseBranch: "main", - headSelector: "me:feature", - title: "Title", - bodyFile, - }); - assert.strictEqual(requests[0]!.method, "POST"); - assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); - assert.deepStrictEqual(requests[0]!.body, { - base: "main", - head: "me:feature", - title: "Title", - body: "Body", - maintainer_can_modify: true, - }); - }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped); - }); - - it.effect("creates a repository under an organization the viewer is not", () => { - const requests: Array = []; - const { layer } = harness({ - remotes: "", - api: { - rest: (input) => - Effect.sync(() => { - requests.push(`${input.method ?? "GET"} ${input.path}`); - return input.path === "user" - ? restResponse({ login: "me" }) - : restResponse({ - full_name: "acme/new", - html_url: "https://github.com/acme/new", - ssh_url: "git@github.com:acme/new.git", - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - const urls = yield* gh.createRepository({ - cwd: "/repo", - repository: "acme/new", - visibility: "private", - }); - assert.deepStrictEqual(urls, { - nameWithOwner: "acme/new", - url: "https://github.com/acme/new", - sshUrl: "git@github.com:acme/new.git", - }); - assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubCli.checkoutPullRequest", () => { - const repository = (fullName: string, defaultBranch = "main") => - restResponse({ - full_name: fullName, - html_url: `https://github.com/${fullName}`, - ssh_url: `git@github.com:${fullName}.git`, - default_branch: defaultBranch, - }); - - it.effect("checks a same-repository pull request out from its head branch", () => { - const { layer, git } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - api: { - graphql: () => - Effect.succeed( - encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "5" }); - assert.deepStrictEqual(git, [ - [ - "fetchRemoteTrackingBranch", - { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" }, - ], - ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]], - ["switchRef", { cwd: "/repo", refName: "feature/x" }], - [ - "setBranchUpstream", - { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" }, - ], - ]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("refuses a fork checkout when the base's default branch cannot be read", () => { - const { layer, git } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - localBranches: ["main"], - api: { - graphql: () => - Effect.succeed( - encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), - ), - rest: () => - Effect.fail( - new GitHubApi.GitHubApiRequestError({ - host: "github.com", - operation: "x", - cause: "offline", - }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - yield* Effect.flip(gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true })); - // Nothing touched the local branches: `main` must not be reset to the fork's commit. - assert.deepStrictEqual(git, []); - }).pipe(Effect.provide(layer)); - }); - - it.effect("adds a remote for a fork and names a default-branch head after its owner", () => { - const { layer, git } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - localBranches: ["someone/main"], - api: { - graphql: () => - Effect.succeed( - encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), - ), - rest: (input) => - Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true }); - assert.deepStrictEqual(git, [ - [ - "ensureRemote", - { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" }, - ], - [ - "fetchRemoteTrackingBranch", - { cwd: "/repo", remoteName: "someone", remoteBranch: "main" }, - ], - ["switchRef", { cwd: "/repo", refName: "someone/main" }], - ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]], - [ - "setBranchUpstream", - { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" }, - ], - ]); - }).pipe(Effect.provide(layer)); - }); - - it("names the local branch the way gh pr checkout does", () => { - const name = (headRefName: string, isCrossRepository: boolean) => - GitHubCli.pullRequestCheckoutBranchName({ - headRefName, - headOwner: "someone", - isCrossRepository, - defaultBranch: "main", - }); - assert.strictEqual(name("main", true), "someone/main"); - assert.strictEqual(name("feature", true), "feature"); - assert.strictEqual(name("main", false), "main"); - }); -}); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts deleted file mode 100644 index d12e5f334febdc989e8efd1b5a32aa316afef763..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ /dev/null @@ -1,1091 +0,0 @@ -import * as Context from "effect/Context"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Request from "effect/Request"; -import * as RequestResolver from "effect/RequestResolver"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; - -import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; -import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -import { - detectSourceControlProviderFromRemoteUrl, - isSshRemoteUrl, -} from "@t3tools/shared/sourceControl"; - -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; - -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCredentials from "./GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; -import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; -import { - decodeGitHubPullRequestEntries, - type NormalizedGitHubPullRequestRecord, -} from "./gitHubPullRequests.ts"; - -export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve; - -const gitHubCliFailureFields = { - command: Schema.Literal("gh"), - cwd: Schema.String, - cause: Schema.Defect(), -} as const; - -export class GitHubCliUnavailableError extends Schema.TaggedError()( - "GitHubCliUnavailableError", - gitHubCliFailureFields, -) { - override get message(): string { - return "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`."; - } -} - -export class GitHubCliAuthenticationError extends Schema.TaggedError()( - "GitHubCliAuthenticationError", - gitHubCliFailureFields, -) { - override get message(): string { - // A missing or turned-off credential already says what to do about it. - return GitHubCredentials.isGitHubCredentialUnavailableError(this.cause) - ? this.cause.message - : "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry."; - } -} - -export class GitHubCliRateLimitError extends Schema.TaggedError()( - "GitHubCliRateLimitError", - { ...gitHubCliFailureFields, retryAt: Schema.optionalKey(Schema.Finite) }, -) { - override get message(): string { - return "GitHub API rate limit exceeded. Requests resume when the limit resets."; - } -} - -export class GitHubPullRequestNotFoundError extends Schema.TaggedError()( - "GitHubPullRequestNotFoundError", - gitHubCliFailureFields, -) { - override get message(): string { - return "Pull request not found. Check the PR number or URL and try again."; - } -} - -export class GitHubCliCommandError extends Schema.TaggedError()( - "GitHubCliCommandError", - { ...gitHubCliFailureFields, httpStatus: Schema.optional(Schema.Int) }, -) { - override get message(): string { - // GitHub's own reason ("A pull request already exists…") or the failed step's, when known. - const reason = - this.cause instanceof Error && this.cause.message.trim() !== "" - ? this.cause.message.trim() - : null; - return reason === null ? "GitHub request failed." : reason; - } -} - -const gitHubCliDecodeFields = { - command: Schema.Literal("gh"), - cwd: Schema.String, - cause: Schema.Defect(), -} as const; - -export class GitHubPullRequestListDecodeError extends Schema.TaggedError()( - "GitHubPullRequestListDecodeError", - gitHubCliDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid pull request list."; - } -} - -export class GitHubChangeRequestListDecodeError extends Schema.TaggedError()( - "GitHubChangeRequestListDecodeError", - gitHubCliDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid change request list."; - } -} - -export class GitHubPullRequestDecodeError extends Schema.TaggedError()( - "GitHubPullRequestDecodeError", - gitHubCliDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid pull request."; - } -} - -export class GitHubRepositoryDecodeError extends Schema.TaggedError()( - "GitHubRepositoryDecodeError", - gitHubCliDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid repository."; - } -} - -export const GitHubCliError = Schema.Union([ - GitHubCliUnavailableError, - GitHubCliAuthenticationError, - GitHubCliRateLimitError, - GitHubPullRequestNotFoundError, - GitHubCliCommandError, - GitHubPullRequestListDecodeError, - GitHubChangeRequestListDecodeError, - GitHubPullRequestDecodeError, - GitHubRepositoryDecodeError, -]); -export type GitHubCliError = typeof GitHubCliError.Type; - -export const isGitHubCliError = Schema.is(GitHubCliError); - -/** Maps a GitHub API failure onto the errors callers of this service already handle. */ -function fromGitHubApiError(cwd: string, error: GitHubApi.GitHubApiError): GitHubCliError { - const context = { command: "gh" as const, cwd, cause: error }; - switch (error._tag) { - case "GitHubCliMissingError": - return new GitHubCliUnavailableError(context); - case "GitHubNotSignedInError": - case "GitHubHostDisabledError": - case "GitHubApiAuthenticationError": - return new GitHubCliAuthenticationError(context); - case "GitHubCliFailedError": - return new GitHubCliCommandError(context); - case "GitHubApiRateLimitError": - return new GitHubCliRateLimitError({ - ...context, - ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), - }); - case "SourceControlRateLimitPausedError": - return new GitHubCliRateLimitError({ ...context, retryAt: error.retryAt }); - case "GitHubApiNotFoundError": - return new GitHubPullRequestNotFoundError(context); - case "GitHubApiResponseError": - return new GitHubCliCommandError({ ...context, httpStatus: error.status }); - case "GitHubApiRequestError": - return new GitHubCliCommandError(context); - } -} - -export interface GitHubPullRequestSummary { - readonly number: number; - readonly title: string; - readonly url: string; - readonly baseRefName: string; - readonly headRefName: string; - readonly headSha?: string; - readonly state?: "open" | "closed" | "merged"; - readonly isDraft?: boolean; - readonly closedAt?: string | null; - readonly mergedAt?: string | null; - readonly updatedAt?: string; - readonly isCrossRepository?: boolean; - readonly headRepositoryNameWithOwner?: string | null; - readonly headRepositoryOwnerLogin?: string | null; -} - -function pullRequestSummary(input: NormalizedGitHubPullRequestRecord): GitHubPullRequestSummary { - const { updatedAt, ...summary } = input; - return { - ...summary, - ...(Option.isSome(updatedAt) ? { updatedAt: DateTime.formatIso(updatedAt.value) } : {}), - }; -} - -export interface GitHubRepositoryCloneUrls { - readonly nameWithOwner: string; - readonly url: string; - readonly sshUrl: string; -} - -export class GitHubCli extends Context.Service< - GitHubCli, - { - readonly listOpenPullRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly limit?: number; - readonly rateLimitHost?: string; - }) => Effect.Effect, GitHubCliError>; - - /** - * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the - * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive - * together share one GraphQL document. - */ - readonly listPullRequestsByHead: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit: number; - /** The checkout's GitHub API host. Without it, the host comes from the git remotes. */ - readonly rateLimitHost?: string; - }) => Effect.Effect, GitHubCliError>; - - readonly getPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly rateLimitHost?: string; - }) => Effect.Effect; - - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - readonly getDefaultBranch: (input: { - readonly cwd: string; - readonly rateLimitHost?: string; - }) => Effect.Effect; - - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; - } ->()("t3/sourceControl/GitHubCli") {} - -/** - * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a - * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin - * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of - * `git config --get-regexp '^remote\..*\.gh-resolved$'`. - * - * Null whenever gh might weigh the remotes differently: a remote on another host or under an - * SSH alias, more than one mark, or several remotes with none of those names. Callers then - * fall back to the provider's remote, then to the best-ranked remote on the host. - */ -export function selectGitHubBaseRepository(input: { - readonly remotes: string; - readonly resolved: string; - readonly host: string; -}): { readonly owner: string; readonly name: string } | null { - const host = input.host.toLowerCase(); - const repositories = new Map(); - for (const line of input.remotes.split("\n")) { - const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()); - if (!match) continue; - const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/"); - if (remoteHost !== host || !owner || !name || rest.length > 0) return null; - repositories.set(match[1]!, { owner, name }); - } - const marks = input.resolved - .split("\n") - .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) - .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); - if (marks.length > 1) return null; - const [mark] = marks; - if (mark) { - if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; - const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); - return owner && name && rest.length === 0 ? { owner, name } : null; - } - // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the - // top place has no defined winner. - const score = (remoteName: string) => - ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; - const ranked = [...repositories.entries()].toSorted( - ([left], [right]) => score(right) - score(left), - ); - const [top, next] = ranked; - return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) - ? top[1] - : null; -} - -const RawRepositorySchema = Schema.Struct({ - full_name: TrimmedNonEmptyString, - html_url: TrimmedNonEmptyString, - ssh_url: TrimmedNonEmptyString, - default_branch: Schema.optional(Schema.NullOr(Schema.String)), -}); -const decodeRawRepository = decodeJsonResult(RawRepositorySchema); - -function repositoryCloneUrls( - raw: Schema.Schema.Type, -): GitHubRepositoryCloneUrls { - return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url }; -} - -const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString })); - -type PullRequestListState = "open" | "closed" | "merged" | "all"; - -/** The pull request fields every read selects, in GraphQL. */ -const PULL_REQUEST_NODE_SELECTION = - "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; -const GRAPHQL_STATES: Record> = { - open: ["OPEN"], - closed: ["CLOSED"], - merged: ["MERGED"], - all: ["OPEN", "CLOSED", "MERGED"], -}; -/** - * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one - * single lookup, but half that keeps each answer near half a second. - */ -const HEAD_LOOKUPS_PER_DOCUMENT = 50; -/** - * How long a head lookup waits for company. Branch discovery reaches GitHub only after each - * branch's own git reads, so lookups started together arrive tens of milliseconds apart. - * A background sweep's lookups spread over up to ~300ms, and every document costs a point no - * matter how few heads it holds, so reads without reserve wait longer. - */ -const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; -const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; -/** - * Background documents fill up under the longer window, and a failed document fails every head - * in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own - * processing limit; twenty-five took ~7s. - */ -const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25; -/** A full document is 5,000 rows of well under 2 KB each. */ -const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; -/** - * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch - * named like a busy default (`main`) is the case this bounds; the owner's own row is near the - * top because the newest come first. - */ -const OWNER_HEAD_SCAN_LIMIT = 100; - -class PullRequestsByHeadRead extends Request.Class< - { - readonly cwd: string; - readonly host: string; - readonly owner: string; - readonly name: string; - readonly headRefName: string; - readonly state: PullRequestListState; - readonly limit: number; - readonly allowReserve: boolean; - }, - ReadonlyArray, - GitHubCliError -> {} - -/** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ -function buildPullRequestsByHeadQuery( - lookups: ReadonlyArray>, -): { readonly document: string; readonly variables: Record } { - const variables: Record = {}; - const declarations: string[] = ["$owner: String!", "$name: String!"]; - const selections: string[] = []; - for (const [index, lookup] of lookups.entries()) { - variables[`h${index}`] = lookup.headRefName; - variables[`s${index}`] = GRAPHQL_STATES[lookup.state]; - declarations.push(`$h${index}: String!`, `$s${index}: [PullRequestState!]`); - // `gh pr list` orders the same way, so a head with more matches than the limit keeps the - // same rows. - selections.push( - ` h${index}: pullRequests(headRefName: $h${index}, states: $s${index}, first: ${lookup.limit}, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${PULL_REQUEST_NODE_SELECTION} } }`, - ); - } - return { - document: `query PullRequestsByHead(${declarations.join(", ")}) {\n repository(owner: $owner, name: $name) {\n${selections.join("\n")}\n }\n}`, - variables, - }; -} - -const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} } - } -}`; - -const decodePullRequestsByHead = decodeJsonResult( - Schema.Struct({ - data: Schema.Struct({ - repository: Schema.NullOr( - Schema.Record( - Schema.String, - Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), - ), - ), - }), - }), -); - -const decodePullRequestByNumber = decodeJsonResult( - Schema.Struct({ - data: Schema.Struct({ - repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })), - }), - }), -); - -/** A repository on a GitHub host: the API it is read through, and its owner and name. */ -export interface GitHubRepositoryLocator { - readonly host: string; - readonly owner: string; - readonly name: string; -} - -/** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ -function parseGitHubRepositorySelector( - selector: string, - defaultHost: string, -): GitHubRepositoryLocator | null { - const trimmed = selector.trim().replace(/\.git$/i, ""); - if (/^https?:\/\//i.test(trimmed)) { - try { - const url = new URL(trimmed); - const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean); - return owner && name && rest.length === 0 - ? { host: url.host.toLowerCase(), owner, name } - : null; - } catch { - return null; - } - } - const parts = trimmed.split("/").filter(Boolean); - if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; - if (parts.length === 3) - return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! }; - return null; -} - -/** - * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request - * URL, or a branch name. - */ -function parsePullRequestReference( - reference: string, -): - | { readonly kind: "number"; readonly number: number } - | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number } - | { readonly kind: "branch"; readonly headSelector: string } { - const trimmed = reference.trim(); - const numbered = /^#?([1-9]\d*)$/.exec(trimmed); - if (numbered) return { kind: "number", number: Number(numbered[1]) }; - if (/^https?:\/\//i.test(trimmed)) { - try { - const url = new URL(trimmed); - const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname); - if (match) { - return { - kind: "url", - locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! }, - number: Number(match[3]), - }; - } - } catch { - // Not a URL after all; read it as a branch. - } - } - return { kind: "branch", headSelector: trimmed }; -} - -/** - * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH - * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through - * `github.com`, which is what such an alias almost always stands for (issue #6198). - */ -export function gitHubApiHostForRemote(remoteUrl: string): string | null { - const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); - if (provider === null) return null; - const host = new URL(provider.baseUrl).host.toLowerCase(); - // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. - if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { - return host.includes("github") ? "github.com" : null; - } - return provider.kind === "github" ? host : null; -} - -/** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ -function apiHostForHint(host: string): string { - const normalized = host.toLowerCase(); - return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; -} - -/** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ -export function pullRequestCheckoutBranchName(input: { - readonly headRefName: string; - readonly headOwner: string | null; - readonly isCrossRepository: boolean; - readonly defaultBranch: string | null; -}): string { - // gh prefixes the owner only where the fork's branch would take over the default branch's - // name, which is the one collision every fork pull request from `main` would hit. - return input.isCrossRepository && - input.headOwner !== null && - input.defaultBranch !== null && - input.headRefName === input.defaultBranch - ? `${input.headOwner}/${input.headRefName}` - : input.headRefName; -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const process = yield* VcsProcess.VcsProcess; - const environment = yield* HostProcessEnvironment; - const api = yield* GitHubApi.GitHubApi; - const git = yield* GitVcsDriver.GitVcsDriver; - const fileSystem = yield* FileSystem.FileSystem; - - const gitRead = (cwd: string, args: ReadonlyArray) => - process.run({ - operation: "GitHubCli.resolveRepository", - command: "git", - args, - cwd, - allowNonZeroExit: true, - timeoutMs: 5_000, - }); - - const commandFailure = (cwd: string, detail: string) => - new GitHubCliCommandError({ command: "gh", cwd, cause: new Error(detail) }); - - /** - * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else - * the remote the caller resolved the provider from, else the best-ranked GitHub remote. - */ - const resolveRepository = Effect.fn("GitHubCli.resolveRepository")(function* (input: { - readonly cwd: string; - readonly host?: string | undefined; - }) { - const envRepository = environment.GH_REPO?.trim(); - const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase(); - if (envRepository) { - const locator = parseGitHubRepositorySelector(envRepository, defaultHost); - if (locator !== null) return locator; - } - const [remotes, resolved] = yield* Effect.all([ - gitRead(input.cwd, ["remote", "-v"]), - gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), - ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); - const remoteOutput = remotes?.exitCode === 0 ? remotes.stdout : ""; - const fetchRemotes = remoteOutput - .split("\n") - .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) - .filter((match): match is RegExpExecArray => match !== null) - .map((match) => ({ - name: match[1]!, - url: match[2]!, - host: gitHubApiHostForRemote(match[2]!), - })); - const host = - (input.host === undefined ? undefined : apiHostForHint(input.host)) ?? - fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? - fetchRemotes.find((remote) => remote.host !== null)?.host ?? - defaultHost; - const selected = selectGitHubBaseRepository({ - remotes: remoteOutput, - resolved: resolved?.exitCode === 0 ? resolved.stdout : "", - host, - }); - if (selected !== null) return { host, ...selected }; - // gh's own order without its prompt: upstream, github, origin, then the first remote, among - // the ones on this host (an SSH alias counts as its API host). - const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); - const candidates = fetchRemotes - .filter((remote) => remote.host === host) - .toSorted((left, right) => { - const l = rank(left.name); - const r = rank(right.name); - return (l === -1 ? 99 : l) - (r === -1 ? 99 : r); - }); - for (const remote of candidates) { - const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1); - if (owner && name && rest.length === 0) return { host, owner, name }; - } - return yield* commandFailure( - input.cwd, - `No GitHub repository on ${host} was found among this checkout's git remotes.`, - ); - }); - - const graphqlJson = ( - cwd: string, - input: GitHubApi.GitHubGraphQlInput, - decode: (raw: string) => Result.Result, - onDecodeFailure: (cause: unknown) => GitHubCliError, - ) => - api.graphql(input).pipe( - Effect.mapError((error) => fromGitHubApiError(cwd, error)), - Effect.flatMap((raw) => { - const decoded = decode(raw); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail(onDecodeFailure(decoded.failure)); - }), - ); - - const rest = (cwd: string, input: GitHubApi.GitHubRestInput) => - api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(cwd, error))); - - /** Pull requests whose head branch is `headRefName` on one repository. */ - const readPullRequestsByHead = (input: { - readonly cwd: string; - readonly locator: GitHubRepositoryLocator; - readonly lookups: ReadonlyArray< - Pick - >; - readonly allowReserve: boolean; - readonly onDecodeFailure: (cause: unknown) => GitHubCliError; - }) => { - const query = buildPullRequestsByHeadQuery(input.lookups); - return graphqlJson( - input.cwd, - { - host: input.locator.host, - operation: "listPullRequestsByHead", - query: query.document, - variables: { owner: input.locator.owner, name: input.locator.name, ...query.variables }, - allowReserve: input.allowReserve, - // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred - // fork pull requests, so the usual cap would cut the answer short. - maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, - }, - decodePullRequestsByHead, - input.onDecodeFailure, - ).pipe( - Effect.flatMap((decoded) => - decoded.data.repository === null - ? Effect.fail( - new GitHubPullRequestNotFoundError({ - command: "gh", - cwd: input.cwd, - cause: new Error("The repository could not be read."), - }), - ) - : Effect.succeed(decoded.data.repository), - ), - ); - }; - - const headResolver = RequestResolver.makeGrouped({ - key: ({ request, context }) => - [ - request.host, - request.owner, - request.name, - String(request.allowReserve), - Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) - ?.credentialFingerprint ?? "", - Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), - ].join("\0"), - resolver: (entries) => { - const [first] = entries; - const { cwd, host, owner, name, allowReserve } = first.request; - return readPullRequestsByHead({ - cwd, - locator: { host, owner, name }, - lookups: entries.map((entry) => entry.request), - allowReserve, - onDecodeFailure: (cause) => - new GitHubChangeRequestListDecodeError({ command: "gh", cwd, cause }), - }).pipe( - Effect.map((aliases) => { - for (const [index, entry] of entries.entries()) { - const alias = aliases[`h${index}`]; - entry.completeUnsafe( - Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), - ); - } - }), - Effect.catchCause((cause) => - Effect.sync(() => { - for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); - }), - ), - ); - }, - }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT)); - const interactiveHeadResolver = headResolver.pipe( - RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW), - ); - const backgroundHeadResolver = headResolver.pipe( - RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT), - RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), - ); - - const listByHead = Effect.fn("GitHubCli.listByHead")(function* (input: { - readonly cwd: string; - readonly headSelector: string; - readonly state: PullRequestListState; - readonly limit: number; - readonly rateLimitHost?: string | undefined; - readonly allowReserve: boolean; - }) { - const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); - const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); - // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the - // owner is matched on the rows it returns. - const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); - const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); - const rows = yield* Effect.request( - new PullRequestsByHeadRead({ - cwd: input.cwd, - ...locator, - headRefName, - state: input.state, - limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, - allowReserve: input.allowReserve, - }), - input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver, - ); - if (!ownerMatch) return rows; - const headOwner = ownerMatch[1]!.toLowerCase(); - return rows - .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner) - .slice(0, limit); - }); - - const toSummaries = (rows: ReadonlyArray) => - rows.map(pullRequestSummary); - - const readPullRequest = Effect.fn("GitHubCli.readPullRequest")(function* (input: { - readonly cwd: string; - readonly reference: string; - readonly rateLimitHost?: string | undefined; - }) { - const parsed = parsePullRequestReference(input.reference); - if (parsed.kind === "branch") { - // `gh pr view ` prefers an open pull request, then the newest of any state. - const [open] = yield* listByHead({ - cwd: input.cwd, - headSelector: parsed.headSelector, - state: "open", - limit: 1, - rateLimitHost: input.rateLimitHost, - allowReserve: true, - }); - const found = - open ?? - (yield* listByHead({ - cwd: input.cwd, - headSelector: parsed.headSelector, - state: "all", - limit: 1, - rateLimitHost: input.rateLimitHost, - allowReserve: true, - }))[0]; - if (found === undefined) { - return yield* new GitHubPullRequestNotFoundError({ - command: "gh", - cwd: input.cwd, - cause: new Error("No pull request has this head branch."), - }); - } - return found; - } - const locator = - parsed.kind === "url" - ? parsed.locator - : yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); - const decodeFailure = (cause: unknown) => - new GitHubPullRequestDecodeError({ command: "gh", cwd: input.cwd, cause }); - const decoded = yield* graphqlJson( - input.cwd, - { - host: locator.host, - operation: "getPullRequest", - query: PULL_REQUEST_BY_NUMBER_QUERY, - variables: { owner: locator.owner, name: locator.name, number: parsed.number }, - allowReserve: true, - }, - decodePullRequestByNumber, - decodeFailure, - ); - const node = decoded.data.repository?.pullRequest; - if (node == null) { - return yield* new GitHubPullRequestNotFoundError({ - command: "gh", - cwd: input.cwd, - cause: new Error("The pull request does not exist."), - }); - } - const [record] = decodeGitHubPullRequestEntries([node]); - if (record === undefined) return yield* decodeFailure(new Error("Malformed pull request.")); - return record; - }); - - const readRepository = Effect.fn("GitHubCli.readRepository")(function* ( - cwd: string, - locator: GitHubRepositoryLocator, - ) { - const response = yield* rest(cwd, { - host: locator.host, - operation: "getRepository", - path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, - allowReserve: true, - }); - const decoded = decodeRawRepository(response.body); - if (Result.isFailure(decoded)) { - return yield* new GitHubRepositoryDecodeError({ - command: "gh", - cwd, - cause: decoded.failure, - }); - } - return decoded.success; - }); - - const readViewerLogin = Effect.fn("GitHubCli.readViewerLogin")(function* ( - cwd: string, - host: string, - ) { - const response = yield* rest(cwd, { host, operation: "getViewer", path: "user" }); - const decoded = decodeViewerLogin(response.body); - if (Result.isFailure(decoded)) { - return yield* new GitHubCliCommandError({ command: "gh", cwd, cause: decoded.failure }); - } - return decoded.success.login; - }); - - const gitFailure = (cwd: string) => (cause: unknown) => - new GitHubCliCommandError({ command: "gh", cwd, cause }); - - const runGit = (cwd: string, operation: string, args: ReadonlyArray) => - git.execute({ operation: `GitHubCli.checkoutPullRequest.${operation}`, cwd, args }); - - /** - * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a - * fork gets a remote of its own), checked out under the name gh would give it, and set to - * track the head. A head branch that is gone is read from the base's `refs/pull//head`. - * An existing branch fast-forwards, or with `force` is reset to the pull request. - */ - const checkoutPullRequest: GitHubCli["Service"]["checkoutPullRequest"] = Effect.fn( - "GitHubCli.checkoutPullRequest", - )(function* (input) { - const reference = parsePullRequestReference(input.reference); - const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); - const base = - reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd }); - const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); - const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; - const isCrossRepository = - pullRequest.isCrossRepository ?? - (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); - const headOwner = - pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; - - const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( - Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), - Effect.mapError(gitFailure(input.cwd)), - ); - const remoteFor = (nameWithOwner: string) => - remotes - .split("\n") - .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) - .find( - (match) => - match !== null && - normalizeGitRemoteUrl(match[2]!).split("/").slice(1).join("/") === - nameWithOwner.toLowerCase(), - )?.[1] ?? null; - const baseRemote = Effect.suspend(() => { - const known = remoteFor(baseNameWithOwner); - return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); - }); - // A fork's branch named like the base's default branch is checked out under the owner's - // prefix. Without the default branch that collision cannot be ruled out, and the checkout - // would reset the local default branch to the fork's commit, so it fails instead. - const defaultBranch = isCrossRepository - ? yield* readRepository(input.cwd, base).pipe( - Effect.map((repository) => repository.default_branch ?? null), - ) - : null; - const localBranch = pullRequestCheckoutBranchName({ - headRefName: pullRequest.headRefName, - headOwner, - isCrossRepository, - defaultBranch, - }); - - /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ - const headRemote = Effect.gen(function* () { - if (!isCrossRepository) return yield* baseRemote; - if (headNameWithOwner === null) return yield* commandFailure(input.cwd, "The fork is gone."); - const known = remoteFor(headNameWithOwner); - if (known !== null) return known; - const [owner, name] = headNameWithOwner.split("/"); - const fork = yield* readRepository(input.cwd, { - host: base.host, - owner: owner!, - name: name!, - }); - const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); - return yield* git.ensureRemote({ - cwd: input.cwd, - preferredName: headOwner ?? "fork", - url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url, - }); - }); - - const exists = (yield* git - .listLocalBranchNames(input.cwd) - .pipe(Effect.mapError(gitFailure(input.cwd)))).includes(localBranch); - - // The commit to check out, fetched from the head branch where it still exists. - const target = yield* Effect.gen(function* () { - const remoteName = yield* headRemote; - yield* git.fetchRemoteTrackingBranch({ - cwd: input.cwd, - remoteName, - remoteBranch: pullRequest.headRefName, - }); - return { - ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`, - upstream: { remoteName, remoteBranch: pullRequest.headRefName }, - }; - }).pipe( - Effect.catch(() => - Effect.gen(function* () { - yield* runGit(input.cwd, "fetchPullRef", [ - "fetch", - "--quiet", - "--no-tags", - yield* baseRemote, - `refs/pull/${pullRequest.number}/head`, - ]); - const { commitSha } = yield* git.resolveCommit({ - cwd: input.cwd, - revision: "FETCH_HEAD", - }); - return { ref: commitSha, upstream: null }; - }), - ), - Effect.mapError(gitFailure(input.cwd)), - ); - - yield* Effect.gen(function* () { - if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]); - yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); - if (exists) { - yield* runGit( - input.cwd, - "sync", - input.force === true - ? ["reset", "--hard", "--quiet", target.ref] - : ["merge", "--ff-only", "--quiet", target.ref], - ); - } - // Tracking is set once the branch is the pull request's, so a sync that fails leaves an - // existing branch's upstream as it was. - if (target.upstream !== null) { - yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); - } - }).pipe(Effect.mapError(gitFailure(input.cwd))); - }); - - return GitHubCli.of({ - listPullRequestsByHead: (input) => - AllowGitHubReserve.pipe( - Effect.flatMap((allowReserve) => listByHead({ ...input, allowReserve })), - ), - listOpenPullRequests: (input) => - listByHead({ - cwd: input.cwd, - headSelector: input.headSelector, - state: "open", - limit: input.limit ?? 1, - rateLimitHost: input.rateLimitHost, - allowReserve: true, - }).pipe(Effect.map(toSummaries)), - getPullRequest: (input) => readPullRequest(input).pipe(Effect.map(pullRequestSummary)), - getRepositoryCloneUrls: (input) => - Effect.gen(function* () { - const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe( - Effect.map((locator) => locator.host), - Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), - )).toLowerCase(); - const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); - if (locator === null) { - return yield* commandFailure(input.cwd, "Repositories are named owner/name."); - } - return repositoryCloneUrls(yield* readRepository(input.cwd, locator)); - }), - createRepository: (input) => - Effect.gen(function* () { - const locator = parseGitHubRepositorySelector( - input.repository, - (environment.GH_HOST ?? "github.com").toLowerCase(), - ); - const viewer = locator === null ? null : yield* readViewerLogin(input.cwd, locator.host); - const owner = locator?.owner ?? viewer; - const name = locator?.name ?? input.repository.trim(); - const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); - const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); - const response = yield* rest(input.cwd, { - host, - operation: "createRepository", - method: "POST", - path: - isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, - body: { name, private: input.visibility === "private" }, - }); - const decoded = decodeRawRepository(response.body); - if (Result.isFailure(decoded)) { - return yield* new GitHubRepositoryDecodeError({ - command: "gh", - cwd: input.cwd, - cause: decoded.failure, - }); - } - return repositoryCloneUrls(decoded.success); - }), - createPullRequest: (input) => - Effect.gen(function* () { - const locator = yield* resolveRepository({ cwd: input.cwd }); - const body = yield* fileSystem - .readFileString(input.bodyFile) - .pipe(Effect.mapError(gitFailure(input.cwd))); - yield* rest(input.cwd, { - host: locator.host, - operation: "createPullRequest", - method: "POST", - path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, - // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. - // gh allows maintainer edits unless told otherwise; the API's default is not documented. - body: { - base: input.baseBranch, - head: input.headSelector, - title: input.title, - body, - maintainer_can_modify: true, - }, - }); - }), - getDefaultBranch: (input) => - Effect.gen(function* () { - const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); - const repository = yield* readRepository(input.cwd, locator); - const branch = repository.default_branch?.trim() ?? ""; - return branch.length > 0 ? branch : null; - }), - checkoutPullRequest, - }); -}); - -export const layer = Layer.effect(GitHubCli, make).pipe( - Layer.provideMerge(GitHubApi.layer), - Layer.provideMerge(GitHubCredentials.layer), - Layer.provideMerge(GitHubGraphQlBudget.layer), - Layer.provideMerge(SourceControlRateLimit.layer), -); diff --git a/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts b/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..688216211f1fd15e22c2466fdcd13a3c27f5755f --- /dev/null +++ b/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts @@ -0,0 +1,651 @@ +import { assert, it, describe } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as TestClock from "effect/testing/TestClock"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/process"; + +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubApi from "./GitHubApi.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; +import * as GitHubCredentials from "./GitHubCredentials.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(exitCode), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const remotesOutput = (...entries: ReadonlyArray) => + entries + .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join("\n"); + +const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({ + status, + headers: {}, + body: body === undefined ? "" : encodeJson(body), + truncated: false, + invalidUtf8: false, +}); + +const node = (number: number, headRefName: string, owner = "acme") => ({ + number, + title: `PR ${number}`, + url: `https://github.com/acme/web/pull/${number}`, + baseRefName: "main", + headRefName, + state: "OPEN", + isCrossRepository: owner !== "acme", + updatedAt: "2026-01-02T00:00:00Z", + headRepository: { name: "web", nameWithOwner: `${owner}/web` }, + headRepositoryOwner: { login: owner }, +}); + +/** + * A GitHubRepositoryApi over a mocked GitHubApi, git driver and process. `remotes` is what + * `git remote -v` prints; `git` records every driver call. + */ +function harness(input: { + readonly remotes: string; + readonly api: Partial; + readonly localBranches?: ReadonlyArray; +}) { + const git: Array = []; + const record = + (name: string, value: A) => + (args: unknown) => + Effect.sync(() => { + git.push([name, args]); + return value; + }); + const driver = Layer.mock(GitVcsDriver.GitVcsDriver)({ + execute: (args) => + Effect.sync(() => { + git.push(["execute", args.args]); + return processOutput(""); + }), + resolvePrimaryRemoteName: () => Effect.succeed("origin"), + readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), + ensureRemote: (args) => + Effect.sync(() => { + git.push(["ensureRemote", args]); + return args.preferredName; + }), + fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args), + setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args), + switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never, + listLocalBranchNames: () => Effect.succeed([...(input.localBranches ?? [])]), + resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), + }); + const process = Layer.mock(VcsProcess.VcsProcess)({ + run: (args) => + Effect.succeed( + args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), + ), + }); + const layer = Layer.effect( + GitHubRepositoryApi.GitHubRepositoryApi, + GitHubRepositoryApi.make, + ).pipe( + Layer.provide( + Layer.mergeAll( + driver, + process, + Layer.mock(GitHubApi.GitHubApi)(input.api), + NodeServices.layer, + ), + ), + ); + return { layer, git }; +} + +describe("selectGitHubBaseRepository", () => { + const remotes = (...entries: ReadonlyArray) => + entries + .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join("\n"); + const select = (input: { remotes: string; resolved?: string }) => + GitHubRepositoryApi.selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); + + it("picks the repository gh reads without a prompt", () => { + assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { + owner: "me", + name: "web", + }); + const fork = remotes( + ["origin", "git@github.com:me/web.git"], + ["upstream", "https://github.com/Acme/Web.git"], + ); + assert.deepStrictEqual(select({ remotes: fork }), { owner: "acme", name: "web" }); + assert.deepStrictEqual(select({ remotes: fork, resolved: "remote.origin.gh-resolved base" }), { + owner: "me", + name: "web", + }); + assert.deepStrictEqual( + select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), + { owner: "acme", name: "other" }, + ); + // gh ranks remote names in any case. + assert.deepStrictEqual( + select({ + remotes: remotes( + ["origin", "git@github.com:me/web.git"], + ["Upstream", "git@github.com:acme/web.git"], + ), + }), + { owner: "acme", name: "web" }, + ); + }); + + it("leaves gh to choose when it might weigh the remotes differently", () => { + for (const input of [ + { remotes: "" }, + { + remotes: remotes(["a", "git@github.com:me/web.git"], ["b", "git@github.com:acme/web.git"]), + }, + { + remotes: remotes( + ["origin", "git@github.com:me/web.git"], + ["mirror", "git@gitlab.com:me/web.git"], + ), + }, + { remotes: remotes(["origin", "git@github-work:me/web.git"]) }, + { + remotes: remotes( + ["origin", "git@github.com:me/web.git"], + ["Origin", "git@github.com:acme/web.git"], + ), + }, + { + remotes: remotes( + ["origin", "git@github.com:me/web.git"], + ["upstream", "git@github.com:acme/web.git"], + ), + resolved: "remote.origin.gh-resolved base\nremote.upstream.gh-resolved base", + }, + ]) { + assert.strictEqual(select(input), null); + } + }); +}); + +describe("GitHubRepositoryApi repository resolution", () => { + it.effect("reads the repository gh would pick from the remotes", () => { + const paths: string[] = []; + const { layer } = harness({ + remotes: remotesOutput( + ["origin", "git@github.com:me/web.git"], + ["upstream", "https://github.com/acme/web.git"], + ), + api: { + rest: (input) => + Effect.sync(() => { + paths.push(`${input.host} ${input.path}`); + return restResponse({ + full_name: "acme/web", + html_url: "https://github.com/acme/web", + ssh_url: "git@github.com:acme/web.git", + default_branch: "trunk", + }); + }), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; + assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); + assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("reads an SSH alias remote through github.com", () => { + const hosts: string[] = []; + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github:acme/web.git"]), + api: { + rest: (input) => + Effect.sync(() => { + hosts.push(`${input.host} ${input.path}`); + return restResponse({ + full_name: "acme/web", + html_url: "https://github.com/acme/web", + ssh_url: "git@github.com:acme/web.git", + default_branch: "main", + }); + }), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; + yield* gh.getDefaultBranch({ cwd: "/repo" }); + // A provider's host hint for the same alias (`github` here) resolves the same way. + yield* gh.getDefaultBranch({ cwd: "/repo", rateLimitHost: "github" }); + assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); + assert.strictEqual( + GitHubRepositoryApi.gitHubApiHostForRemote("git@github.example.com:a/b.git"), + "github.example.com", + ); + assert.strictEqual( + GitHubRepositoryApi.gitHubApiHostForRemote("git@gitlab.com:a/b.git"), + null, + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails clearly when no remote is on GitHub", () => { + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]), + api: {}, + }); + return Effect.gen(function* () { + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; + const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); + assert.strictEqual(error._tag, "GitHubRepositoryCommandError"); + assert.include(String((error.cause as Error).message), "No GitHub repository"); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubRepositoryApi.listPullRequestsByHead", () => { + const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); + + it.effect("reads a background sweep's staggered heads in one GraphQL document", () => { + const documents: Array = []; + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.sync(() => { + documents.push(input); + return encodeJson({ + data: { + repository: { + h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] }, + h1: { nodes: [] }, + }, + }, + }); + }), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; + const lookup = (headSelector: string) => + gh + .listPullRequestsByHead({ + cwd: "/repo", + headSelector, + state: "all", + limit: 100, + rateLimitHost: "github.com", + }) + .pipe(Effect.forkChild); + // Each branch's own git reads come first, so a sweep's lookups arrive spread out. + const firstLookup = yield* lookup("feature/a"); + yield* TestClock.adjust("200 millis"); + const secondLookup = yield* lookup("feature/b"); + yield* TestClock.adjust("300 millis"); + const first = yield* Fiber.join(firstLookup); + const second = yield* Fiber.join(secondLookup); + assert.deepStrictEqual( + first?.map((pr) => pr.number), + [7], + ); + assert.strictEqual(first?.[0]?.headSha, "a".repeat(40)); + assert.deepStrictEqual(second, []); + assert.strictEqual(documents.length, 1); + assert.include(documents[0]!.query, "headRefOid"); + assert.deepStrictEqual(documents[0]!.variables, { + owner: "acme", + name: "web", + h0: "feature/a", + s0: ["OPEN", "CLOSED", "MERGED"], + h1: "feature/b", + s1: ["OPEN", "CLOSED", "MERGED"], + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("caps a background document at twenty-five heads", () => { + const headCounts: Array = []; + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.sync(() => { + const heads = Object.keys(input.variables ?? {}).filter((key) => /^h\d+$/.test(key)); + headCounts.push(heads.length); + return encodeJson({ + data: { repository: Object.fromEntries(heads.map((key) => [key, { nodes: [] }])) }, + }); + }), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; + const lookups = yield* Effect.all( + Array.from({ length: 26 }, (_, index) => + gh.listPullRequestsByHead({ + cwd: "/repo", + headSelector: `feature/${index}`, + state: "all", + limit: 100, + rateLimitHost: "github.com", + }), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* TestClock.adjust("500 millis"); + yield* Fiber.join(lookups); + assert.deepStrictEqual( + headCounts.toSorted((a, b) => a - b), + [1, 25], + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect("matches an owner:branch selector on the head owner", () => { + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.succeed( + encodeJson({ + data: { + repository: { + h0: { + nodes: + input.variables?.h0 === "main" + ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")] + : [], + }, + }, + }, + }), + ), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; + const open = yield* gh + .listOpenPullRequests({ cwd: "/repo", headSelector: "me:main", limit: 1 }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("50 millis"); + assert.deepStrictEqual( + (yield* Fiber.join(open)).map((pr) => pr.number), + [8], + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect("maps API failures onto the errors callers handle", () => { + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.fail( + input.variables?.h0 === "missing" + ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" }) + : new GitHubApi.GitHubApiRateLimitError({ + host: "github.com", + operation: "x", + retryAt: 123, + }), + ), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; + const read = (headSelector: string) => + gh + .listPullRequestsByHead({ cwd: "/repo", headSelector, state: "open", limit: 1 }) + .pipe(Effect.flip, Effect.forkChild); + const missing = yield* read("missing"); + yield* TestClock.adjust("500 millis"); + assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubRepositoryUnavailableError"); + const limited = yield* read("limited"); + yield* TestClock.adjust("500 millis"); + const error = yield* Fiber.join(limited); + assert.strictEqual(error._tag, "GitHubRepositoryRateLimitError"); + assert.propertyVal(error, "retryAt", 123); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubRepositoryApi.getPullRequest", () => { + it.effect("reads a pull request by number, and by URL on its own repository", () => { + const variables: Array = []; + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + api: { + graphql: (input) => + Effect.sync(() => { + variables.push(input.variables); + return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } }); + }), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; + assert.strictEqual((yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" })).number, 42); + yield* gh.getPullRequest({ + cwd: "/repo", + reference: "https://github.com/other/thing/pull/42", + }); + assert.deepStrictEqual(variables, [ + { owner: "acme", name: "web", number: 42 }, + { owner: "other", name: "thing", number: 42 }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails a missing pull request as not found", () => { + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + api: { + graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; + const error = yield* gh.getPullRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); + assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubRepositoryApi writes", () => { + it.effect("creates a cross-repository pull request with an owner:branch head", () => { + const requests: Array = []; + const { layer } = harness({ + remotes: remotesOutput( + ["origin", "git@github.com:me/web.git"], + ["upstream", "git@github.com:acme/web.git"], + ), + api: { + rest: (input) => + Effect.sync(() => { + requests.push(input); + return restResponse({ number: 1 }, 201); + }), + }, + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); + yield* fs.writeFileString(bodyFile, "Body"); + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; + yield* gh.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "me:feature", + title: "Title", + bodyFile, + }); + assert.strictEqual(requests[0]!.method, "POST"); + assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); + assert.deepStrictEqual(requests[0]!.body, { + base: "main", + head: "me:feature", + title: "Title", + body: "Body", + maintainer_can_modify: true, + }); + }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped); + }); + + it.effect("creates a repository under an organization the viewer is not", () => { + const requests: Array = []; + const { layer } = harness({ + remotes: "", + api: { + rest: (input) => + Effect.sync(() => { + requests.push(`${input.method ?? "GET"} ${input.path}`); + return input.path === "user" + ? restResponse({ login: "me" }) + : restResponse({ + full_name: "acme/new", + html_url: "https://github.com/acme/new", + ssh_url: "git@github.com:acme/new.git", + }); + }), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; + const urls = yield* gh.createRepository({ + cwd: "/repo", + repository: "acme/new", + visibility: "private", + }); + assert.deepStrictEqual(urls, { + nameWithOwner: "acme/new", + url: "https://github.com/acme/new", + sshUrl: "git@github.com:acme/new.git", + }); + assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubRepositoryApi.checkoutPullRequest", () => { + const repository = (fullName: string, defaultBranch = "main") => + restResponse({ + full_name: fullName, + html_url: `https://github.com/${fullName}`, + ssh_url: `git@github.com:${fullName}.git`, + default_branch: defaultBranch, + }); + + it.effect("checks a same-repository pull request out from its head branch", () => { + const { layer, git } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), + ), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; + yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "5" }); + assert.deepStrictEqual(git, [ + [ + "fetchRemoteTrackingBranch", + { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" }, + ], + ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]], + ["switchRef", { cwd: "/repo", refName: "feature/x" }], + [ + "setBranchUpstream", + { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" }, + ], + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("refuses a fork checkout when the base's default branch cannot be read", () => { + const { layer, git } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + localBranches: ["main"], + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), + ), + rest: () => + Effect.fail( + new GitHubApi.GitHubApiRequestError({ + host: "github.com", + operation: "x", + cause: "offline", + }), + ), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; + yield* Effect.flip(gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true })); + // Nothing touched the local branches: `main` must not be reset to the fork's commit. + assert.deepStrictEqual(git, []); + }).pipe(Effect.provide(layer)); + }); + + it.effect("adds a remote for a fork and names a default-branch head after its owner", () => { + const { layer, git } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + localBranches: ["someone/main"], + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), + ), + rest: (input) => + Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; + yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true }); + assert.deepStrictEqual(git, [ + [ + "ensureRemote", + { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" }, + ], + [ + "fetchRemoteTrackingBranch", + { cwd: "/repo", remoteName: "someone", remoteBranch: "main" }, + ], + ["switchRef", { cwd: "/repo", refName: "someone/main" }], + ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]], + [ + "setBranchUpstream", + { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" }, + ], + ]); + }).pipe(Effect.provide(layer)); + }); + + it("names the local branch the way gh pr checkout does", () => { + const name = (headRefName: string, isCrossRepository: boolean) => + GitHubRepositoryApi.pullRequestCheckoutBranchName({ + headRefName, + headOwner: "someone", + isCrossRepository, + defaultBranch: "main", + }); + assert.strictEqual(name("main", true), "someone/main"); + assert.strictEqual(name("feature", true), "feature"); + assert.strictEqual(name("main", false), "main"); + }); +}); diff --git a/apps/server/src/sourceControl/GitHubRepositoryApi.ts b/apps/server/src/sourceControl/GitHubRepositoryApi.ts new file mode 100644 index 0000000000000000000000000000000000000000..595d869d72275071fe7078ea1193387b33941007 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubRepositoryApi.ts @@ -0,0 +1,1086 @@ +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Request from "effect/Request"; +import * as RequestResolver from "effect/RequestResolver"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { + detectSourceControlProviderFromRemoteUrl, + isSshRemoteUrl, +} from "@t3tools/shared/sourceControl"; + +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubApi from "./GitHubApi.ts"; +import * as GitHubCredentials from "./GitHubCredentials.ts"; +import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; +import { + decodeGitHubPullRequestEntries, + type NormalizedGitHubPullRequestRecord, +} from "./gitHubPullRequests.ts"; + +export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve; + +const gitHubFailureFields = { + cwd: Schema.String, + cause: Schema.Defect(), +} as const; + +export class GitHubRepositoryUnavailableError extends Schema.TaggedError()( + "GitHubRepositoryUnavailableError", + gitHubFailureFields, +) { + override get message(): string { + return "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`."; + } +} + +export class GitHubRepositoryAuthenticationError extends Schema.TaggedError()( + "GitHubRepositoryAuthenticationError", + gitHubFailureFields, +) { + override get message(): string { + // A missing or turned-off credential already says what to do about it. + return GitHubCredentials.isGitHubCredentialUnavailableError(this.cause) + ? this.cause.message + : "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry."; + } +} + +export class GitHubRepositoryRateLimitError extends Schema.TaggedError()( + "GitHubRepositoryRateLimitError", + { ...gitHubFailureFields, retryAt: Schema.optionalKey(Schema.Finite) }, +) { + override get message(): string { + return "GitHub API rate limit exceeded. Requests resume when the limit resets."; + } +} + +export class GitHubPullRequestNotFoundError extends Schema.TaggedError()( + "GitHubPullRequestNotFoundError", + gitHubFailureFields, +) { + override get message(): string { + return "Pull request not found. Check the PR number or URL and try again."; + } +} + +export class GitHubRepositoryCommandError extends Schema.TaggedError()( + "GitHubRepositoryCommandError", + { ...gitHubFailureFields, httpStatus: Schema.optional(Schema.Int) }, +) { + override get message(): string { + // GitHub's own reason ("A pull request already exists…") or the failed step's, when known. + const reason = + this.cause instanceof Error && this.cause.message.trim() !== "" + ? this.cause.message.trim() + : null; + return reason === null ? "GitHub request failed." : reason; + } +} + +const gitHubDecodeFields = { + cwd: Schema.String, + cause: Schema.Defect(), +} as const; + +export class GitHubPullRequestListDecodeError extends Schema.TaggedError()( + "GitHubPullRequestListDecodeError", + gitHubDecodeFields, +) { + override get message(): string { + return "GitHub returned an invalid pull request list."; + } +} + +export class GitHubChangeRequestListDecodeError extends Schema.TaggedError()( + "GitHubChangeRequestListDecodeError", + gitHubDecodeFields, +) { + override get message(): string { + return "GitHub returned an invalid change request list."; + } +} + +export class GitHubPullRequestDecodeError extends Schema.TaggedError()( + "GitHubPullRequestDecodeError", + gitHubDecodeFields, +) { + override get message(): string { + return "GitHub returned an invalid pull request."; + } +} + +export class GitHubRepositoryDecodeError extends Schema.TaggedError()( + "GitHubRepositoryDecodeError", + gitHubDecodeFields, +) { + override get message(): string { + return "GitHub returned an invalid repository."; + } +} + +export const GitHubRepositoryApiError = Schema.Union([ + GitHubRepositoryUnavailableError, + GitHubRepositoryAuthenticationError, + GitHubRepositoryRateLimitError, + GitHubPullRequestNotFoundError, + GitHubRepositoryCommandError, + GitHubPullRequestListDecodeError, + GitHubChangeRequestListDecodeError, + GitHubPullRequestDecodeError, + GitHubRepositoryDecodeError, +]); +export type GitHubRepositoryApiError = typeof GitHubRepositoryApiError.Type; + +export const isGitHubRepositoryApiError = Schema.is(GitHubRepositoryApiError); + +/** Maps a GitHub API failure onto the errors callers of this service already handle. */ +function fromGitHubApiError( + cwd: string, + error: GitHubApi.GitHubApiError, +): GitHubRepositoryApiError { + const context = { cwd, cause: error }; + switch (error._tag) { + case "GitHubCliMissingError": + return new GitHubRepositoryUnavailableError(context); + case "GitHubNotSignedInError": + case "GitHubHostDisabledError": + case "GitHubApiAuthenticationError": + return new GitHubRepositoryAuthenticationError(context); + case "GitHubCliFailedError": + return new GitHubRepositoryCommandError(context); + case "GitHubApiRateLimitError": + return new GitHubRepositoryRateLimitError({ + ...context, + ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), + }); + case "SourceControlRateLimitPausedError": + return new GitHubRepositoryRateLimitError({ ...context, retryAt: error.retryAt }); + case "GitHubApiNotFoundError": + return new GitHubPullRequestNotFoundError(context); + case "GitHubApiResponseError": + return new GitHubRepositoryCommandError({ ...context, httpStatus: error.status }); + case "GitHubApiRequestError": + return new GitHubRepositoryCommandError(context); + } +} + +export interface GitHubPullRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly headSha?: string; + readonly state?: "open" | "closed" | "merged"; + readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + readonly updatedAt?: string; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +function pullRequestSummary(input: NormalizedGitHubPullRequestRecord): GitHubPullRequestSummary { + const { updatedAt, ...summary } = input; + return { + ...summary, + ...(Option.isSome(updatedAt) ? { updatedAt: DateTime.formatIso(updatedAt.value) } : {}), + }; +} + +export interface GitHubRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; +} + +export class GitHubRepositoryApi extends Context.Service< + GitHubRepositoryApi, + { + readonly listOpenPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly limit?: number; + readonly rateLimitHost?: string; + }) => Effect.Effect, GitHubRepositoryApiError>; + + /** + * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the + * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive + * together share one GraphQL document. + */ + readonly listPullRequestsByHead: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit: number; + /** The checkout's GitHub API host. Without it, the host comes from the git remotes. */ + readonly rateLimitHost?: string; + }) => Effect.Effect, GitHubRepositoryApiError>; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly rateLimitHost?: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + readonly rateLimitHost?: string; + }) => Effect.Effect; + + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; + } +>()("t3/sourceControl/GitHubRepositoryApi") {} + +/** + * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a + * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin + * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of + * `git config --get-regexp '^remote\..*\.gh-resolved$'`. + * + * Null whenever gh might weigh the remotes differently: a remote on another host or under an + * SSH alias, more than one mark, or several remotes with none of those names. Callers then + * fall back to the provider's remote, then to the best-ranked remote on the host. + */ +export function selectGitHubBaseRepository(input: { + readonly remotes: string; + readonly resolved: string; + readonly host: string; +}): { readonly owner: string; readonly name: string } | null { + const host = input.host.toLowerCase(); + const repositories = new Map(); + for (const line of input.remotes.split("\n")) { + const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()); + if (!match) continue; + const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/"); + if (remoteHost !== host || !owner || !name || rest.length > 0) return null; + repositories.set(match[1]!, { owner, name }); + } + const marks = input.resolved + .split("\n") + .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) + .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); + if (marks.length > 1) return null; + const [mark] = marks; + if (mark) { + if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; + const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); + return owner && name && rest.length === 0 ? { owner, name } : null; + } + // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the + // top place has no defined winner. + const score = (remoteName: string) => + ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; + const ranked = [...repositories.entries()].toSorted( + ([left], [right]) => score(right) - score(left), + ); + const [top, next] = ranked; + return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) + ? top[1] + : null; +} + +const RawRepositorySchema = Schema.Struct({ + full_name: TrimmedNonEmptyString, + html_url: TrimmedNonEmptyString, + ssh_url: TrimmedNonEmptyString, + default_branch: Schema.optional(Schema.NullOr(Schema.String)), +}); +const decodeRawRepository = decodeJsonResult(RawRepositorySchema); + +function repositoryCloneUrls( + raw: Schema.Schema.Type, +): GitHubRepositoryCloneUrls { + return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url }; +} + +const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString })); + +type PullRequestListState = "open" | "closed" | "merged" | "all"; + +/** The pull request fields every read selects, in GraphQL. */ +const PULL_REQUEST_NODE_SELECTION = + "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; +const GRAPHQL_STATES: Record> = { + open: ["OPEN"], + closed: ["CLOSED"], + merged: ["MERGED"], + all: ["OPEN", "CLOSED", "MERGED"], +}; +/** + * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one + * single lookup, but half that keeps each answer near half a second. + */ +const HEAD_LOOKUPS_PER_DOCUMENT = 50; +/** + * How long a head lookup waits for company. Branch discovery reaches GitHub only after each + * branch's own git reads, so lookups started together arrive tens of milliseconds apart. + * A background sweep's lookups spread over up to ~300ms, and every document costs a point no + * matter how few heads it holds, so reads without reserve wait longer. + */ +const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; +const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; +/** + * Background documents fill up under the longer window, and a failed document fails every head + * in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own + * processing limit; twenty-five took ~7s. + */ +const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25; +/** A full document is 5,000 rows of well under 2 KB each. */ +const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; +/** + * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch + * named like a busy default (`main`) is the case this bounds; the owner's own row is near the + * top because the newest come first. + */ +const OWNER_HEAD_SCAN_LIMIT = 100; + +class PullRequestsByHeadRead extends Request.Class< + { + readonly cwd: string; + readonly host: string; + readonly owner: string; + readonly name: string; + readonly headRefName: string; + readonly state: PullRequestListState; + readonly limit: number; + readonly allowReserve: boolean; + }, + ReadonlyArray, + GitHubRepositoryApiError +> {} + +/** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ +function buildPullRequestsByHeadQuery( + lookups: ReadonlyArray>, +): { readonly document: string; readonly variables: Record } { + const variables: Record = {}; + const declarations: string[] = ["$owner: String!", "$name: String!"]; + const selections: string[] = []; + for (const [index, lookup] of lookups.entries()) { + variables[`h${index}`] = lookup.headRefName; + variables[`s${index}`] = GRAPHQL_STATES[lookup.state]; + declarations.push(`$h${index}: String!`, `$s${index}: [PullRequestState!]`); + // `gh pr list` orders the same way, so a head with more matches than the limit keeps the + // same rows. + selections.push( + ` h${index}: pullRequests(headRefName: $h${index}, states: $s${index}, first: ${lookup.limit}, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${PULL_REQUEST_NODE_SELECTION} } }`, + ); + } + return { + document: `query PullRequestsByHead(${declarations.join(", ")}) {\n repository(owner: $owner, name: $name) {\n${selections.join("\n")}\n }\n}`, + variables, + }; +} + +const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} } + } +}`; + +const decodePullRequestsByHead = decodeJsonResult( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), + ), + ), + }), + }), +); + +const decodePullRequestByNumber = decodeJsonResult( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })), + }), + }), +); + +/** A repository on a GitHub host: the API it is read through, and its owner and name. */ +export interface GitHubRepositoryLocator { + readonly host: string; + readonly owner: string; + readonly name: string; +} + +/** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ +function parseGitHubRepositorySelector( + selector: string, + defaultHost: string, +): GitHubRepositoryLocator | null { + const trimmed = selector.trim().replace(/\.git$/i, ""); + if (/^https?:\/\//i.test(trimmed)) { + try { + const url = new URL(trimmed); + const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean); + return owner && name && rest.length === 0 + ? { host: url.host.toLowerCase(), owner, name } + : null; + } catch { + return null; + } + } + const parts = trimmed.split("/").filter(Boolean); + if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; + if (parts.length === 3) + return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! }; + return null; +} + +/** + * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request + * URL, or a branch name. + */ +function parsePullRequestReference( + reference: string, +): + | { readonly kind: "number"; readonly number: number } + | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number } + | { readonly kind: "branch"; readonly headSelector: string } { + const trimmed = reference.trim(); + const numbered = /^#?([1-9]\d*)$/.exec(trimmed); + if (numbered) return { kind: "number", number: Number(numbered[1]) }; + if (/^https?:\/\//i.test(trimmed)) { + try { + const url = new URL(trimmed); + const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname); + if (match) { + return { + kind: "url", + locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! }, + number: Number(match[3]), + }; + } + } catch { + // Not a URL after all; read it as a branch. + } + } + return { kind: "branch", headSelector: trimmed }; +} + +/** + * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH + * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through + * `github.com`, which is what such an alias almost always stands for (issue #6198). + */ +export function gitHubApiHostForRemote(remoteUrl: string): string | null { + const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); + if (provider === null) return null; + const host = new URL(provider.baseUrl).host.toLowerCase(); + // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. + if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { + return host.includes("github") ? "github.com" : null; + } + return provider.kind === "github" ? host : null; +} + +/** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ +function apiHostForHint(host: string): string { + const normalized = host.toLowerCase(); + return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; +} + +/** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ +export function pullRequestCheckoutBranchName(input: { + readonly headRefName: string; + readonly headOwner: string | null; + readonly isCrossRepository: boolean; + readonly defaultBranch: string | null; +}): string { + // gh prefixes the owner only where the fork's branch would take over the default branch's + // name, which is the one collision every fork pull request from `main` would hit. + return input.isCrossRepository && + input.headOwner !== null && + input.defaultBranch !== null && + input.headRefName === input.defaultBranch + ? `${input.headOwner}/${input.headRefName}` + : input.headRefName; +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const environment = yield* HostProcessEnvironment; + const api = yield* GitHubApi.GitHubApi; + const git = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + + const gitRead = (cwd: string, args: ReadonlyArray) => + process.run({ + operation: "GitHubRepositoryApi.resolveRepository", + command: "git", + args, + cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + }); + + const commandFailure = (cwd: string, detail: string) => + new GitHubRepositoryCommandError({ cwd, cause: new Error(detail) }); + + /** + * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else + * the remote the caller resolved the provider from, else the best-ranked GitHub remote. + */ + const resolveRepository = Effect.fn("GitHubRepositoryApi.resolveRepository")(function* (input: { + readonly cwd: string; + readonly host?: string | undefined; + }) { + const envRepository = environment.GH_REPO?.trim(); + const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase(); + if (envRepository) { + const locator = parseGitHubRepositorySelector(envRepository, defaultHost); + if (locator !== null) return locator; + } + const [remotes, resolved] = yield* Effect.all([ + gitRead(input.cwd, ["remote", "-v"]), + gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), + ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); + const remoteOutput = remotes?.exitCode === 0 ? remotes.stdout : ""; + const fetchRemotes = remoteOutput + .split("\n") + .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) + .filter((match): match is RegExpExecArray => match !== null) + .map((match) => ({ + name: match[1]!, + url: match[2]!, + host: gitHubApiHostForRemote(match[2]!), + })); + const host = + (input.host === undefined ? undefined : apiHostForHint(input.host)) ?? + fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? + fetchRemotes.find((remote) => remote.host !== null)?.host ?? + defaultHost; + const selected = selectGitHubBaseRepository({ + remotes: remoteOutput, + resolved: resolved?.exitCode === 0 ? resolved.stdout : "", + host, + }); + if (selected !== null) return { host, ...selected }; + // gh's own order without its prompt: upstream, github, origin, then the first remote, among + // the ones on this host (an SSH alias counts as its API host). + const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); + const candidates = fetchRemotes + .filter((remote) => remote.host === host) + .toSorted((left, right) => { + const l = rank(left.name); + const r = rank(right.name); + return (l === -1 ? 99 : l) - (r === -1 ? 99 : r); + }); + for (const remote of candidates) { + const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1); + if (owner && name && rest.length === 0) return { host, owner, name }; + } + return yield* commandFailure( + input.cwd, + `No GitHub repository on ${host} was found among this checkout's git remotes.`, + ); + }); + + const graphqlJson = ( + cwd: string, + input: GitHubApi.GitHubGraphQlInput, + decode: (raw: string) => Result.Result, + onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError, + ) => + api.graphql(input).pipe( + Effect.mapError((error) => fromGitHubApiError(cwd, error)), + Effect.flatMap((raw) => { + const decoded = decode(raw); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(onDecodeFailure(decoded.failure)); + }), + ); + + const rest = (cwd: string, input: GitHubApi.GitHubRestInput) => + api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(cwd, error))); + + /** Pull requests whose head branch is `headRefName` on one repository. */ + const readPullRequestsByHead = (input: { + readonly cwd: string; + readonly locator: GitHubRepositoryLocator; + readonly lookups: ReadonlyArray< + Pick + >; + readonly allowReserve: boolean; + readonly onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError; + }) => { + const query = buildPullRequestsByHeadQuery(input.lookups); + return graphqlJson( + input.cwd, + { + host: input.locator.host, + operation: "listPullRequestsByHead", + query: query.document, + variables: { owner: input.locator.owner, name: input.locator.name, ...query.variables }, + allowReserve: input.allowReserve, + // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred + // fork pull requests, so the usual cap would cut the answer short. + maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, + }, + decodePullRequestsByHead, + input.onDecodeFailure, + ).pipe( + Effect.flatMap((decoded) => + decoded.data.repository === null + ? Effect.fail( + new GitHubPullRequestNotFoundError({ + cwd: input.cwd, + cause: new Error("The repository could not be read."), + }), + ) + : Effect.succeed(decoded.data.repository), + ), + ); + }; + + const headResolver = RequestResolver.makeGrouped({ + key: ({ request, context }) => + [ + request.host, + request.owner, + request.name, + String(request.allowReserve), + Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) + ?.credentialFingerprint ?? "", + Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), + ].join("\0"), + resolver: (entries) => { + const [first] = entries; + const { cwd, host, owner, name, allowReserve } = first.request; + return readPullRequestsByHead({ + cwd, + locator: { host, owner, name }, + lookups: entries.map((entry) => entry.request), + allowReserve, + onDecodeFailure: (cause) => new GitHubChangeRequestListDecodeError({ cwd, cause }), + }).pipe( + Effect.map((aliases) => { + for (const [index, entry] of entries.entries()) { + const alias = aliases[`h${index}`]; + entry.completeUnsafe( + Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), + ); + } + }), + Effect.catchCause((cause) => + Effect.sync(() => { + for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + }), + ), + ); + }, + }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT)); + const interactiveHeadResolver = headResolver.pipe( + RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW), + ); + const backgroundHeadResolver = headResolver.pipe( + RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT), + RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), + ); + + const listByHead = Effect.fn("GitHubRepositoryApi.listByHead")(function* (input: { + readonly cwd: string; + readonly headSelector: string; + readonly state: PullRequestListState; + readonly limit: number; + readonly rateLimitHost?: string | undefined; + readonly allowReserve: boolean; + }) { + const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); + const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); + // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the + // owner is matched on the rows it returns. + const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); + const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); + const rows = yield* Effect.request( + new PullRequestsByHeadRead({ + cwd: input.cwd, + ...locator, + headRefName, + state: input.state, + limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, + allowReserve: input.allowReserve, + }), + input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver, + ); + if (!ownerMatch) return rows; + const headOwner = ownerMatch[1]!.toLowerCase(); + return rows + .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner) + .slice(0, limit); + }); + + const toSummaries = (rows: ReadonlyArray) => + rows.map(pullRequestSummary); + + const readPullRequest = Effect.fn("GitHubRepositoryApi.readPullRequest")(function* (input: { + readonly cwd: string; + readonly reference: string; + readonly rateLimitHost?: string | undefined; + }) { + const parsed = parsePullRequestReference(input.reference); + if (parsed.kind === "branch") { + // `gh pr view ` prefers an open pull request, then the newest of any state. + const [open] = yield* listByHead({ + cwd: input.cwd, + headSelector: parsed.headSelector, + state: "open", + limit: 1, + rateLimitHost: input.rateLimitHost, + allowReserve: true, + }); + const found = + open ?? + (yield* listByHead({ + cwd: input.cwd, + headSelector: parsed.headSelector, + state: "all", + limit: 1, + rateLimitHost: input.rateLimitHost, + allowReserve: true, + }))[0]; + if (found === undefined) { + return yield* new GitHubPullRequestNotFoundError({ + cwd: input.cwd, + cause: new Error("No pull request has this head branch."), + }); + } + return found; + } + const locator = + parsed.kind === "url" + ? parsed.locator + : yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); + const decodeFailure = (cause: unknown) => + new GitHubPullRequestDecodeError({ cwd: input.cwd, cause }); + const decoded = yield* graphqlJson( + input.cwd, + { + host: locator.host, + operation: "getPullRequest", + query: PULL_REQUEST_BY_NUMBER_QUERY, + variables: { owner: locator.owner, name: locator.name, number: parsed.number }, + allowReserve: true, + }, + decodePullRequestByNumber, + decodeFailure, + ); + const node = decoded.data.repository?.pullRequest; + if (node == null) { + return yield* new GitHubPullRequestNotFoundError({ + cwd: input.cwd, + cause: new Error("The pull request does not exist."), + }); + } + const [record] = decodeGitHubPullRequestEntries([node]); + if (record === undefined) return yield* decodeFailure(new Error("Malformed pull request.")); + return record; + }); + + const readRepository = Effect.fn("GitHubRepositoryApi.readRepository")(function* ( + cwd: string, + locator: GitHubRepositoryLocator, + ) { + const response = yield* rest(cwd, { + host: locator.host, + operation: "getRepository", + path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, + allowReserve: true, + }); + const decoded = decodeRawRepository(response.body); + if (Result.isFailure(decoded)) { + return yield* new GitHubRepositoryDecodeError({ + cwd, + cause: decoded.failure, + }); + } + return decoded.success; + }); + + const readViewerLogin = Effect.fn("GitHubRepositoryApi.readViewerLogin")(function* ( + cwd: string, + host: string, + ) { + const response = yield* rest(cwd, { host, operation: "getViewer", path: "user" }); + const decoded = decodeViewerLogin(response.body); + if (Result.isFailure(decoded)) { + return yield* new GitHubRepositoryCommandError({ cwd, cause: decoded.failure }); + } + return decoded.success.login; + }); + + const gitFailure = (cwd: string) => (cause: unknown) => + new GitHubRepositoryCommandError({ cwd, cause }); + + const runGit = (cwd: string, operation: string, args: ReadonlyArray) => + git.execute({ operation: `GitHubRepositoryApi.checkoutPullRequest.${operation}`, cwd, args }); + + /** + * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a + * fork gets a remote of its own), checked out under the name gh would give it, and set to + * track the head. A head branch that is gone is read from the base's `refs/pull//head`. + * An existing branch fast-forwards, or with `force` is reset to the pull request. + */ + const checkoutPullRequest: GitHubRepositoryApi["Service"]["checkoutPullRequest"] = Effect.fn( + "GitHubRepositoryApi.checkoutPullRequest", + )(function* (input) { + const reference = parsePullRequestReference(input.reference); + const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); + const base = + reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd }); + const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); + const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; + const isCrossRepository = + pullRequest.isCrossRepository ?? + (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); + const headOwner = + pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; + + const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( + Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), + Effect.mapError(gitFailure(input.cwd)), + ); + const remoteFor = (nameWithOwner: string) => + remotes + .split("\n") + .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) + .find( + (match) => + match !== null && + normalizeGitRemoteUrl(match[2]!).split("/").slice(1).join("/") === + nameWithOwner.toLowerCase(), + )?.[1] ?? null; + const baseRemote = Effect.suspend(() => { + const known = remoteFor(baseNameWithOwner); + return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); + }); + // A fork's branch named like the base's default branch is checked out under the owner's + // prefix. Without the default branch that collision cannot be ruled out, and the checkout + // would reset the local default branch to the fork's commit, so it fails instead. + const defaultBranch = isCrossRepository + ? yield* readRepository(input.cwd, base).pipe( + Effect.map((repository) => repository.default_branch ?? null), + ) + : null; + const localBranch = pullRequestCheckoutBranchName({ + headRefName: pullRequest.headRefName, + headOwner, + isCrossRepository, + defaultBranch, + }); + + /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ + const headRemote = Effect.gen(function* () { + if (!isCrossRepository) return yield* baseRemote; + if (headNameWithOwner === null) return yield* commandFailure(input.cwd, "The fork is gone."); + const known = remoteFor(headNameWithOwner); + if (known !== null) return known; + const [owner, name] = headNameWithOwner.split("/"); + const fork = yield* readRepository(input.cwd, { + host: base.host, + owner: owner!, + name: name!, + }); + const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); + return yield* git.ensureRemote({ + cwd: input.cwd, + preferredName: headOwner ?? "fork", + url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url, + }); + }); + + const exists = (yield* git + .listLocalBranchNames(input.cwd) + .pipe(Effect.mapError(gitFailure(input.cwd)))).includes(localBranch); + + // The commit to check out, fetched from the head branch where it still exists. + const target = yield* Effect.gen(function* () { + const remoteName = yield* headRemote; + yield* git.fetchRemoteTrackingBranch({ + cwd: input.cwd, + remoteName, + remoteBranch: pullRequest.headRefName, + }); + return { + ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`, + upstream: { remoteName, remoteBranch: pullRequest.headRefName }, + }; + }).pipe( + Effect.catch(() => + Effect.gen(function* () { + yield* runGit(input.cwd, "fetchPullRef", [ + "fetch", + "--quiet", + "--no-tags", + yield* baseRemote, + `refs/pull/${pullRequest.number}/head`, + ]); + const { commitSha } = yield* git.resolveCommit({ + cwd: input.cwd, + revision: "FETCH_HEAD", + }); + return { ref: commitSha, upstream: null }; + }), + ), + Effect.mapError(gitFailure(input.cwd)), + ); + + yield* Effect.gen(function* () { + if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]); + yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); + if (exists) { + yield* runGit( + input.cwd, + "sync", + input.force === true + ? ["reset", "--hard", "--quiet", target.ref] + : ["merge", "--ff-only", "--quiet", target.ref], + ); + } + // Tracking is set once the branch is the pull request's, so a sync that fails leaves an + // existing branch's upstream as it was. + if (target.upstream !== null) { + yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); + } + }).pipe(Effect.mapError(gitFailure(input.cwd))); + }); + + return GitHubRepositoryApi.of({ + listPullRequestsByHead: (input) => + AllowGitHubReserve.pipe( + Effect.flatMap((allowReserve) => listByHead({ ...input, allowReserve })), + ), + listOpenPullRequests: (input) => + listByHead({ + cwd: input.cwd, + headSelector: input.headSelector, + state: "open", + limit: input.limit ?? 1, + rateLimitHost: input.rateLimitHost, + allowReserve: true, + }).pipe(Effect.map(toSummaries)), + getPullRequest: (input) => readPullRequest(input).pipe(Effect.map(pullRequestSummary)), + getRepositoryCloneUrls: (input) => + Effect.gen(function* () { + const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe( + Effect.map((locator) => locator.host), + Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), + )).toLowerCase(); + const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); + if (locator === null) { + return yield* commandFailure(input.cwd, "Repositories are named owner/name."); + } + return repositoryCloneUrls(yield* readRepository(input.cwd, locator)); + }), + createRepository: (input) => + Effect.gen(function* () { + const locator = parseGitHubRepositorySelector( + input.repository, + (environment.GH_HOST ?? "github.com").toLowerCase(), + ); + const viewer = locator === null ? null : yield* readViewerLogin(input.cwd, locator.host); + const owner = locator?.owner ?? viewer; + const name = locator?.name ?? input.repository.trim(); + const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); + const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); + const response = yield* rest(input.cwd, { + host, + operation: "createRepository", + method: "POST", + path: + isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, + body: { name, private: input.visibility === "private" }, + }); + const decoded = decodeRawRepository(response.body); + if (Result.isFailure(decoded)) { + return yield* new GitHubRepositoryDecodeError({ + cwd: input.cwd, + cause: decoded.failure, + }); + } + return repositoryCloneUrls(decoded.success); + }), + createPullRequest: (input) => + Effect.gen(function* () { + const locator = yield* resolveRepository({ cwd: input.cwd }); + const body = yield* fileSystem + .readFileString(input.bodyFile) + .pipe(Effect.mapError(gitFailure(input.cwd))); + yield* rest(input.cwd, { + host: locator.host, + operation: "createPullRequest", + method: "POST", + path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, + // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. + // gh allows maintainer edits unless told otherwise; the API's default is not documented. + body: { + base: input.baseBranch, + head: input.headSelector, + title: input.title, + body, + maintainer_can_modify: true, + }, + }); + }), + getDefaultBranch: (input) => + Effect.gen(function* () { + const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); + const repository = yield* readRepository(input.cwd, locator); + const branch = repository.default_branch?.trim() ?? ""; + return branch.length > 0 ? branch : null; + }), + checkoutPullRequest, + }); +}); + +export const layer = Layer.effect(GitHubRepositoryApi, make).pipe( + Layer.provideMerge(GitHubApi.layer), + Layer.provideMerge(GitHubCredentials.layer), + Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(SourceControlRateLimit.layer), +); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index f3f7f31ed4e3b2d7def9a1259684de615131617d..89398b63478604b9c392d16f19f7e99c50463315 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -8,7 +8,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; @@ -27,12 +27,15 @@ const processResult = ( }); function makeProvider( - github: Partial, + github: Partial, api: Partial = {}, ) { return GitHubSourceControlProvider.make.pipe( Effect.provide( - Layer.merge(Layer.mock(GitHubCli.GitHubCli)(github), Layer.mock(GitHubApi.GitHubApi)(api)), + Layer.merge( + Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(github), + Layer.mock(GitHubApi.GitHubApi)(api), + ), ), ); } @@ -87,10 +90,9 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = }), ); -it.effect("adds safe request context while retaining GitHub CLI causes", () => +it.effect("adds safe request context while retaining GitHub API causes", () => Effect.gen(function* () { - const cause = new GitHubCli.GitHubPullRequestNotFoundError({ - command: "gh", + const cause = new GitHubRepositoryApi.GitHubPullRequestNotFoundError({ cwd: "/repo", cause: new Error("raw upstream detail that should remain in the cause"), }); @@ -109,7 +111,6 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () => { provider: error.provider, operation: error.operation, - command: error.command, cwd: error.cwd, reference: error.reference, detail: error.detail, @@ -117,7 +118,6 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () => { provider: "github", operation: "getChangeRequest", - command: "gh", cwd: "/repo", reference: "https://github.com/pingdotgg/t3code/pull/42", 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", () => it.effect("lists change request history through the batched head lookup", () => Effect.gen(function* () { - let lookup: Parameters[0] | null = - null; + let lookup: + | Parameters[0] + | null = null; const provider = yield* makeProvider({ listPullRequestsByHead: (input) => { lookup = input; @@ -183,8 +184,9 @@ it.effect("lists change request history through the batched head lookup", () => it.effect("creates GitHub PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = - null; + let createInput: + | Parameters[0] + | null = null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index cf06c6efdfcd015f4345f10c62b5a0b085f590e3..a3b7a22f8d55942cb455601ff817f9c9f1854d61 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -15,7 +15,7 @@ import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as ServerSettings from "../serverSettings.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import { effectiveGitHubAccount, findAuthenticatedGitHubAccount, @@ -40,7 +40,7 @@ const decodeLinkSubject = Schema.decodeUnknownEffect( ), ); -function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { +function toChangeRequest(summary: GitHubRepositoryApi.GitHubPullRequestSummary): ChangeRequest { return { provider: "github", number: summary.number, @@ -264,7 +264,7 @@ export const makeDiscovery = Effect.gen(function* () { }); export const make = Effect.gen(function* () { - const github = yield* GitHubCli.GitHubCli; + const github = yield* GitHubRepositoryApi.GitHubRepositoryApi; const api = yield* GitHubApi.GitHubApi; const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = @@ -286,7 +286,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "listChangeRequests", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.headSelector, @@ -325,7 +324,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "listChangeRequests", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.headSelector, @@ -403,7 +401,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "getChangeRequest", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.reference, @@ -428,7 +425,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "createChangeRequest", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.headSelector, @@ -445,7 +441,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "getRepositoryCloneUrls", - command: error.command, cwd: input.cwd, repository: SourceControlProvider.transportSafeSourceControlErrorValue( input.repository, @@ -462,7 +457,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "createRepository", - command: error.command, cwd: input.cwd, repository: SourceControlProvider.transportSafeSourceControlErrorValue( input.repository, @@ -486,7 +480,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "getDefaultBranch", - command: error.command, cwd: input.cwd, detail: error.message, cause: error, @@ -500,7 +493,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "checkoutChangeRequest", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.reference, diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 54de8cc14fde7864ce80a8e67f0b1c72be682a34..d9bbb51d7a61678f0bd583f5366ae4d6f610102a 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -18,7 +18,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; @@ -40,7 +40,7 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubCli.GitHubCli)({}), + Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)({}), Layer.mock(GitHubApi.GitHubApi)({}), Layer.mock(GitLabCli.GitLabCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 08751017b35133f03171ba34e8be2dd391da3f15..c9a7164c8b3107d1922c58d18b7e4d327a76a172 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -15,7 +15,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; @@ -42,7 +42,7 @@ function makeRegistry(input: { readonly url: string; }>; readonly process?: Partial; - readonly github?: Partial; + readonly github?: Partial; readonly githubApi?: Partial; readonly gitlab?: Partial; readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; @@ -98,7 +98,7 @@ function makeRegistry(input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubCli.GitHubCli)(input.github ?? {}), + Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(input.github ?? {}), Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}), Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index af3fc819a4c71538c35a6647555380ea15ad5b81..5f64ecb12f87ecc45d0de631ccce9c5be9c05028 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -229,7 +229,7 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery. import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubCli from "./sourceControl/GitHubCli.ts"; +import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; @@ -3190,7 +3190,7 @@ export const layer = Layer.unwrap( Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, - GitHubCli.layer, + GitHubRepositoryApi.layer, GitLabCli.layer, ForgejoCli.layer, ),