diff --git a/apps/server/package.json b/apps/server/package.json index ab9ad49d4e621efa2649476205fcd7b2123524c8..3dd02fcdcb5e568539910b2677d6a16dc6ca0646 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -49,6 +49,7 @@ "@t3tools/source-control-bitbucket": "workspace:*", "@t3tools/source-control-core": "workspace:*", "@t3tools/source-control-forgejo": "workspace:*", + "@t3tools/source-control-github": "workspace:*", "@t3tools/source-control-gitlab": "workspace:*", "effect": "catalog:", "jose": "catalog:", diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index 598f6cc2f930784c862b43eee4d0867263605e14..cb1553b5cdcf5bf5d37da03a06b17df2c6dd2eba 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -23,7 +23,6 @@ import { } from "@t3tools/provider-core/server/threadTitleContext"; import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; -import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts"; import * as ServerSettings from "../src/serverSettings.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; @@ -147,9 +146,7 @@ await Effect.runPromise( Layer.mergeAll( ProcessRunner.layer, SourceControlProviderRegistry.layer.pipe( - Layer.provide( - Layer.mergeAll(GitHubApi.layerWithDependencies, SourceControlBuiltInDrivers.layer), - ), + Layer.provide(SourceControlBuiltInDrivers.layer), // Default settings: no saved Bitbucket token, gh's own GitHub account choice. Layer.provide(ServerSettings.layerTest()), Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), diff --git a/apps/server/scripts/measure-pr-preview.ts b/apps/server/scripts/measure-pr-preview.ts index 0f33ec9a6139c05d476781693d92b46c72dd1509..8fca30c91486ffda0b5d34466bce638b8aba73d2 100644 --- a/apps/server/scripts/measure-pr-preview.ts +++ b/apps/server/scripts/measure-pr-preview.ts @@ -10,14 +10,19 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { FetchHttpClient } from "effect/http"; -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"; +import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; +import * as GitHubPullRequestProvider from "@t3tools/source-control-github/server/GitHubPullRequestProvider"; +import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; +import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; +import * as ServerConfig from "../src/config.ts"; import * as ServerSettings from "../src/serverSettings.ts"; -import * as GitHubQuota from "../src/sourceControl/githubQuota.ts"; +import * as GitHubQuota from "@t3tools/source-control-github/server/GitHubQuota"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import * as ServerSourceControlHost from "../src/sourceControl/ServerSourceControlHost.ts"; +import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; +import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts"; const [repository, ...numbers] = process.argv.slice(2); if (!repository || numbers.length === 0 || numbers.some((number) => !/^\d+$/.test(number))) { @@ -41,6 +46,17 @@ function withRateLimit(query: string): string { : `${query.slice(0, end)}\n rateLimit { cost }\n${query.slice(end)}`; } +// The server's host port over default settings: gh's own account choice, no saved token. +const sourceControlHost = ServerSourceControlHost.layer.pipe( + Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), + Layer.provide(GitVcsDriver.layer), + Layer.provide(ServerSettings.layerTest()), + // A scratch state directory, so nothing here touches a real T3 home. + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-measure-pr-preview-" })), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + const measuredApi = Layer.effect( GitHubApi.GitHubApi, Effect.gen(function* () { @@ -73,18 +89,15 @@ const measuredApi = Layer.effect( }); }), ).pipe( - Layer.provide(GitHubCredentials.layer.pipe(Layer.provide(ServerSettings.layerTest()))), + Layer.provide(GitHubCredentials.layer), Layer.provide(GitHubQuota.layer), Layer.provide(SourceControlRateLimit.layer), Layer.provide(FetchHttpClient.layer), - Layer.provide(VcsProcess.layer), - Layer.provide(NodeServices.layer), ); const services = GitHubPullRequestApi.layer.pipe( Layer.provideMerge(measuredApi), - Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(sourceControlHost), ); const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 12ec00256973b6f98198700d25b7aeae9c9ff9d3..61197efb6a50573b36cad0d229885f95b578a0d3 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -35,7 +35,7 @@ import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.t import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; import { openMediaFile } from "./MediaFile.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; -import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; +import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; import { githubMediaResponse } from "./GitHubMediaFetch.ts"; vi.mock("node:fs/promises", async (importOriginal) => { diff --git a/apps/server/src/assets/GitHubMediaFetch.ts b/apps/server/src/assets/GitHubMediaFetch.ts index 7b26c3e69acd90a06a778e07ffa184a52772102c..d05308f2afedac5865f98420922188849459534a 100644 --- a/apps/server/src/assets/GitHubMediaFetch.ts +++ b/apps/server/src/assets/GitHubMediaFetch.ts @@ -12,7 +12,7 @@ import { type HttpClientResponse, } from "effect/http"; -import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; +import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; /** * Exactly the hosts the credential is for. Everything a redirect leads to — the presigned diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 92b7fcd607fc1046deb875c7b99f6c3779f71837..24dd7e1d54ea0e550f3d4f3d4fda73784162bf0a 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -43,7 +43,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; +import { decodeGitHubPullRequestListJson } from "@t3tools/source-control-github/server/gitHubPullRequests"; import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts deleted file mode 100644 index 8888f4c11f45a2310dfda82bc1a9829d940abb4c..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts +++ /dev/null @@ -1,4588 +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/GitHubApi.ts"; -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; -import * as GitHubQuota from "../sourceControl/githubQuota.ts"; -import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; -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 quota from each - * answer's headers, the way GitHubApi does, so the reserve behaviour is still the module's to prove. - */ -const mockApi = Layer.effect( - GitHubApi.GitHubApi, - Effect.gen(function* () { - const quota = yield* GitHubQuota.GitHubQuota; - return GitHubApi.GitHubApi.of({ - graphql: (input) => - GitHubApi.AllowGitHubReserve.pipe( - Effect.flatMap((interactive) => - quota.admit(input.host, "graphql", { - allowReserve: input.allowReserve ?? interactive, - }), - ), - Effect.andThen(() => - (input.query.includes("query PullRequestStackMemberships") - ? mockedStackMemberships - : mockedExecute)({ kind: "graphql", ...input }), - ), - Effect.tap((answer) => quota.observe(input.host, answer.headers)), - Effect.map((answer) => answer.body), - ), - rest: (input) => mockedExecute({ kind: "rest", ...input }), - credential: (host) => mockedCredential(host), - }); - }), -); - -const layer = it.layer( - GitHubPullRequestApi.layer.pipe( - Layer.provideMerge(mockApi), - Layer.provideMerge(GitHubQuota.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, - headers: Readonly> = {}, -): GitHubApi.GitHubRestResponse { - return { status: 200, headers, body, truncated, invalidUtf8 }; -} - -/** GitHub's GraphQL quota headers, leaving `remaining` of 5,000 until `resetAt`. */ -function graphqlQuota(remaining: number, resetAt = "2099-08-13T14:00:00Z") { - return { - "x-ratelimit-resource": "graphql", - "x-ratelimit-limit": "5000", - "x-ratelimit-remaining": String(remaining), - "x-ratelimit-reset": String(Date.parse(resetAt) / 1000), - }; -} - -/** - * 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(GitHubQuota.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("holds background previews once an answer reports the reserve, until it resets", () => - Effect.gen(function* () { - const now = yield* Clock.currentTimeMillis; - const quota = yield* GitHubQuota.GitHubQuota; - const resetAt = "2099-08-13T14:00:00Z"; - yield* quota.observe("preview-budget.example", graphqlQuota(501, resetAt)); - // GitHub's count, not ours: the answer below reports the reserve has been reached. - 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, - }, - }, - }, - }), - false, - false, - graphqlQuota(499, resetAt), - ), - ), - ); - const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; - const input = { - cwd: "/w", - repository: "acme/web", - host: "preview-budget.example", - number: 7, - }; - yield* cli.getPullRequestPreview(input); - const held = yield* Effect.flip(cli.getPullRequestPreview({ ...input, number: 8 })); - expect(held._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(); - expect(queryAt(0)).toContain( - "s0: repository(owner: $s0_owner, name: $s0_name) { pullRequest(number: $s0_number)", - ); - expect(varsAt(0)).toMatchObject({ - s0_owner: "acme", - s0_name: "web", - s0_number: 7, - s1_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(); - expect(queryAt(0)).toContain( - "w1: repository(owner: $w1_owner, name: $w1_name) { pullRequest(number: $w1_number)", - ); - expect(varsAt(0)).toMatchObject({ w1_owner: "acme", w1_name: "web", w1_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("reports truncation when GitHub repeats a cursor before the page is full", () => - Effect.gen(function* () { - // Two pages of 100 that hand back the same cursor, for a page of 250. - const repeating = output( - encodeJson({ - data: { - search: { - pageInfo: { hasNextPage: true, endCursor: "same" }, - nodes: rows(100, 1), - }, - }, - }), - ); - mockedExecute.mockReturnValue(Effect.succeed(repeating)); - 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: 250, - }); - - assert.strictEqual(mockedExecute.mock.calls.length, 2); - 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 numbers = Object.entries( - membership?.kind === "graphql" ? (membership.variables ?? {}) : {}, - ) - .filter(([name]) => name.endsWith("_number")) - .map(([, number]) => number); - expect(numbers).toEqual([4, 5]); - }), - ); - - 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" && Object.values(input.variables ?? {}).includes(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 }, - ]); - expect(queryAt(0)).toContain("s0: repository(owner: $s0_owner, name: $s0_name)"); - expect(varsAt(0)).toMatchObject({ s0_owner: "acme", s0_name: "web", s24_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 the head list stops before its end", () => - Effect.gen(function* () { - // GitHub hands the same cursor back, so the second page is never read past. #7 looks - // unique on what was read, and an unread page could still hold another head like it. - const repeating = heads([7]); - repeating.data.repository.pullRequests.pageInfo = { - hasNextPage: true, - endCursor: "same", - } as never; - workflowApprovalRoutes(() => crossRepositoryDetail(), repeating, 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-list-truncated", - }); - }), - ); - - 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("looks up avatars by node id in one GraphQL read", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - encodeJson({ - data: { - nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }], - }, - }), - ), - ), - ); - 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(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), false, false, graphqlQuota(499))), - ); - 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 }, - }, - }, - }), - false, - false, - graphqlQuota(499), - ), - ), - ); - const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; - const input = { - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - } as const; - - yield* cli.getViewerAccess(input); - - 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 }, - }, - }, - }), - false, - false, - graphqlQuota(499), - ), - ), - ) - .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, - }); - - // The same cursor twice is a page GitHub already gave, so reading stops there. - assert.strictEqual(mockedExecute.mock.calls.length, 2); - assert.isTrue(viewed.truncated); - }), - ); - - 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", - f0_path: "src/a.ts", - f1_path: "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 deleted file mode 100644 index dd093df00b775ea32223af3eef936a30418c7f0f..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.ts +++ /dev/null @@ -1,2857 +0,0 @@ -import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage"; -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 * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import { readGraphQlPages } from "../sourceControl/githubGraphQl.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; -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, - 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 "@t3tools/source-control-core/server/PullRequestProvider"; - -/** - * 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. - */ - type CheckContextsPage = Result.Result.Success< - ReturnType - >; - const readAllCheckContexts = ( - input: Parameters[0], - allowReserve: boolean, - ) => - Effect.gen(function* () { - const { owner, name } = parseRepositorySelector(input.repository); - const { pages, truncated } = yield* readGraphQlPages( - (after, previous: ReadonlyArray) => - graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestDetail", - allowReserve, - variables: { owner, name, number: input.number, after }, - query: pullRequestCheckContextsGraphQlQuery(input.host), - decode: decodePullRequestCheckContextsJson, - }).pipe( - // Each page names the head it read, so a push mid-walk fails rather than mixing - // two revisions' checks. - Effect.filterOrFail( - (page) => previous.length === 0 || page.headSha === previous[0]!.headSha, - () => - readError( - input.cwd, - "getPullRequestDetail", - new Error("Pull request head changed while reading checks."), - ), - ), - ), - { maxPages: CHECK_CONTEXT_PAGES, nextCursor: (page) => page.nextCursor }, - ); - return { - headSha: pages[0]?.headSha ?? null, - contexts: pages.flatMap((page) => page.contexts), - truncated, - }; - }); - - const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - return GitHubApi.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. - * `truncated` says some were never read, which is never enough to call a head unique. - */ - 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 countHeads = (pages: ReadonlyArray<{ readonly heads: ReadonlyArray }>) => - pages.reduce((total, page) => total + page.heads.length, 0); - const { pages, truncated } = yield* readGraphQlPages( - (after) => - 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, - }), - { - nextCursor: (page) => page.nextCursor, - until: (pages) => countHeads(pages) > workflowApprovalLimit, - }, - ); - return { heads: pages.flatMap((page) => page.heads), truncated }; - }); - - /** - * 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, - truncated, - }): Effect.Effect< - GitHubPullRequestHead, - GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError - > => { - const exactHeads = heads.filter( - (pullRequest) => - pullRequest.headSha === input.headSha && - pullRequest.isCrossRepository === true && - pullRequest.headRepositoryOwner?.toLowerCase() === - input.headRepositoryOwner.toLowerCase(), - ); - if (truncated || 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 document = buildPullRequestSummariesGraphQlQuery( - batchable.map((entry) => entry.request), - first.request.host === "github.com", - ); - const batched = - document === null - ? Effect.succeed(new Map()) - : graphqlRead({ - cwd: first.request.cwd, - host: first.request.host, - operation: "getPullRequestSummary", - ...document, - 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 document = buildPullRequestWatchFingerprintsGraphQlQuery( - batchable.map((entry) => entry.request), - ); - const read = - document === null - ? Effect.succeed(new Map()) - : graphqlRead({ - cwd: first.request.cwd, - host: first.request.host, - operation: "getPullRequestWatchFingerprint", - ...document, - 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 countRows = (pages: ReadonlyArray) => - pages.reduce((total, read) => total + read.rawCount, 0); - const { pages, truncated } = yield* readGraphQlPages( - (after, pages: ReadonlyArray) => - page(after, rows - countRows(pages)), - { - nextCursor: (read) => read.endCursor, - until: (pages) => countRows(pages) >= rows, - }, - ); - // `truncated` also covers a cursor GitHub repeated before `rows` arrived. - return { - items: pages.flatMap((read) => read.items), - rawCount: countRows(pages), - truncated, - }; - }); - 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, truncated: cutShort }) => { - 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: - cutShort || - (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 document = buildPullRequestStackMembershipsGraphQlQuery( - input.repository, - chunk.map((item) => item.number), - ); - if (document === null) return Effect.succeed(chunk); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listPullRequestStackMemberships", - ...document, - 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 document = buildPullRequestStatsGraphQlQuery(chunk); - if (document === null) { - return Effect.fail( - new GitHubRepositorySelectorError({ - cwd: input.cwd, - operation: "listPullRequestStats", - }), - ); - } - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listPullRequestStats", - ...document, - 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. - if (dismissalCursor !== null) { - const { pages } = yield* readGraphQlPages( - (cursor) => - graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listReviewThreadComments", - variables: { owner, name, number: input.number, cursor }, - query: REVIEW_DISMISSALS_GRAPHQL_QUERY, - decode: decodeReviewDismissalsJson, - }), - { - from: dismissalCursor, - maxPages: REVIEW_THREAD_PAGES, - nextCursor: (read) => read.nextCursor, - }, - ); - for (const read of pages) - for (const [id, message] of read.dismissalsByReviewId) - dismissalsByReviewId.set(id, message); - } - - 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); - return readGraphQlPages( - (after) => - 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, - }), - { maxPages: FILES_VIEWED_MAX_PAGES, nextCursor: (page) => page.nextCursor }, - ).pipe( - Effect.map(({ pages, truncated }) => ({ - files: pages.flatMap((page) => page.files), - truncated, - })), - ); - }, - - setPullRequestFilesViewed: (input) => { - if (input.files.length === 0) return Effect.void; - return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe( - Effect.flatMap((pullRequestId) => { - const mutation = buildSetFilesViewedGraphQlMutation(pullRequestId, input.files); - return mutation === null - ? Effect.void - : graphql({ host: input.host, operation: "setPullRequestFilesViewed", ...mutation }); - }), - ); - }, - - 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/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts deleted file mode 100644 index df4f7da4cd2cf0767fad80c76550d432ef7055f6..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ /dev/null @@ -1,1067 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Redacted from "effect/Redacted"; -import * as Result from "effect/Result"; -import type { PullRequestReaction } from "@t3tools/contracts"; - -import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; -import * as GitHubApi from "../sourceControl/GitHubApi.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"; - -const coreFields = { - checksTruncated: false, - comparison: { behindBy: 0, viewerCanUpdate: true }, - viewerAccess: { - canWrite: true, - canTriage: true, - canUpdate: true, - didAuthor: false, - mergeCapabilities: { merge: true, squash: true, rebase: true }, - }, -}; - -it.effect("maps credential verification failures without relabeling operation failures", () => - Effect.gen(function* () { - let verificationFails = true; - let operations = 0; - const provider = yield* make.pipe( - Effect.provide( - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ - revalidateChecks: (_input, read) => read, - withVerifiedCredential: (_input, use) => - verificationFails - ? Effect.fail( - new GitHubPullRequestApi.GitHubViewerLoginUnavailableError({ - cwd: "/w", - }), - ) - : use({ accountId: "123", viewer: "viewer", credentialFingerprint: "fingerprint" }), - }), - ), - ); - const verify = provider.withVerifiedCredential; - if (verify === undefined) - return yield* Effect.die("credential verification was not implemented"); - const input = { cwd: "/w", host: "github.com" }; - const operation = () => - Effect.sync(() => { - operations++; - }).pipe(Effect.andThen(Effect.fail("operation-failed"))); - expect(yield* verify(input, operation).pipe(Effect.flip)).toMatchObject({ - _tag: "PullRequestProviderError", - operation: "routeIdentity", - }); - expect(operations).toBe(0); - verificationFails = false; - expect(yield* verify(input, operation).pipe(Effect.flip)).toBe("operation-failed"); - expect(operations).toBe(1); - }), -); - -it.effect("refreshes checks without permissions or comparison reads", () => - Effect.gen(function* () { - let reads = 0; - const snapshot = Result.getOrThrow( - decodePullRequestDetailJson(`{ - "number": 7, "title": "Checks", "url": "https://github.com/acme/web/pull/7", - "headRefName": "feature", "baseRefName": "main", "state": "OPEN", - "createdAt": "2026-07-01T00:00:00Z", "updatedAt": "2026-07-01T00:00:00Z" - }`), - ); - const provider = yield* make.pipe( - Effect.provide( - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ - revalidateChecks: (_input, read) => read, - getPullRequestDetail: () => - Effect.sync(() => { - reads++; - return { - ...snapshot, - ...coreFields, - state: reads === 3 ? ("merged" as const) : ("open" as const), - checks: [ - { - name: "build", - status: reads === 1 ? ("pending" as const) : ("success" as const), - description: null, - url: null, - }, - ], - }; - }), - }), - ), - ); - const read = provider.getChangeRequestChecks; - if (read === undefined) return yield* Effect.die("checks read missing"); - for (let tick = 1; tick <= 3; tick++) { - const result = yield* read({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - expect(result.checks[0]?.status).toBe(tick === 1 ? "pending" : "success"); - expect(result.state).toBe(tick === 3 ? "merged" : "open"); - } - expect(reads).toBe(3); - }), -); - -it.effect("uses one narrow read for a linked pull request summary", () => - Effect.gen(function* () { - let summaryReads = 0; - const provider = yield* make.pipe( - Effect.provide( - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ - revalidateChecks: (_input, read) => read, - getPullRequestSummary: () => - Effect.sync(() => { - summaryReads += 1; - return { - number: 7, - title: "Summary", - url: "https://github.com/acme/web/pull/7", - headBranch: "feat/summary", - baseBranch: "main", - state: "open" as const, - updatedAt: "2026-08-24T12:34:56.000Z", - author: { login: "octocat", name: null, avatarUrl: null }, - }; - }), - }), - ), - ); - - const readSummary = provider.getChangeRequestSummary; - if (readSummary === undefined) return yield* Effect.die("summary read was not implemented"); - const summary = yield* readSummary({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - expect(summary.state).toBe("open"); - expect(summaryReads).toBe(1); - // The author's avatar comes from the login-shaped URL, not a second request. - expect(summary.author?.avatarUrl).toBe("https://github.com/octocat.png?size=80"); - }), -); - -it.effect("declares host-native stacks and passes the one the CLI reads through", () => - Effect.gen(function* () { - const stack = { - id: "42", - number: 3, - url: "https://github.com/acme/web/stacks/3", - base: "main", - layers: [ - { number: 6, headBranch: "feat/one", state: "merged" as const }, - { number: 7, headBranch: "feat/two", state: "open" as const }, - ], - }; - const provider = yield* make.pipe( - Effect.provide( - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ - revalidateChecks: (_input, read) => read, - getPullRequestStack: (input) => Effect.succeed(input.number === 7 ? stack : null), - }), - ), - ); - - expect(provider.capabilities.stacks).toBe(true); - const readStack = provider.getChangeRequestStack; - if (readStack === undefined) return yield* Effect.die("stack read was not implemented"); - const ref = { cwd: "/w", repository: "acme/web", host: "github.com" }; - expect(yield* readStack({ ...ref, number: 7 })).toEqual(stack); - expect(yield* readStack({ ...ref, number: 8 })).toBeNull(); - }), -); - -it.effect("reports a failed stack read against its own operation", () => - Effect.gen(function* () { - const provider = yield* make.pipe( - Effect.provide( - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ - revalidateChecks: (_input, read) => read, - getPullRequestStack: () => - Effect.fail( - new GitHubPullRequestApi.GitHubPullRequestReadError({ - cwd: "/w", - operation: "getPullRequestStack", - cause: new Error("unreadable"), - }), - ), - }), - ), - ); - - const readStack = provider.getChangeRequestStack; - if (readStack === undefined) return yield* Effect.die("stack read was not implemented"); - const error = yield* Effect.flip( - readStack({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }), - ); - - expect(error.operation).toBe("getChangeRequestStack"); - expect(error.reason).toBe("failed"); - }), -); - -describe("gitHubViewerPermissions", () => { - it("offers everything to a viewer who can write to the repository", () => { - expect( - gitHubViewerPermissions({ - canWrite: true, - canTriage: true, - canUpdate: true, - didAuthor: false, - }), - ).toEqual({ - // Arming a merge for later is the merge, so it travels with it. - actions: [ - "merge", - "enable-auto-merge", - "disable-auto-merge", - "revert", - "approve-workflows", - "ready", - "draft", - "close", - "reopen", - ], - comment: true, - resolve: true, - stackRebase: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - labels: true, - }); - }); - - it("leaves a passer-by on a repository they can only read nothing but the review", () => { - // Every open-source pull request somebody else opened: GitHub says no to all five actions - // and to resolving, and yes to commenting and to every verdict. - expect( - gitHubViewerPermissions({ - canWrite: false, - canTriage: false, - canUpdate: false, - didAuthor: false, - }), - ).toEqual({ - actions: [], - comment: true, - resolve: false, - verdicts: ["comment", "approve", "request-changes"], - // Asking somebody else to review is the one thing read access never stretches to. - requestReviewers: false, - labels: false, - }); - }); - - it("lets a triager label without letting them merge or ask for a review", () => { - const permissions = gitHubViewerPermissions({ - canWrite: false, - canTriage: true, - canUpdate: false, - didAuthor: false, - }); - expect(permissions.labels).toBe(true); - expect(permissions.requestReviewers).toBe(false); - expect(permissions.actions).toEqual([]); - }); - - it("keeps an author's own pull request theirs to close, with read access and no more", () => { - expect( - gitHubViewerPermissions({ - canWrite: false, - canTriage: false, - canUpdate: true, - didAuthor: true, - }), - ).toEqual({ - // Merging is the one thing writing is needed for, now or later; the rest an author may do. - actions: ["ready", "draft", "close", "reopen"], - comment: true, - resolve: true, - // GitHub refuses an author's approval of their own change, so the page does not offer one. - verdicts: ["comment"], - requestReviewers: false, - labels: false, - }); - }); - - it.effect("uses the permissions carried by the core read without a second request", () => - Effect.gen(function* () { - const provider = yield* make; - const detail = yield* provider.getChangeRequest({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - expect(detail.viewerPermissions).toEqual({ - actions: ["ready", "draft", "close", "reopen"], - comment: true, - resolve: false, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: false, - labels: false, - }); - expect(detail.workflowApprovalsRequired).toBeUndefined(); - expect(detail.checks).toContainEqual({ - name: "Workflow approval status", - status: "action-required", - description: "GitHub could not determine whether workflows are awaiting approval.", - url: null, - }); - for (const fingerprint of ["broad", "restricted", "broad"]) { - const scoped = yield* provider - .getChangeRequest({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }) - .pipe( - Effect.provideService(GitHubApi.PinnedGitHubCredential, { - host: "github.com", - token: Redacted.make("credential"), - credentialFingerprint: fingerprint, - }), - ); - expect(scoped.mergeCapabilities.squash).toBe(fingerprint !== "restricted"); - } - }).pipe( - Effect.provide( - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ - revalidateChecks: (_input, read) => read, - getPullRequestDetail: () => - Effect.succeed({ - ...coreFields, - comparison: null, - viewerAccess: { ...coreFields.viewerAccess, canWrite: false, canTriage: false }, - authorId: null, - number: 7, - title: "Pull request 7", - url: "https://github.com/acme/web/pull/7", - author: null, - isCrossRepository: true, - headRepositoryOwner: null, - headBranch: "feat/page", - baseBranch: "main", - state: "open" as const, - isDraft: false, - mergeability: "mergeable" as const, - reviewDecision: null, - additions: 1, - deletions: 1, - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - reviewRequestLogins: [], - hasTeamReviewRequest: false, - checksState: null, - labels: [], - body: "", - changedFiles: 1, - mergedAt: null, - closedAt: null, - checks: [], - comments: [], - commits: [], - }).pipe( - Effect.flatMap((detail) => - GitHubApi.PinnedGitHubCredential.pipe( - Effect.map((credential) => ({ - ...detail, - viewerAccess: { - ...detail.viewerAccess, - mergeCapabilities: { - ...detail.viewerAccess.mergeCapabilities, - squash: credential?.credentialFingerprint !== "restricted", - }, - }, - })), - ), - ), - ), - }), - ), - ), - ); - - it.effect("keeps fork workflows awaiting approval out of the passing state", () => - Effect.gen(function* () { - const provider = yield* make; - const detail = yield* provider.getChangeRequest({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - const readChecks = provider.getChangeRequestChecks; - if (readChecks === undefined) return yield* Effect.die("checks read missing"); - expect( - yield* readChecks({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }), - ).toEqual({ state: detail.state, checks: detail.checks }); - expect(detail.workflowApprovalsRequired).toBe(1); - expect(detail.checks).toEqual([ - { - name: "manual gate", - status: "action-required", - description: null, - url: "https://example.com/manual-gate", - }, - { - name: "build", - status: "success", - description: null, - url: null, - }, - { - name: "contributor tests", - status: "action-required", - description: "A maintainer must approve this workflow before it can run.", - url: "https://github.com/acme/web/actions/runs/123", - }, - ]); - }).pipe( - Effect.provide( - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ - revalidateChecks: (_input, read) => read, - getPullRequestDetail: () => - Effect.succeed({ - ...coreFields, - authorId: null, - number: 7, - title: "Pull request 7", - url: "https://github.com/acme/web/pull/7", - author: null, - isCrossRepository: true, - headRepositoryOwner: "octocat", - headSha: "abc123", - headBranch: "feat/page", - baseBranch: "main", - state: "open", - isDraft: false, - mergeability: "mergeable", - reviewDecision: null, - additions: 1, - deletions: 1, - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - reviewRequestLogins: [], - hasTeamReviewRequest: false, - checksState: "passing", - labels: [], - body: "", - changedFiles: 1, - mergedAt: null, - closedAt: null, - checks: [ - { - name: "manual gate", - status: "action-required", - description: null, - url: "https://example.com/manual-gate", - }, - { name: "build", status: "success", description: null, url: null }, - ], - comments: [], - commits: [], - }), - listWorkflowRunsRequiringApproval: () => - Effect.succeed([ - { - id: 123, - name: "contributor tests", - url: "https://github.com/acme/web/actions/runs/123", - }, - ]), - getViewerAccess: () => - Effect.succeed({ - canWrite: true, - canTriage: true, - canUpdate: true, - didAuthor: false, - mergeCapabilities: { merge: true, squash: true, rebase: true }, - }), - }), - ), - ), - ); -}); - -const openDetail = { - ...coreFields, - authorId: null, - number: 7, - title: "Pull request 7", - url: "https://github.com/acme/web/pull/7", - author: null, - isCrossRepository: true, - headRepositoryOwner: "acme", - headSha: "abc123", - headBranch: "feat/page", - baseBranch: "main", - state: "open" as const, - isDraft: false, - mergeability: "mergeable" as const, - reviewDecision: null, - additions: 1, - deletions: 1, - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - reviewRequestLogins: [], - hasTeamReviewRequest: false, - checksState: null, - labels: [], - body: "", - changedFiles: 1, - mergedAt: null, - closedAt: null, - checks: [], - comments: [], - commits: [], -}; - -it.effect( - "uses the core comparison and permissions while preserving workflow approval checks", - () => - Effect.gen(function* () { - const provider = yield* make.pipe( - Effect.provide( - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ - getPullRequestDetail: () => - Effect.succeed({ ...openDetail, comparison: { behindBy: 2, viewerCanUpdate: true } }), - listWorkflowRunsRequiringApproval: () => - Effect.succeed([{ id: 123, name: "tests", url: "https://example.com/runs/123" }]), - }), - ), - ); - - const detail = yield* provider.getChangeRequest({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - expect(detail.behindBy).toBe(2); - expect(detail.workflowApprovalsRequired).toBe(1); - expect(detail.checks).toContainEqual({ - name: "tests", - status: "action-required", - description: "A maintainer must approve this workflow before it can run.", - url: "https://example.com/runs/123", - }); - }), -); - -it.effect("does not classify same-repository gates as fork workflow approvals", () => - Effect.gen(function* () { - const provider = yield* make; - const detail = yield* provider.getChangeRequest({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - expect(detail.workflowApprovalsRequired).toBe(0); - expect(detail.checks).toEqual([]); - }).pipe( - Effect.provide( - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ - revalidateChecks: (_input, read) => read, - getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }), - listWorkflowRunsRequiringApproval: () => - Effect.die("same-repository pull requests must not probe fork workflow approvals"), - getViewerAccess: () => - Effect.succeed({ - canWrite: true, - canTriage: true, - canUpdate: true, - didAuthor: false, - mergeCapabilities: { merge: true, squash: true, rebase: true }, - }), - }), - ), - ), -); - -it.effect("keeps an unsafe workflow approval scope visible as unknown", () => - Effect.gen(function* () { - const provider = yield* make; - const detail = yield* provider.getChangeRequest({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - expect(detail.workflowApprovalsRequired).toBeUndefined(); - expect(detail.checks).toEqual([ - { - name: "Workflow approval status", - status: "action-required", - description: "GitHub could not determine whether workflows are awaiting approval.", - url: null, - }, - ]); - }).pipe( - Effect.provide( - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ - revalidateChecks: (_input, read) => read, - getPullRequestDetail: () => Effect.succeed(openDetail), - listWorkflowRunsRequiringApproval: () => - Effect.fail( - new GitHubPullRequestApi.GitHubWorkflowApprovalRefusedError({ - cwd: "/w", - number: 7, - reason: "head-not-unique", - observedCount: 2, - limit: 1_000, - }), - ), - getViewerAccess: () => - Effect.succeed({ - canWrite: true, - canTriage: true, - canUpdate: true, - didAuthor: false, - mergeCapabilities: { merge: true, squash: true, rebase: true }, - }), - }), - ), - ), -); - -it.effect("propagates workflow discovery rate limits", () => - Effect.gen(function* () { - const provider = yield* make; - const error = yield* provider - .getChangeRequest({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }) - .pipe(Effect.flip); - - expect(error.operation).toBe("getChangeRequest"); - expect(error.reason).toBe("rate-limited"); - }).pipe( - Effect.provide( - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ - revalidateChecks: (_input, read) => read, - getPullRequestDetail: () => Effect.succeed(openDetail), - listWorkflowRunsRequiringApproval: () => - Effect.fail( - new GitHubApi.GitHubApiRateLimitError({ - host: "github.com", - operation: "listWorkflowRunsRequiringApproval", - }), - ), - getViewerAccess: () => - Effect.succeed({ - canWrite: true, - canTriage: true, - canUpdate: true, - didAuthor: false, - mergeCapabilities: { merge: true, squash: true, rebase: true }, - }), - }), - ), - ), -); - -describe("getViewerPermissions", () => { - const access = { - canWrite: true, - canTriage: true, - canUpdate: true, - didAuthor: false, - mergeCapabilities: { merge: true, squash: true, rebase: true }, - }; - - it.effect("checks fresh access without reading branch details for unrelated operations", () => { - let accessReads = 0; - return Effect.gen(function* () { - const provider = yield* make; - const permissions = yield* provider.getViewerPermissions({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - includeUpdateBranch: false, - }); - - expect(accessReads).toBe(1); - expect(permissions.actions).toContain("merge"); - expect(permissions.actions).not.toContain("update-branch"); - }).pipe( - Effect.provide( - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ - revalidateChecks: (_input, read) => read, - getPullRequestDetail: () => Effect.die("Unexpected detail read"), - getViewerAccess: () => Effect.sync(() => (accessReads++, access)), - }), - ), - ); - }); - - const layerWithDetail = ( - detail: Effect.Effect, - onDetail: (allowReserve: boolean) => void = () => {}, - ) => - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ - revalidateChecks: (_input, read) => read, - getPullRequestDetail: () => - GitHubApi.AllowGitHubReserve.pipe( - Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))), - Effect.flatMap(() => detail), - ), - getViewerAccess: () => Effect.die("Unexpected access read"), - }); - - it.effect("answers access and update-branch from the one detail read", () => - Effect.gen(function* () { - const provider = yield* make; - const permissions = yield* provider.getViewerPermissions({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - expect(permissions.actions).toContain("merge"); - expect(permissions.actions).toContain("update-branch"); - expect(permissions.updateMethods).toEqual(["merge", "rebase"]); - }).pipe( - Effect.provide( - layerWithDetail( - Effect.succeed({ ...openDetail, comparison: { behindBy: 3, viewerCanUpdate: true } }), - ), - ), - ), - ); - - it.effect("uses the GraphQL reserve for manual permission checks", () => { - let allowed: boolean | undefined; - return Effect.gen(function* () { - const provider = yield* make; - yield* provider.getViewerPermissions({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - expect(allowed).toBe(true); - }).pipe( - Effect.provide( - layerWithDetail(Effect.succeed(openDetail), (allowReserve) => (allowed = allowReserve)), - ), - ); - }); - - it.effect("still answers from the light access read when the detail read fails", () => - Effect.gen(function* () { - const provider = yield* make; - const permissions = yield* provider.getViewerPermissions({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - expect(permissions.actions).toContain("merge"); - expect(permissions.actions).not.toContain("update-branch"); - }).pipe( - Effect.provide( - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ - revalidateChecks: (_input, read) => read, - getPullRequestDetail: () => - Effect.fail( - new GitHubPullRequestApi.GitHubPullRequestReadError({ - cwd: "/w", - operation: "getPullRequestDetail", - cause: new Error("head changed"), - }), - ), - getViewerAccess: () => Effect.succeed(access), - }), - ), - ), - ); - - it.effect("withholds update-branch where GitHub could not compare the branch", () => - Effect.gen(function* () { - const provider = yield* make; - const permissions = yield* provider.getViewerPermissions({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - expect(permissions.actions).not.toContain("update-branch"); - expect(permissions.updateMethods).toBeUndefined(); - expect(permissions.actions).toContain("merge"); - }).pipe(Effect.provide(layerWithDetail(Effect.succeed({ ...openDetail, comparison: null })))), - ); -}); - -describe("getChangeRequest commits", () => { - const baseDetail = { - authorId: null, - number: 7, - title: "Pull request 7", - url: "https://github.com/acme/web/pull/7", - author: null, - headBranch: "feat/page", - baseBranch: "main", - state: "open" as const, - isDraft: false, - mergeability: "mergeable" as const, - additions: 1, - deletions: 1, - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - reviewRequestLogins: [], - hasTeamReviewRequest: false, - checksState: null, - labels: [], - body: "", - changedFiles: 1, - mergedAt: null, - closedAt: null, - checks: [], - comments: [], - }; - - const baseThreadComments = { - comments: [], - dismissalsByReviewId: new Map(), - reviewThreads: [], - commentCount: 0, - truncated: false, - reviewThreadsTruncated: false, - reactions: [], - reactionsById: new Map>(), - editedAtById: new Map(), - reviewers: [], - avatarsByLogin: new Map(), - botLogins: new Set(), - commitStats: new Map(), - viewer: { canUpdate: true, didAuthor: false }, - }; - - const layerWith = (commits: GitHubReviewThreadComments["commits"]) => - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ - revalidateChecks: (_input, read) => read, - getPullRequestActivity: () => - Effect.succeed({ - author: baseDetail.author, - comments: baseDetail.comments, - commits: [ - { - oid: "view-oldest", - messageHeadline: "gh pr view's oldest commit", - committedDate: "2026-01-01T00:00:00Z", - authors: [], - }, - ], - }), - listReviewThreadComments: () => Effect.succeed({ ...baseThreadComments, commits }), - }); - - it.effect("prefers the GraphQL commits, which are the newest, over the gh view list", () => - Effect.gen(function* () { - const provider = yield* make; - const detail = yield* provider.getChangeRequestActivity({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - expect(detail.commits.map((commit) => commit.oid)).toEqual(["graphql-newest"]); - }).pipe( - Effect.provide( - layerWith([ - { - oid: "graphql-newest", - messageHeadline: "the newest commit gh pr view drops", - committedDate: "2026-07-06T00:00:00Z", - authors: [], - }, - ]), - ), - ), - ); - - it.effect("falls back to the gh view list when the GraphQL read has no commits", () => - Effect.gen(function* () { - const provider = yield* make; - const detail = yield* provider.getChangeRequestActivity({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - - expect(detail.commits.map((commit) => commit.oid)).toEqual(["view-oldest"]); - }).pipe(Effect.provide(layerWith([]))), - ); -}); - -describe("getChangeRequestActivity dismissed reviews", () => { - const dismissedReview = (body: string) => ({ - id: "PRR_1", - kind: "review" as const, - author: { login: "macroscopeapp", name: null, avatarUrl: null }, - body, - createdAt: "2026-07-03T00:00:00Z", - url: null, - path: null, - reviewState: "DISMISSED", - }); - const threadComments: GitHubReviewThreadComments = { - comments: [], - dismissalsByReviewId: new Map([["PRR_1", "Dismissing prior approval to re-evaluate 9b66581"]]), - reviewThreads: [], - commentCount: 0, - truncated: false, - reviewThreadsTruncated: false, - reactions: [], - reactionsById: new Map(), - editedAtById: new Map([["PRR_1", "2026-07-04T00:00:00Z"]]), - reviewers: [], - avatarsByLogin: new Map(), - botLogins: new Set(["macroscopeapp"]), - commitStats: new Map(), - commits: [], - viewer: { canUpdate: true, didAuthor: false }, - }; - const layerFor = (body: string) => - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ - revalidateChecks: (_input, read) => read, - getPullRequestActivity: () => - Effect.succeed({ author: null, comments: [dismissedReview(body)], commits: [] }), - listReviewThreadComments: () => Effect.succeed(threadComments), - }); - const readActivity = Effect.gen(function* () { - const provider = yield* make; - return yield* provider.getChangeRequestActivity({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); - }); - - it.effect("fills a marker-only dismissed review with the timeline's reason", () => - // Macroscope's approvals carry only an HTML comment, which markdown renders as nothing — - // an empty-string check misses them and the card opens onto nothing. - readActivity.pipe( - Effect.map((activity) => { - expect(activity.comments[0]?.body).toBe("Dismissing prior approval to re-evaluate 9b66581"); - expect(activity.comments[0]?.author?.isBot).toBe(true); - expect(activity.comments[0]?.editedAt).toBe("2026-07-04T00:00:00Z"); - }), - Effect.provide(layerFor("")), - ), - ); - - it.effect("keeps the words of a dismissed review that has its own", () => - readActivity.pipe( - Effect.map((activity) => { - expect(activity.comments[0]?.body).toBe("These findings still stand."); - }), - Effect.provide(layerFor("These findings still stand.")), - ), - ); -}); - -describe("editing", () => { - const rewrites: Array = []; - - it.effect("hands a rewrite to the CLI as the request named it", () => - Effect.gen(function* () { - const provider = yield* make; - - expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: true }); - yield* provider.updateChangeRequest!({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - title: "A better title", - }); - yield* provider.updateComment!({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - commentId: "IC_1", - kind: "review-comment", - body: "Reworded.", - }); - - expect(rewrites).toEqual([ - { - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - title: "A better title", - }, - { - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - commentId: "IC_1", - kind: "review-comment", - body: "Reworded.", - }, - ]); - }).pipe( - Effect.provide( - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ - revalidateChecks: (_input, read) => read, - updatePullRequest: (input) => Effect.sync(() => void rewrites.push(input)), - updateComment: (input) => Effect.sync(() => void rewrites.push(input)), - }), - ), - ), - ); -}); - -describe("loginAvatarUrl", () => { - it("serves a user's picture from the host they belong to", () => { - expect(loginAvatarUrl("octocat", "github.com")).toBe("https://github.com/octocat.png?size=80"); - expect(loginAvatarUrl("octocat", "ghe.example.com")).toBe( - "https://ghe.example.com/octocat.png?size=80", - ); - }); - - it("has nothing for an app, which names no page", () => { - // `dependabot[bot]` has a picture, but not at `/dependabot[bot].png` — a guess that 404s is - // worse than the initials it would replace. - expect(loginAvatarUrl("dependabot[bot]", "github.com")).toBeNull(); - }); - - it("refuses anything that is not a login, rather than building a URL out of it", () => { - for (const login of ["../../etc", "a b", "-leading", "x".repeat(40), ""]) { - expect(loginAvatarUrl(login, "github.com")).toBeNull(); - } - }); -}); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts deleted file mode 100644 index 67f871501ddb2af74eb06c6214c4f4905bcd4145..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ /dev/null @@ -1,672 +0,0 @@ -import * as Effect from "effect/Effect"; -import type { - PullRequestActor, - PullRequestCapabilities, - PullRequestCheck, - PullRequestReaction, - PullRequestViewerPermissions, -} from "@t3tools/contracts"; - -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; -import { - PullRequestProviderError, - type PullRequestProviderFailure, - type ProviderChangeRequestActivity, - type ProviderChangeRequestDetail, - type PullRequestProviderApi, - type ProviderRepositoryRef, -} from "@t3tools/source-control-core/server/PullRequestProvider"; -import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts"; - -const CAPABILITIES: PullRequestCapabilities = { - diff: true, - comment: true, - actions: [ - "merge", - "ready", - "draft", - "close", - "reopen", - "update-branch", - "enable-auto-merge", - "disable-auto-merge", - "revert", - "approve-workflows", - ], - mergeMethods: ["merge", "squash", "rebase"], - updateMethods: ["merge", "rebase"], - search: true, - reactions: true, - viewedFiles: "host", - review: { - inlineComment: true, - reply: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - }, - reviewers: { request: true, listCandidates: true }, - edit: { changeRequest: true, comment: true }, - stacks: true, - stackActions: true, - labels: true, -}; - -/** - * What the signed-in account may do here, from the three things GitHub says about it. - * - * Merging needs a role that can push, which is the one thing a stranger on an open-source - * repository never has. The other four actions go by `viewerCanUpdate`, because the author of a - * pull request may close it, reopen it and move it in and out of draft with no more than read - * access on the repository it was opened against. - * - * Commenting and reviewing are not gated at all: read access is enough to say something and - * enough to approve or ask for changes, which is what open-source review consists of. Resolving a - * conversation is the exception — GitHub allows it to whoever can write, and to the author of the - * pull request the conversation is on. - * - * Asking somebody else for a review needs write access, which is the one thing here an author - * cannot do on their own pull request: GitHub shows an outside contributor the reviewer control - * and refuses the request behind it. - */ -export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions { - return { - ...(access.canWrite ? { stackRebase: true } : {}), - actions: [ - // Arming a merge and taking the arming back are the merge, deferred: whoever may not - // merge here may not leave an instruction to merge later either. - ...(access.canWrite - ? ([ - "merge", - "enable-auto-merge", - "disable-auto-merge", - "revert", - "approve-workflows", - ] as const) - : []), - ...(access.canUpdate ? (["ready", "draft", "close", "reopen"] as const) : []), - // Whether this viewer may update the branch is GitHub's own answer, read with the - // comparison; without it the action is offered to nobody rather than to everybody. - ...(access.canUpdateBranch === true ? (["update-branch"] as const) : []), - ], - comment: true, - resolve: access.canWrite || access.didAuthor, - // Anyone may review a pull request they can see, except their own: GitHub refuses an author's - // approval and their request for changes ("Can not approve your own pull request"), and - // leaves them commenting, which is what an author has to say about their own change anyway. - verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts, - requestReviewers: access.canWrite, - ...(access.canUpdateBranch === true ? { updateMethods: CAPABILITIES.updateMethods } : {}), - // Triage is the one role that labels without writing, which is what triage is for. - labels: access.canTriage, - }; -} - -/** The tags that mean GitHub is out of reach for this account, rather than one request failing. */ -export function gitHubProviderFailure( - error: GitHubPullRequestApi.GitHubPullRequestApiError, -): PullRequestProviderFailure { - switch (error._tag) { - case "GitHubCliMissingError": - return { reason: "missing-tool" }; - case "GitHubNotSignedInError": - case "GitHubHostDisabledError": - case "GitHubApiAuthenticationError": - return { reason: "unauthenticated" }; - case "GitHubApiRateLimitError": - return { - reason: "rate-limited", - ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), - }; - case "SourceControlRateLimitPausedError": - return { reason: "rate-limited", retryAt: error.retryAt }; - case "GitHubApiNotFoundError": - return { reason: "not-found" }; - default: - return { reason: "failed" }; - } -} - -/** - * `gh pr view --json` reports no avatar for anyone, so the ones the GraphQL read collected are - * applied here by login. An actor already carrying one keeps it. - * - * A login GitHub did not answer for falls back to the picture every GitHub install serves at - * `/.png`. The lookup is one more request per repository and can be refused — a rate - * limit, a slow host — and a face that comes and goes between two loads of the same page reads - * as a bug in the page rather than as a request that failed quietly. - */ -function withAvatar( - actor: PullRequestActor | null, - avatarsByLogin: ReadonlyMap, - host: string, - botLogins?: ReadonlySet, -): PullRequestActor | null { - if (actor === null) return actor; - if (botLogins?.has(actor.login)) actor = { ...actor, isBot: true }; - if (actor.avatarUrl !== null) return actor; - const avatarUrl = avatarsByLogin.get(actor.login) ?? loginAvatarUrl(actor.login, host); - return avatarUrl === null ? actor : { ...actor, avatarUrl }; -} - -function withWorkflowApprovals( - checks: ReadonlyArray, - runs: ReadonlyArray, - unavailable: boolean, -): ReadonlyArray { - const representedRunIds = new Set(); - for (const check of checks) { - if (check.status !== "action-required" || check.url === null) continue; - const id = check.url.match(/\/actions\/runs\/(\d+)(?:\/|$)/)?.[1]; - if (id !== undefined) representedRunIds.add(Number(id)); - } - const approvalChecks = runs - .filter((run) => !representedRunIds.has(run.id)) - .map((run): PullRequestCheck => ({ - name: run.name, - status: "action-required", - description: "A maintainer must approve this workflow before it can run.", - url: run.url, - })); - return [ - ...checks, - ...approvalChecks, - ...(unavailable - ? [ - { - name: "Workflow approval status", - status: "action-required" as const, - description: "GitHub could not determine whether workflows are awaiting approval.", - url: null, - }, - ] - : []), - ]; -} - -/** - * Null for anything that is not a plain user login: an app posts as `dependabot[bot]`, which - * names no page, and a guessed URL that 404s is worse than the initials it would replace. - */ -export function loginAvatarUrl(login: string, host: string): string | null { - return /^[a-z0-9][a-z0-9-]{0,38}$/iu.test(login) ? `https://${host}/${login}.png?size=80` : null; -} - -/** True where markdown would render nothing: whitespace, or only HTML comments. */ -const rendersEmpty = (body: string): boolean => - body.replace(//g, "").trim().length === 0; - -export const make = Effect.gen(function* () { - const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; - - const fail = (operation: string) => (error: GitHubPullRequestApi.GitHubPullRequestApiError) => - new PullRequestProviderError({ - provider: "github", - operation, - ...gitHubProviderFailure(error), - detail: error.message, - cause: error, - }); - - const readChecks = (input: ProviderRepositoryRef & { readonly number: number }) => - cli.getPullRequestDetail(input).pipe( - Effect.flatMap((pullRequest) => - (pullRequest.state !== "open" || pullRequest.isCrossRepository !== true - ? Effect.succeed({ - runs: [] as ReadonlyArray, - unavailable: false, - }) - : pullRequest.headSha == null || pullRequest.headRepositoryOwner == null - ? Effect.succeed({ - runs: [] as ReadonlyArray, - unavailable: true, - }) - : cli - .listWorkflowRunsRequiringApproval({ - ...input, - headSha: pullRequest.headSha, - headBranch: pullRequest.headBranch, - headRepositoryOwner: pullRequest.headRepositoryOwner, - isCrossRepository: true, - }) - .pipe( - Effect.matchEffect({ - onFailure: (error) => - error._tag === "GitHubApiRateLimitError" || - error._tag === "SourceControlRateLimitPausedError" - ? Effect.fail(error) - : Effect.succeed({ - runs: [] as ReadonlyArray, - unavailable: true, - }), - onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }), - }), - ) - ).pipe( - Effect.map((workflowApprovals) => ({ - ...pullRequest, - author: withAvatar(pullRequest.author, new Map(), input.host), - checks: withWorkflowApprovals( - pullRequest.checks, - workflowApprovals.runs, - workflowApprovals.unavailable, - ), - ...(workflowApprovals.unavailable - ? {} - : { workflowApprovalsRequired: workflowApprovals.runs.length }), - })), - ), - ), - ); - - const provider: PullRequestProviderApi = { - kind: "github", - capabilities: CAPABILITIES, - getRoutingIdentity: (input) => - cli.getRoutingIdentity(input).pipe(Effect.mapError(fail("routeIdentity"))), - withVerifiedCredential: (input, use) => - cli - .withVerifiedCredential(input, (identity) => use(identity).pipe(Effect.result)) - .pipe(Effect.mapError(fail("routeIdentity")), Effect.flatMap(Effect.fromResult)), - - getViewer: (input) => - cli - .getViewerLogin({ cwd: input.cwd, host: input.host ?? "github.com" }) - .pipe(Effect.mapError(fail("getViewer"))), - - listChangeRequests: (input) => - cli - .listPullRequests({ - cwd: input.cwd, - repository: input.repository, - host: input.host, - state: input.state, - involvement: input.involvement, - viewer: input.viewer, - limit: input.limit, - query: input.query, - cursor: input.cursor, - filters: input.filters, - }) - .pipe( - Effect.mapError(fail("listChangeRequests")), - Effect.flatMap((page) => - cli - .listActorAvatars({ - cwd: input.cwd, - repository: input.repository, - host: input.host, - ids: [...new Set(page.items.flatMap((item) => item.authorId ?? []))], - }) - // A listing without faces is still a listing, so a failed lookup falls back to - // the initials rather than taking the rows down with it. - .pipe( - Effect.orElseSucceed(() => new Map()), - Effect.map((avatarsByLogin) => ({ - ...page, - items: page.items.map((item) => ({ - ...item, - author: withAvatar(item.author, avatarsByLogin, input.host), - })), - })), - ), - ), - ), - - /** - * The same listing for a whole host in one search. The avatar lookup the per-repository read - * needs is not here: a search reports an author's picture itself, so a face costs no request - * of its own — `withAvatar` still stands behind it for the login GitHub answered nothing for. - */ - listChangeRequestsAcross: (input) => - cli - .searchPullRequests({ - cwd: input.cwd, - host: input.host, - repositories: input.repositories, - state: input.state, - involvement: input.involvement, - viewer: input.viewer, - limit: input.limit, - query: input.query, - cursor: input.cursor, - filters: input.filters, - }) - .pipe( - Effect.mapError(fail("listChangeRequestsAcross")), - Effect.map((batch) => ({ - truncated: batch.truncated, - items: batch.items.map((item) => ({ - ...item, - author: withAvatar(item.author, new Map(), input.host), - })), - })), - ), - - listChangeRequestStats: (input) => - cli - .listPullRequestStats({ - cwd: input.cwd, - host: input.host, - changeRequests: input.changeRequests, - }) - .pipe(Effect.mapError(fail("listChangeRequestStats"))), - - getChangeRequestSummary: (input) => - cli.getPullRequestSummary(input).pipe( - // `gh pr view` names the author without an avatar; the login-shaped URL every user - // has stands in, without the second request the listing spends on it. - Effect.map((summary) => ({ - ...summary, - ...(summary.author === undefined - ? {} - : { author: withAvatar(summary.author, new Map(), input.host) }), - })), - Effect.mapError(fail("getChangeRequestSummary")), - ), - - getChangeRequestStack: (input) => - cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))), - - getChangeRequestWatchFingerprint: (input) => - cli - .getPullRequestWatchFingerprint(input) - .pipe(Effect.mapError(fail("getChangeRequestWatchFingerprint"))), - - getChangeRequestPreview: (input) => - cli.getPullRequestPreview(input).pipe(Effect.mapError(fail("getChangeRequestPreview"))), - - getChangeRequestChecks: (input) => - cli.revalidateChecks(input, readChecks(input)).pipe( - Effect.map(({ state, checks }) => ({ state, checks })), - Effect.mapError(fail("getChangeRequestChecks")), - ), - - getChangeRequest: (input) => - readChecks(input).pipe( - Effect.map((pullRequest): ProviderChangeRequestDetail => ({ - ...pullRequest, - author: withAvatar(pullRequest.author, new Map(), input.host), - reviewers: pullRequest.reviewRequestLogins.map((login) => ({ - login, - name: null, - avatarUrl: null, - })), - mergeCapabilities: pullRequest.viewerAccess.mergeCapabilities, - viewerPermissions: gitHubViewerPermissions({ - ...pullRequest.viewerAccess, - canUpdateBranch: pullRequest.comparison?.viewerCanUpdate === true, - }), - baseComparison: - pullRequest.comparison === null || pullRequest.comparison.behindBy === null - ? "unknown" - : pullRequest.comparison.behindBy > 0 - ? "behind" - : "up-to-date", - ...(pullRequest.comparison?.behindBy == null - ? {} - : { behindBy: pullRequest.comparison.behindBy }), - })), - Effect.mapError(fail("getChangeRequest")), - ), - - getChangeRequestActivity: (input) => - Effect.all( - [ - cli.getPullRequestActivity(input), - // Line comments live on review threads, which `gh pr view --json` cannot reach. A - // GraphQL hiccup degrades to a truncated conversation rather than blanking activity. - cli.listReviewThreadComments(input).pipe( - Effect.orElseSucceed(() => ({ - comments: [], - dismissalsByReviewId: new Map(), - reactions: [], - reactionsById: new Map>(), - editedAtById: new Map(), - reviewThreads: [], - commentCount: 0, - truncated: true, - reviewThreadsTruncated: true, - reviewers: [], - avatarsByLogin: new Map(), - botLogins: new Set(), - commitStats: new Map< - string, - { readonly additions: number; readonly deletions: number } - >(), - commits: [], - viewer: { canUpdate: true, didAuthor: false }, - })), - ), - ], - { concurrency: 2 }, - ).pipe( - Effect.mapError(fail("getChangeRequestActivity")), - Effect.map(([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({ - author: withAvatar( - pullRequest.author, - reviewThreads.avatarsByLogin, - input.host, - reviewThreads.botLogins, - ), - reviewers: reviewThreads.reviewers, - reactions: reviewThreads.reactions, - commits: (reviewThreads.commits.length > 0 - ? reviewThreads.commits - : pullRequest.commits - ).map((commit) => ({ - ...commit, - ...reviewThreads.commitStats.get(commit.oid), - authors: commit.authors?.map( - (author) => - withAvatar( - author, - reviewThreads.avatarsByLogin, - input.host, - reviewThreads.botLogins, - ) ?? author, - ), - })), - comments: [...pullRequest.comments, ...reviewThreads.comments] - .map((comment) => ({ - ...comment, - // GitHub keeps the dismissal reason on the timeline event, not on the review, - // so a dismissed review with nothing visible of its own reads its words from - // there. "Visible" and not "empty": bot reviews often carry only an HTML - // marker comment, which markdown renders as nothing. - body: - comment.kind === "review" && - comment.reviewState?.toUpperCase() === "DISMISSED" && - rendersEmpty(comment.body) - ? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body) - : comment.body, - author: withAvatar( - comment.author, - reviewThreads.avatarsByLogin, - input.host, - reviewThreads.botLogins, - ), - // A comment out of `gh pr view --json` carries none of its own: that read - // reports no reaction at all, so they arrive from the GraphQL page by node id. - reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [], - editedAt: reviewThreads.editedAtById.get(comment.id) ?? comment.editedAt ?? null, - })) - .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), - // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two - // are always whole and only the thread walk can stop short of the host. - commentCount: pullRequest.comments.length + reviewThreads.commentCount, - commentsTruncated: reviewThreads.truncated, - reviewThreadsTruncated: reviewThreads.reviewThreadsTruncated, - reviewThreads: reviewThreads.reviewThreads.map((thread) => ({ - ...thread, - comments: thread.comments.map((comment) => ({ - ...comment, - author: withAvatar( - comment.author, - reviewThreads.avatarsByLogin, - input.host, - reviewThreads.botLogins, - ), - })), - })), - })), - ), - - getReviewThreadComments: (input) => - cli.getReviewThreadComments(input).pipe(Effect.mapError(fail("getReviewThreadComments"))), - - getViewerPermissions: (input) => { - const lightAccess = cli - .getViewerAccess({ ...input, allowReserve: true }) - .pipe(Effect.map((access) => gitHubViewerPermissions(access))); - if (input.includeUpdateBranch === false) { - return lightAccess.pipe(Effect.mapError(fail("getViewerPermissions"))); - } - // The core detail already carries the viewer's access, the merge settings, and the base - // 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(GitHubApi.AllowGitHubReserve, true), - Effect.map((pullRequest) => - gitHubViewerPermissions({ - ...pullRequest.viewerAccess, - canUpdateBranch: - pullRequest.state === "open" && pullRequest.comparison?.viewerCanUpdate === true, - }), - ), - Effect.catchIf( - (error) => - error._tag !== "GitHubApiRateLimitError" && - error._tag !== "SourceControlRateLimitPausedError", - () => lightAccess, - ), - Effect.mapError(fail("getViewerPermissions")), - ); - }, - - getDiff: (input) => cli.getPullRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), - - getDiffFileContents: (input) => - cli.getPullRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))), - - getFilesViewed: (input) => - cli.getPullRequestFilesViewed(input).pipe(Effect.mapError(fail("getFilesViewed"))), - - setFilesViewed: (input) => - cli.setPullRequestFilesViewed(input).pipe(Effect.mapError(fail("setFilesViewed"))), - - listReviewerCandidates: (input) => - cli.listReviewerCandidates(input).pipe(Effect.mapError(fail("listReviewerCandidates"))), - - setReviewerRequest: (input) => - cli - .setReviewerRequest({ - cwd: input.cwd, - repository: input.repository, - host: input.host, - number: input.number, - reviewers: input.reviewers, - requested: input.requested, - }) - .pipe(Effect.mapError(fail("setReviewerRequest"))), - - listLabelCandidates: (input) => - cli.listLabelCandidates(input).pipe(Effect.mapError(fail("listLabelCandidates"))), - - setLabels: (input) => - cli - .setLabels({ - cwd: input.cwd, - repository: input.repository, - host: input.host, - number: input.number, - labels: input.labels, - applied: input.applied, - }) - .pipe(Effect.mapError(fail("setLabels"))), - - runAction: (input) => - cli - .runPullRequestAction({ - cwd: input.cwd, - repository: input.repository, - host: input.host, - number: input.number, - action: input.action, - ...(input.removeAgentCreditsOnMerge === true ? { removeAgentCreditsOnMerge: true } : {}), - ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), - ...(input.expectedStackHeads === undefined - ? {} - : { expectedStackHeads: input.expectedStackHeads }), - ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), - ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), - }) - .pipe(Effect.mapError(fail("runAction"))), - - updateChangeRequest: (input) => - cli - .updatePullRequest({ - cwd: input.cwd, - repository: input.repository, - host: input.host, - number: input.number, - ...(input.title === undefined ? {} : { title: input.title }), - ...(input.body === undefined ? {} : { body: input.body }), - }) - .pipe(Effect.mapError(fail("updateChangeRequest"))), - - comment: (input) => cli.commentOnPullRequest(input).pipe(Effect.mapError(fail("comment"))), - - updateComment: (input) => - cli - .updateComment({ - cwd: input.cwd, - repository: input.repository, - host: input.host, - number: input.number, - commentId: input.commentId, - kind: input.kind, - body: input.body, - }) - .pipe(Effect.mapError(fail("updateComment"))), - - submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), - - replyToThread: (input) => - cli - .replyToReviewThread({ - cwd: input.cwd, - repository: input.repository, - host: input.host, - threadId: input.threadId, - body: input.body, - }) - .pipe(Effect.mapError(fail("replyToThread"))), - - setReaction: (input) => - cli - .setReaction({ - cwd: input.cwd, - repository: input.repository, - host: input.host, - number: input.number, - ...(input.subjectId === undefined ? {} : { subjectId: input.subjectId }), - content: input.content, - reacted: input.reacted, - }) - .pipe(Effect.mapError(fail("setReaction"))), - - setThreadResolution: (input) => - cli - .setReviewThreadResolution({ - cwd: input.cwd, - repository: input.repository, - host: input.host, - threadId: input.threadId, - resolved: input.resolved, - }) - .pipe(Effect.mapError(fail("setThreadResolution"))), - }; - - return provider; -}); diff --git a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts index 6d35adc0724669b8ab4998d7aab6e00730ed7119..8271c2f22dc07b0e1a89be4180d7fc9b944e24ca 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts @@ -2,12 +2,12 @@ import { assert, it } from "@effect/vitest"; import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider"; import { bitbucketProviderFailure } from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestProvider"; -import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts"; +import { gitHubProviderFailure } from "@t3tools/source-control-github/server/GitHubPullRequestProvider"; import { gitLabProviderFailure } from "@t3tools/source-control-gitlab/server/GitLabPullRequestProvider"; const cause = new Error("redacted provider failure"); diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 5702957a0ff421ee5aa1b4c54c5fd585fd610b93..66607a4c8abd07f8baf0e6c34dbc2736d1ba96f6 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -3,9 +3,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import type { SourceControlProviderKind } from "@t3tools/contracts"; -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; -import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider"; @@ -36,24 +33,14 @@ export function fromProviders( * @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const providers = yield* Effect.all([GitHubPullRequestProvider.make]); const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => driver.make.pipe(Effect.map((instance) => instance.pullRequests)), ); - return fromProviders([ - ...providers, - ...drivers.filter((provider): provider is PullRequestProviderApi => provider !== null), - ]); + return fromProviders( + drivers.filter((provider): provider is PullRequestProviderApi => provider !== null), + ); }); export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( - Layer.provide( - GitHubPullRequestApi.layer.pipe( - Layer.provide( - // Built here too, so a registry built alone still has one budget and pause per host. - GitHubApi.layerWithDependencies, - ), - ), - ), Layer.provide(BuiltInDrivers.layer), ); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index fb14ef896cd51a3721ee56db7c6fe35adc229e53..0295673350b7304ce18b980fc2e5e0a2fe5e9da8 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/GitHubApi.ts"; +import { AllowGitHubReserve } from "@t3tools/source-control-github/server/GitHubApi"; 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.test.ts b/apps/server/src/pullRequest/gitHubConditionalChecks.test.ts deleted file mode 100644 index 79048b1d74535823194a7c0816755bca84ffd8ad..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/gitHubConditionalChecks.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import * as TestClock from "effect/testing/TestClock"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as Redacted from "effect/Redacted"; -import type { PullRequestCheck } from "@t3tools/contracts"; - -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; - -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); - -const reference = { cwd: "/repo", repository: "acme/web", host: "github.com", number: 1 }; -const credential = { - host: "github.com", - token: Redacted.make("token"), - credentialFingerprint: "one", -}; - -it.effect( - "reuses unchanged checks and catches reruns, later pages, fork approvals, pushes and account changes", - () => - Effect.gen(function* () { - let sha = "a".repeat(40); - let changed = ""; - let reads = 0; - const requests: string[] = []; - const known: Array = []; - const revalidate = yield* makeChecksRevalidator.pipe( - Effect.provide( - Layer.mock(GitHubApi.GitHubApi)({ - rest: ({ path, ifNoneMatch }) => - Effect.sync(() => { - requests.push(path); - const head = path.endsWith("/pulls/1"); - const modified = - ifNoneMatch === undefined || (path.includes(changed) && changed !== ""); - const next = path.includes("check-runs") && path.endsWith("page=1"); - const runs = path.includes("/actions/runs"); - return { - status: modified ? 200 : 304, - headers: modified - ? { - etag: `"${sha}-${changed}"`, - ...(next ? { link: '; rel="next"' } : {}), - } - : {}, - body: !modified - ? "" - : head - ? encodeJson({ head: { sha, repo: { id: 2 } }, base: { repo: { id: 1 } } }) - : runs - ? encodeJson({ - workflow_runs: [ - { id: 9, status: "completed", conclusion: "action_required" }, - ], - }) - : "{}", - truncated: false, - invalidUtf8: false, - }; - }), - }), - ), - ); - const read = Effect.gen(function* () { - known.push(yield* KnownWorkflowRuns); - reads++; - return { - state: "open" as const, - checks: [ - { name: "build", status: "success", description: null, url: null }, - ] satisfies PullRequestCheck[], - headSha: sha, - workflowApprovalsRequired: 0, - }; - }); - const poll = (identity = credential) => - revalidate(reference, read).pipe( - Effect.provideService(GitHubApi.PinnedGitHubCredential, identity), - ); - yield* poll(); - expect(reads).toBe(1); - // The fork's runs were just confirmed, so the read is handed them instead of listing them. - expect(known[0]).toEqual({ - headSha: sha, - runs: [{ id: 9, status: "completed", conclusion: "action_required" }], - }); - requests.length = 0; - yield* poll(); - expect(reads).toBe(1); - expect(requests).toHaveLength(5); - for (const endpoint of [ - "check-runs?filter=all&per_page=100&page=2", - "/status", - "/actions/runs", - "/pulls/1", - ]) { - changed = endpoint; - yield* poll(); - changed = ""; - yield* poll(); - } - expect(reads).toBe(5); - sha = "b".repeat(40); - changed = "/pulls/1"; - requests.length = 0; - yield* poll(); - expect(reads).toBe(6); - expect( - requests - .filter((endpoint) => endpoint.includes("/commits/")) - .every((endpoint) => endpoint.includes(sha)), - ).toBe(true); - changed = ""; - yield* poll({ ...credential, credentialFingerprint: "two" }); - expect(reads).toBe(7); - yield* TestClock.adjust("5 minutes"); - yield* poll(); - expect(reads).toBe(8); - }), -); - -it.effect("does not retain failed or incomplete reads, and supports hosts without ETags", () => - Effect.gen(function* () { - const sha = "a".repeat(40); - let etags = true; - let unavailable = false; - let fail = true; - let complete = true; - let reads = 0; - const revalidate = yield* makeChecksRevalidator.pipe( - Effect.provide( - Layer.mock(GitHubApi.GitHubApi)({ - rest: ({ path, ifNoneMatch }) => - unavailable - ? Effect.fail( - new GitHubApi.GitHubApiResponseError({ - host: "github.com", - operation: "revalidateChecks", - status: 502, - }), - ) - : Effect.succeed( - ifNoneMatch !== undefined && etags - ? { status: 304, headers: {}, body: "", truncated: false, invalidUtf8: false } - : { - status: 200, - headers: etags ? { etag: '"one"' } : {}, - body: path.endsWith("/pulls/1") - ? encodeJson({ - head: { sha, repo: { id: 1 } }, - base: { repo: { id: 1 } }, - }) - : "{}", - truncated: false, - invalidUtf8: false, - }, - ), - }), - ), - ); - const read = Effect.suspend(() => { - reads++; - return fail - ? Effect.fail( - new GitHubApi.GitHubApiResponseError({ - host: "github.com", - operation: "read", - status: 500, - }), - ) - : Effect.succeed({ - state: "open" as const, - checks: [], - headSha: sha, - ...(complete ? { workflowApprovalsRequired: 0 } : {}), - }); - }); - const poll = () => - revalidate(reference, read).pipe( - Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), - ); - yield* poll().pipe(Effect.flip); - fail = false; - complete = false; - yield* poll(); - yield* poll(); - expect(reads).toBe(3); - complete = true; - yield* poll(); - yield* poll(); - expect(reads).toBe(4); - yield* TestClock.adjust("5 minutes"); - complete = false; - yield* poll(); - yield* poll(); - expect(reads).toBe(6); - complete = true; - unavailable = true; - yield* poll(); - expect(reads).toBe(7); - unavailable = false; - yield* poll(); - yield* poll(); - expect(reads).toBe(8); - etags = false; - yield* poll(); - yield* poll(); - expect(reads).toBe(10); - }), -); - -it.effect("falls back when REST checks are unavailable without retrying unsupported probes", () => - Effect.gen(function* () { - let probes = 0; - let reads = 0; - const revalidate = yield* makeChecksRevalidator.pipe( - Effect.provide( - Layer.mock(GitHubApi.GitHubApi)({ - rest: () => { - probes++; - return Effect.fail( - new GitHubApi.GitHubApiNotFoundError({ - host: "github.com", - operation: "revalidateChecks", - }), - ); - }, - }), - ), - ); - const read = Effect.sync(() => { - reads++; - return { state: "open" as const, checks: [] }; - }); - for (let tick = 0; tick < 2; tick++) - yield* revalidate(reference, read).pipe( - Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), - ); - expect(probes).toBe(1); - expect(reads).toBe(2); - }), -); diff --git a/apps/server/src/pullRequest/gitHubConditionalChecks.ts b/apps/server/src/pullRequest/gitHubConditionalChecks.ts deleted file mode 100644 index 353089ebf0f9648d0156fdbb1304bd2f0722a0fc..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/gitHubConditionalChecks.ts +++ /dev/null @@ -1,193 +0,0 @@ -import * as Clock from "effect/Clock"; -import * as Cache from "effect/Cache"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Semaphore from "effect/Semaphore"; -import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts"; - -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts"; -import type { GitHubPullRequestApiError } from "./GitHubPullRequestApi.ts"; -import type { ProviderRepositoryRef } from "@t3tools/source-control-core/server/PullRequestProvider"; - -const HeadSchema = Schema.Struct({ - head: Schema.Struct({ - sha: Schema.String.check(Schema.isPattern(/^[a-f0-9]{40,64}$/)), - // Null once the fork a pull request came from has been deleted. - repo: Schema.NullOr(Schema.Struct({ id: PositiveInt })), - }), - base: Schema.Struct({ repo: Schema.Struct({ id: PositiveInt }) }), -}); -const decodeHead = Schema.decodeUnknownEffect(Schema.fromJsonString(HeadSchema)); - -const WorkflowRunSchema = Schema.Struct({ - id: Schema.Int, - name: Schema.optional(Schema.NullOr(Schema.String)), - html_url: Schema.optional(Schema.NullOr(Schema.String)), - status: Schema.optional(Schema.NullOr(Schema.String)), - conclusion: Schema.optional(Schema.NullOr(Schema.String)), - head_branch: Schema.optional(Schema.NullOr(Schema.String)), -}); -const decodeWorkflowRuns = Schema.decodeUnknownOption( - Schema.fromJsonString(Schema.Struct({ workflow_runs: Schema.Array(WorkflowRunSchema) })), -); - -export type KnownWorkflowRun = typeof WorkflowRunSchema.Type; - -/** - * Every `pull_request` workflow run of one head, as the revalidator just confirmed them against - * GitHub. A read running under it filters these rather than asking for the runs again. - */ -export const KnownWorkflowRuns = Context.Reference<{ - readonly headSha: string; - readonly runs: ReadonlyArray; -} | null>("t3/pullRequest/KnownWorkflowRuns", { defaultValue: () => null }); - -type Validator = { etag: string | undefined; next: boolean; body: string }; - -/** A host that answers these with one of them has no conditional REST for it at all. */ -const UNSUPPORTED_STATUSES = new Set([404, 405, 501]); - -export const makeChecksRevalidator = Effect.gen(function* () { - const api = yield* GitHubApi.GitHubApi; - const entries = yield* Cache.makeWith( - (_key: string) => - Effect.sync(() => ({ - gate: Semaphore.makeUnsafe(1), - validators: new Map(), - head: null as typeof HeadSchema.Type | null, - value: null as PullRequestChecks | null, - readAt: 0, - supported: true, - })), - { capacity: 128, timeToLive: () => "30 minutes" }, - ); - return ( - input: ProviderRepositoryRef & { readonly number: number }, - read: Effect.Effect< - Pick & { - workflowApprovalsRequired?: number; - }, - GitHubPullRequestApiError - >, - ) => - Effect.gen(function* () { - const credential = yield* GitHubApi.PinnedGitHubCredential; - if (credential === null) return yield* read; - const key = `${credential.credentialFingerprint}\0${input.host}\0${input.repository}\0${input.number}`; - const entry = yield* Cache.get(entries, key); - return yield* entry.gate.withPermit( - Effect.gen(function* () { - if (!entry.supported) return yield* read; - const fail = (status: number) => - new GitHubApi.GitHubApiResponseError({ - host: input.host, - operation: "revalidateChecks", - status, - }); - /** Whether the endpoint has a next page, or null where the host cannot say. */ - const get = (endpoint: string, head = false) => - Effect.gen(function* () { - const previous = entry.validators.get(endpoint); - const response = yield* api - .rest({ - host: input.host, - operation: "revalidateChecks", - path: endpoint, - ...(previous?.etag ? { ifNoneMatch: previous.etag } : {}), - }) - .pipe( - Effect.catchTags({ - GitHubApiNotFoundError: () => - Effect.sync(() => { - entry.supported = false; - return null; - }), - GitHubApiResponseError: (error) => - Effect.sync(() => { - entry.supported = !UNSUPPORTED_STATUSES.has(error.status); - return null; - }), - }), - ); - if (response === null) { - entry.value = null; - return null; - } - if (response.status === 304 && previous) return previous.next; - if (response.status !== 200 || response.truncated) { - return yield* fail(response.status); - } - entry.value = null; - if (head) { - const decoded = yield* decodeHead(response.body).pipe( - Effect.mapError(() => fail(response.status)), - ); - if (entry.head?.head.sha !== decoded.head.sha) entry.validators.clear(); - entry.head = decoded; - } - const validator = { - etag: response.headers["etag"]?.trim(), - next: /rel="next"/.test(response.headers["link"] ?? ""), - body: response.body, - }; - entry.validators.set(endpoint, validator); - return validator.next; - }); - const root = `repos/${input.repository}`; - if ((yield* get(`${root}/pulls/${input.number}`, true)) === null) return yield* read; - const head = entry.head; - if (head === null) return yield* fail(200); - const runsEndpoint = - head.head.repo?.id !== head.base.repo.id - ? `${root}/actions/runs?head_sha=${head.head.sha}&event=pull_request` - : null; - const endpoints = [ - `${root}/commits/${head.head.sha}/check-runs?filter=all`, - `${root}/commits/${head.head.sha}/status`, - ...(runsEndpoint === null ? [] : [runsEndpoint]), - ]; - const runPages: Array = []; - for (const endpoint of endpoints) { - for (let page = 1; ; page++) { - if (page > 100) return yield* fail(200); - const paged = `${endpoint}${endpoint.includes("?") ? "&" : "?"}per_page=100&page=${page}`; - const next = yield* get(paged); - if (next === null) return yield* read; - if (endpoint === runsEndpoint) runPages.push(paged); - if (!next) break; - } - } - const now = yield* Clock.currentTimeMillis; - if (entry.value !== null && now - entry.readAt < 5 * 60_000) return entry.value; - // Every page of the head's runs was just confirmed current, so the read filters them - // rather than listing the runs a second time. - const pages = runPages.map((endpoint) => - decodeWorkflowRuns(entry.validators.get(endpoint)?.body ?? ""), - ); - const known = - runsEndpoint !== null && pages.every(Option.isSome) - ? { - headSha: head.head.sha, - runs: pages.flatMap((page) => - Option.isSome(page) ? page.value.workflow_runs : [], - ), - } - : null; - const fresh = yield* read.pipe(Effect.provideService(KnownWorkflowRuns, known)); - const value = { - state: fresh.state, - checks: fresh.checks, - }; - entry.value = - fresh.workflowApprovalsRequired !== undefined && fresh.headSha === head.head.sha - ? value - : null; - entry.readAt = now; - return value; - }), - ); - }); -}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts deleted file mode 100644 index 06e25c6e4709a90c6238fce99546b176cfa04d1b..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ /dev/null @@ -1,2111 +0,0 @@ -import * as Result from "effect/Result"; -import { describe, expect, it } from "vite-plus/test"; - -import { - buildPullRequestSummariesGraphQlQuery, - buildPullRequestWatchFingerprintsGraphQlQuery, - decodePullRequestSummariesJson, - decodePullRequestWatchFingerprintsJson, - buildReviewSubmission, - buildPullRequestStackMembershipsGraphQlQuery, - decodePullRequestStackMembershipsJson, - buildReviewerRequest, - buildSetFilesViewedGraphQlMutation, - decodePullRequestActivityJson, - decodePullRequestDetailJson, - decodePullRequestFilesJson, - decodePullRequestFilesViewedJson, - decodePullRequestListJson, - decodePullRequestNodeIdJson, - decodePullRequestSearchJson, - decodePullRequestStacksJson, - decodeLabelCandidatesJson, - decodeReviewerCandidatesJson, - decodeReviewThreadCommentsJson, - decodeReviewThreadsJson, - decodeViewerPermissionsJson, - decodeWorkflowRunsJson, - reviewThreadConversation, - REVIEW_THREADS_GRAPHQL_QUERY, - pullRequestCoreGraphQlQuery, - pullRequestSearchGraphQlQuery, -} from "./gitHubPullRequestJson.ts"; - -/** - * Rows as a repository's own GraphQL list answers them. Entries may use flat shapes for brevity: - * `reviewRequests` and `latestReviews` as arrays, and `checks` as the rollup state. - */ -function listJson(entries: ReadonlyArray>): string { - return JSON.stringify({ - data: { - repository: { - pullRequests: { - pageInfo: { hasNextPage: false }, - nodes: entries.map(({ reviewRequests, latestReviews, checks, ...entry }) => ({ - number: 1, - title: "Add the pull requests page", - url: "https://github.com/pingdotgg/t3code/pull/1", - headRefName: "feat/page", - baseRefName: "main", - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - ...(reviewRequests === undefined - ? {} - : { - reviewRequests: { - nodes: (reviewRequests as ReadonlyArray).map((requestedReviewer) => ({ - requestedReviewer, - })), - }, - }), - ...(latestReviews === undefined ? {} : { latestReviews: { nodes: latestReviews } }), - ...(checks === undefined - ? {} - : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }), - ...entry, - })), - }, - }, - }, - }); -} - -function expectSuccess(result: Result.Result): A { - expect(Result.isSuccess(result)).toBe(true); - if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); - return result.success; -} - -describe("pull request list decoding", () => { - it("treats a merge timestamp as merged even when the state still says closed", () => { - const [entry] = expectSuccess( - decodePullRequestListJson(listJson([{ state: "CLOSED", mergedAt: "2026-07-03T00:00:00Z" }])), - ).items; - expect(entry?.state).toBe("merged"); - }); - - it("normalizes mergeability and defaults unknown values", () => { - const batch = expectSuccess( - decodePullRequestListJson( - listJson([{ mergeable: "CONFLICTING" }, { mergeable: "SOMETHING_NEW" }, {}]), - ), - ); - expect(batch.items.map((entry) => entry.mergeability)).toEqual([ - "conflicting", - "unknown", - "unknown", - ]); - }); - - it("keeps user review requests and drops team ones, which are not logins", () => { - const [entry] = expectSuccess( - decodePullRequestListJson( - listJson([{ reviewRequests: [{ login: "octocat" }, { slug: "web-platform" }] }]), - ), - ).items; - expect(entry?.reviewRequestLogins).toEqual(["octocat"]); - }); - - it("normalizes the review decision and reports nothing for one GitHub does not summarize", () => { - const batch = expectSuccess( - decodePullRequestListJson( - listJson([ - { reviewDecision: "APPROVED" }, - { reviewDecision: "CHANGES_REQUESTED" }, - { reviewDecision: "REVIEW_REQUIRED" }, - { reviewDecision: null }, - ]), - ), - ); - expect(batch.items.map((entry) => entry.reviewDecision)).toEqual([ - "approved", - "changes-requested", - "review-required", - null, - ]); - }); - - it("takes the verdict from the latest reviews when GitHub summarizes none, as for a bot's approval", () => { - const batch = expectSuccess( - decodePullRequestListJson( - listJson([ - { - reviewDecision: null, - latestReviews: [{ author: { login: "macroscopeapp" }, state: "APPROVED" }], - }, - { - reviewDecision: "REVIEW_REQUIRED", - latestReviews: [ - { author: { login: "octocat" }, state: "APPROVED" }, - { author: { login: "hubot" }, state: "CHANGES_REQUESTED" }, - ], - }, - { - reviewDecision: "APPROVED", - latestReviews: [{ author: { login: "hubot" }, state: "CHANGES_REQUESTED" }], - }, - { - reviewDecision: null, - latestReviews: [{ author: { login: "octocat" }, state: "COMMENTED" }], - }, - ]), - ), - ); - expect(batch.items.map((entry) => entry.reviewDecision)).toEqual([ - "approved", - "changes-requested", - "approved", - null, - ]); - }); - - it("rolls the head commit's rollup up to the one word a row has space for", () => { - const batch = expectSuccess( - decodePullRequestListJson( - listJson([ - { checks: "FAILURE" }, - { checks: "PENDING" }, - { checks: "SUCCESS" }, - { checks: "ERROR" }, - { checks: "EXPECTED" }, - {}, - ]), - ), - ); - expect(batch.items.map((entry) => entry.checksState)).toEqual([ - "failing", - "pending", - "passing", - "failing", - "pending", - null, - ]); - }); - - it("skips malformed entries but still counts them, so paging does not stop early", () => { - const batch = expectSuccess( - decodePullRequestListJson(listJson([{}, { number: "not-a-number" }])), - ); - expect(batch.items).toHaveLength(1); - expect(batch.rawCount).toBe(2); - }); -}); - -describe("pull request search decoding", () => { - function searchJson(rollupStates: ReadonlyArray): string { - return JSON.stringify({ - data: { - search: { - pageInfo: { hasNextPage: false }, - nodes: rollupStates.map((state, index) => ({ - number: index + 1, - title: "Add the pull requests page", - url: "https://github.com/pingdotgg/t3code/pull/1", - headRefName: "feat/page", - baseRefName: "main", - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - repository: { nameWithOwner: "pingdotgg/t3code" }, - commits: { - nodes: [{ commit: { statusCheckRollup: state === null ? null : { state } } }], - }, - })), - }, - }, - }); - } - - it("keeps stack membership beside search results without extra per-PR reads", () => { - const raw = JSON.parse(searchJson(["SUCCESS", null])); - raw.data.search.nodes[0].stack = { number: 3, size: 2, baseRefName: "main" }; - raw.data.search.nodes[0].stackEntry = { position: 1 }; - const batch = expectSuccess(decodePullRequestSearchJson(JSON.stringify(raw))); - expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, position: 1, base: "main" }); - expect(batch.items[1]?.stack).toBeUndefined(); - expect(pullRequestSearchGraphQlQuery(20, true)).toContain("stackEntry"); - expect(pullRequestSearchGraphQlQuery(20)).not.toContain("stackEntry"); - }); - - it("maps the rollup enum the search answers with onto the same three words", () => { - // The search asks GitHub for the verdict rather than the checks behind it, so this path sees - // one enum where the listing sees an array. - const batch = expectSuccess( - decodePullRequestSearchJson( - searchJson(["SUCCESS", "FAILURE", "ERROR", "PENDING", "EXPECTED", null]), - ), - ); - expect(batch.items.map((entry) => entry.checksState)).toEqual([ - "passing", - "failing", - "failing", - "pending", - "pending", - null, - ]); - }); -}); - -describe("pull request detail decoding", () => { - const detailJson = JSON.stringify({ - number: 7, - title: "Detail", - url: "https://github.com/pingdotgg/t3code/pull/7", - headRefName: "feat/detail", - baseRefName: "main", - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-05T00:00:00Z", - body: "Body", - statusCheckRollup: [ - { __typename: "CheckRun", name: "build", status: "IN_PROGRESS" }, - { __typename: "CheckRun", name: "test", status: "COMPLETED", conclusion: "FAILURE" }, - { __typename: "StatusContext", context: "ci/legacy", state: "SUCCESS" }, - ], - comments: [{ id: "c1", body: "second", createdAt: "2026-07-04T00:00:00Z" }], - reviews: [ - { id: "r1", body: "first", state: "CHANGES_REQUESTED", submittedAt: "2026-07-03T00:00:00Z" }, - { id: "r2", body: " ", state: "APPROVED", submittedAt: "2026-07-06T00:00:00Z" }, - ], - commits: [ - { - oid: "abc1234", - messageHeadline: "Ship the timeline", - committedDate: "2026-07-05T00:00:00Z", - authors: [ - { login: "octocat", name: "Octo Cat", email: "octo@example.com" }, - { name: "Pair Author", email: "pair@example.com" }, - ], - }, - ], - }); - - /** The same conversation as the GraphQL activity query answers it. */ - const activityJson = (raw: Record) => - JSON.stringify({ - data: { - repository: { - pullRequest: { - comments: { nodes: raw["comments"] ?? [] }, - reviews: { nodes: raw["reviews"] ?? [] }, - commits: { - nodes: ((raw["commits"] ?? []) as ReadonlyArray>).map( - ({ authors, ...commit }) => ({ - commit: { - ...commit, - authors: { - nodes: ((authors ?? []) as ReadonlyArray>).map( - ({ login, ...author }) => ({ - ...author, - ...(login === undefined ? {} : { user: { login } }), - }), - ), - }, - }, - }), - ), - }, - }, - }, - }, - }); - const activity = (raw: Record) => - Result.map(decodePullRequestActivityJson(activityJson(raw)), (page) => ({ - comments: page.remarks.toSorted((left, right) => - left.createdAt.localeCompare(right.createdAt), - ), - commits: page.commits ?? [], - })); - - it("maps check-run status and commit-status state onto one vocabulary", () => { - const detail = expectSuccess(decodePullRequestDetailJson(detailJson)); - expect(detail.checks.map((check) => [check.name, check.status])).toEqual([ - ["build", "pending"], - ["test", "failure"], - ["ci/legacy", "success"], - ]); - }); - - it("keeps what branch protection requires, and asks for it on github.com only", () => { - const raw = JSON.parse(detailJson) as Record; - const detail = expectSuccess( - decodePullRequestDetailJson( - JSON.stringify({ - ...raw, - statusCheckRollup: [ - { __typename: "CheckRun", name: "test", status: "IN_PROGRESS", isRequired: true }, - { __typename: "StatusContext", context: "bot", state: "PENDING", isRequired: false }, - { __typename: "StatusContext", context: "legacy", state: "SUCCESS" }, - ], - }), - ), - ); - expect(detail.checks.map((check) => check.required)).toEqual([true, false, undefined]); - expect(pullRequestCoreGraphQlQuery("github.com")).toContain("isRequired"); - expect(pullRequestCoreGraphQlQuery("github.example.com")).not.toContain("isRequired"); - }); - - it("keeps a workflow waiting for approval out of the passing state", () => { - const raw = JSON.parse(detailJson) as Record; - const detail = expectSuccess( - decodePullRequestDetailJson( - JSON.stringify({ - ...raw, - statusCheckRollup: [ - { __typename: "CheckRun", name: "build", status: "COMPLETED", conclusion: "SUCCESS" }, - { - __typename: "CheckRun", - name: "contributor tests", - status: "COMPLETED", - conclusion: "ACTION_REQUIRED", - }, - ], - }), - ), - ); - - expect(detail.checks.map((check) => check.status)).toEqual(["success", "action-required"]); - expect(detail.checksState).toBe("pending"); - }); - - it("decodes workflow runs that can be approved", () => { - expect( - expectSuccess( - decodeWorkflowRunsJson( - JSON.stringify({ - workflow_runs: [ - { id: 10, name: "contributor tests", html_url: "https://example.com/10" }, - { id: 11, name: null, html_url: null }, - ], - }), - ), - ).runs, - ).toEqual([ - { id: 10, name: "contributor tests", url: "https://example.com/10" }, - { id: 11, name: "Workflow run 11", url: null }, - ]); - }); - - it("reads an auto-merge request and strategy, its null as off and its absence as neither", () => { - const raw = JSON.parse(detailJson) as Record; - const armed = (entry: Record) => - expectSuccess(decodePullRequestDetailJson(JSON.stringify({ ...raw, ...entry }))); - - expect( - armed({ autoMergeRequest: { enabledBy: { login: "octocat" }, mergeMethod: "SQUASH" } }), - ).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" }); - expect(armed({ autoMergeRequest: null }).autoMergeEnabled).toBe(false); - // `gh` not answering for the field at all is not GitHub saying the merge is unarmed. - expect(armed({}).autoMergeEnabled).toBeUndefined(); - }); - - it("shows a re-running check once, as the run that is happening now", () => { - // What `statusCheckRollup` reports while a workflow is being re-run: the same check twice, - // the finished run and the one that replaced it, with no id to tell them apart. - const raw = JSON.parse(detailJson) as Record; - const detail = expectSuccess( - decodePullRequestDetailJson( - JSON.stringify({ - ...raw, - statusCheckRollup: [ - { - __typename: "CheckRun", - name: "Prepare PR size config", - workflowName: "PR Size", - status: "COMPLETED", - conclusion: "SUCCESS", - startedAt: "2026-08-11T16:06:20Z", - completedAt: "2026-08-11T16:06:25Z", - }, - { - __typename: "CheckRun", - name: "Prepare PR size config", - workflowName: "PR Size", - status: "IN_PROGRESS", - conclusion: "", - startedAt: "2026-08-11T17:01:04Z", - completedAt: "0001-01-01T00:00:00Z", - }, - ], - }), - ), - ); - - expect(detail.checks.map((check) => [check.name, check.status])).toEqual([ - ["Prepare PR size config", "pending"], - ]); - expect(detail.checksState).toBe("pending"); - }); - - it("merges reviews with comments in time order and keeps a bodyless approval", () => { - const detail = expectSuccess(activity(JSON.parse(detailJson) as Record)); - // r2 approved without writing anything, which is still the event worth seeing. - expect(detail.comments.map((comment) => comment.id)).toEqual(["r1", "c1", "r2"]); - expect(detail.comments.at(-1)?.reviewState).toBe("APPROVED"); - }); - - it("keeps every attributed commit author, including an unlinked signature", () => { - const detail = expectSuccess(activity(JSON.parse(detailJson) as Record)); - expect(detail.commits[0]?.authors).toEqual([ - { login: "octocat", name: "Octo Cat", avatarUrl: null }, - { login: "Pair Author", name: "Pair Author", avatarUrl: null }, - ]); - }); - - it("drops the bodyless review GitHub opens to hold line comments", () => { - const raw = JSON.parse(detailJson) as Record; - const detail = expectSuccess( - activity({ - ...raw, - reviews: [ - // What a reviewer leaving inline comments produces: a container with a state but - // nothing to read. Its comments come from the review threads instead. - { id: "r4", body: "", state: "COMMENTED", submittedAt: "2026-07-07T00:00:00Z" }, - { - id: "r5", - body: "Looks good.", - state: "COMMENTED", - submittedAt: "2026-07-08T00:00:00Z", - }, - ], - }), - ); - - expect(detail.comments.map((comment) => comment.id)).toEqual(["c1", "r5"]); - }); - - it.each(["APPROVED", "CHANGES_REQUESTED", "DISMISSED"])( - "keeps a bodyless %s review, which is the event itself", - (state) => { - const raw = JSON.parse(detailJson) as Record; - const detail = expectSuccess( - activity({ - ...raw, - reviews: [{ id: "r6", body: "", state, submittedAt: "2026-07-07T00:00:00Z" }], - }), - ); - - expect(detail.comments.map((comment) => comment.id)).toContain("r6"); - }, - ); - - it("drops a review that carries neither a body nor a state", () => { - const raw = JSON.parse(detailJson) as Record; - const detail = expectSuccess( - activity({ - ...raw, - reviews: [{ id: "r3", body: " ", submittedAt: "2026-07-07T00:00:00Z" }], - }), - ); - expect(detail.comments.map((comment) => comment.id)).toEqual(["c1"]); - }); -}); - -describe("review thread decoding", () => { - const threadsJson = ( - nodes: ReadonlyArray>, - totalCount = nodes.length, - pageInfo: Record = { hasNextPage: false, endCursor: null }, - ): string => - JSON.stringify({ - data: { repository: { pullRequest: { reviewThreads: { totalCount, pageInfo, nodes } } } }, - }); - - /** The same query carries the review roster, so it is built alongside the threads. */ - const reviewJson = (input: { - readonly requested?: ReadonlyArray; - readonly reviewed?: ReadonlyArray; - }): string => - JSON.stringify({ - data: { - repository: { - pullRequest: { - reviewThreads: { totalCount: 0, nodes: [] }, - reviewRequests: { - nodes: (input.requested ?? []).map((r) => ({ requestedReviewer: r })), - }, - latestReviews: { nodes: (input.reviewed ?? []).map((a) => ({ author: a })) }, - }, - }, - }, - }); - - it("keeps a reviewer who has already reviewed, app or person, with their avatar", () => { - const result = expectSuccess( - decodeReviewThreadsJson( - reviewJson({ - requested: [{ login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" }], - // An app that has reviewed is no longer an outstanding request, which is why asking - // only for requests reported nobody on a pull request a bot had reviewed. - reviewed: [ - { - __typename: "Bot", - login: "macroscopeapp", - avatarUrl: "https://avatars/in/900172.png", - }, - ], - }), - ), - ); - - expect(result.botLogins).toEqual(new Set(["macroscopeapp"])); - expect(result.reviewers).toEqual([ - { login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" }, - { - login: "macroscopeapp", - name: null, - avatarUrl: "https://avatars/in/900172.png", - isBot: true, - }, - ]); - }); - - it("carries per-commit line counts from the pull-request connection", () => { - const result = expectSuccess( - decodeReviewThreadsJson( - JSON.stringify({ - data: { - repository: { - pullRequest: { - reviewThreads: { totalCount: 0, nodes: [] }, - commits: { - nodes: [ - { commit: { oid: "abc123", additions: 18, deletions: 7 } }, - { commit: { oid: "def456", additions: 3, deletions: 0 } }, - ], - }, - }, - }, - }, - }), - ), - ); - - expect([...result.commitStats]).toEqual([ - ["abc123", { additions: 18, deletions: 7 }], - ["def456", { additions: 3, deletions: 0 }], - ]); - }); - - it("omits misleading line counts from merge commits", () => { - const result = expectSuccess( - decodeReviewThreadsJson( - JSON.stringify({ - data: { - repository: { - pullRequest: { - reviewThreads: { totalCount: 0, nodes: [] }, - commits: { - nodes: [ - { - commit: { - oid: "merge123", - additions: 36_858, - deletions: 12_928, - parents: { totalCount: 2 }, - }, - }, - ], - }, - }, - }, - }, - }), - ), - ); - - expect([...result.commitStats]).toEqual([]); - }); - - it("decodes the newest commits off the same connection, oldest to newest", () => { - const result = expectSuccess( - decodeReviewThreadsJson( - JSON.stringify({ - data: { - repository: { - pullRequest: { - reviewThreads: { totalCount: 0, nodes: [] }, - commits: { - nodes: [ - { - commit: { - oid: "abc123", - messageHeadline: "Ship the timeline", - committedDate: "2026-07-05T00:00:00Z", - additions: 18, - deletions: 7, - authors: { nodes: [{ name: "Julius", user: { login: "julius" } }] }, - }, - }, - { - commit: { - oid: "def456", - messageHeadline: "Fix the flaky test", - committedDate: "2026-07-06T00:00:00Z", - }, - }, - ], - }, - }, - }, - }, - }), - ), - ); - - expect(result.commits).toEqual([ - { - oid: "abc123", - messageHeadline: "Ship the timeline", - committedDate: "2026-07-05T00:00:00Z", - authors: [{ login: "julius", name: "Julius", avatarUrl: null }], - }, - { - oid: "def456", - messageHeadline: "Fix the flaky test", - committedDate: "2026-07-06T00:00:00Z", - authors: [], - }, - ]); - }); - - it("lists someone who was asked and then answered only once", () => { - const result = expectSuccess( - decodeReviewThreadsJson( - reviewJson({ - requested: [{ login: "julius", avatarUrl: "https://avatars/j.png" }], - reviewed: [{ login: "julius", avatarUrl: "https://avatars/j.png" }], - }), - ), - ); - - expect(result.reviewers).toHaveLength(1); - }); - - it("skips a team request, which names nobody to show", () => { - const result = expectSuccess(decodeReviewThreadsJson(reviewJson({ requested: [null] }))); - - expect(result.reviewers).toEqual([]); - }); - - it("keeps the conversation when a request is from a team, which has no login", () => { - // GraphQL answers with an empty object for a union member the query has no fragment for. - // Failing on it would take the whole response down, comments included. - const result = expectSuccess( - decodeReviewThreadsJson( - reviewJson({ requested: [{}, { login: "julius", avatarUrl: "https://avatars/j.png" }] }), - ), - ); - - expect(result.reviewers).toEqual([ - { login: "julius", name: null, avatarUrl: "https://avatars/j.png" }, - ]); - }); - - it("carries a resolved thread into the conversation, which was still said", () => { - const result = expectSuccess( - decodeReviewThreadsJson( - threadsJson([ - { - id: "PRRT_a", - isResolved: false, - path: "apps/server/src/ws.ts", - comments: { - nodes: [{ id: "t1", body: "fix this", createdAt: "2026-07-01T00:00:00Z" }], - }, - }, - { - id: "PRRT_b", - isResolved: true, - path: "apps/web/src/main.tsx", - comments: { nodes: [{ id: "t2", body: "done", createdAt: "2026-07-01T00:00:00Z" }] }, - }, - ]), - ), - ); - const comments = reviewThreadConversation(result.threads.map((entry) => entry.thread)); - expect(comments.map((comment) => comment.id)).toEqual(["t1", "t2"]); - expect(comments[0]).toMatchObject({ - id: "t1", - kind: "review-comment", - path: "apps/server/src/ws.ts", - }); - }); - - it("carries every reply, not only the remark each thread opened with", () => { - const result = expectSuccess( - decodeReviewThreadsJson( - threadsJson([ - { - id: "PRRT_c", - isResolved: false, - path: "apps/server/src/ws.ts", - comments: { - nodes: [ - { id: "t1", body: "fix this", createdAt: "2026-07-01T00:00:00Z" }, - { id: "t2", body: "fixed", createdAt: "2026-07-01T01:00:00Z" }, - ], - }, - }, - ]), - ), - ); - const comments = reviewThreadConversation(result.threads.map((entry) => entry.thread)); - expect(comments.map((comment) => comment.id)).toEqual(["t1", "t2"]); - }); - - it("hands back the cursor the next page of threads carries on from", () => { - const result = expectSuccess( - decodeReviewThreadsJson( - threadsJson( - [ - { - id: "PRRT_d", - path: "apps/server/src/ws.ts", - isResolved: false, - comments: { nodes: [{ id: "t1", createdAt: "2026-07-01T00:00:00Z" }] }, - }, - ], - 80, - { hasNextPage: true, endCursor: "Y3Vyc29yOjE" }, - ), - ), - ); - expect(result.nextCursor).toBe("Y3Vyc29yOjE"); - }); - - it("keeps GitHub's own count of a thread whose comments were not all read", () => { - const result = expectSuccess( - decodeReviewThreadsJson( - threadsJson([ - { - id: "PRRT_e", - path: "apps/server/src/ws.ts", - isResolved: false, - comments: { - totalCount: 140, - pageInfo: { hasNextPage: true, endCursor: "Y3Vyc29yOjI" }, - nodes: [{ id: "t1", createdAt: "2026-07-01T00:00:00Z" }], - }, - }, - ]), - ), - ); - expect(result.threads[0]).toMatchObject({ - commentCount: 140, - nextCommentCursor: "Y3Vyc29yOjI", - }); - }); - - it("ends a thread's walk on the last page, which still names a cursor", () => { - const decoded = expectSuccess( - decodeReviewThreadCommentsJson( - JSON.stringify({ - data: { - repository: { pullRequest: { id: "PR_1" } }, - node: { - pullRequest: { id: "PR_1" }, - comments: { - pageInfo: { hasNextPage: false, endCursor: "Y3Vyc29yOjk" }, - nodes: [{ id: "t9", body: "last", createdAt: "2026-07-01T00:00:00Z" }], - }, - }, - }, - }), - ), - ); - expect(decoded.comments.map((comment) => comment.id)).toEqual(["t9"]); - expect(decoded.nextCursor).toBeNull(); - }); -}); - -describe("reaction decoding", () => { - const commentWithGroups = (reactionGroups: ReadonlyArray>) => - JSON.stringify({ - data: { - repository: { pullRequest: { id: "PR_1" } }, - node: { - pullRequest: { id: "PR_1" }, - comments: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [{ id: "t1", body: "nice", createdAt: "2026-07-01T00:00:00Z", reactionGroups }], - }, - }, - }, - }); - - it("keeps a named group, widens a group whose reactors were cut short, drops an unknown content and an empty group", () => { - const decoded = expectSuccess( - decodeReviewThreadCommentsJson( - commentWithGroups([ - { - content: "THUMBS_UP", - viewerHasReacted: true, - reactors: { totalCount: 2, nodes: [{ login: "julius" }, { login: "bilal" }] }, - }, - // Not one of the eight the contract carries. - { - content: "PARTY_PARROT", - reactors: { totalCount: 1, nodes: [{ login: "hubot" }] }, - }, - // Nobody behind it, which GitHub still answers a group for. - { content: "HEART", reactors: { totalCount: 0, nodes: [] } }, - // More reactors than the bounded read named, and no `viewerHasReacted` at all. - { - content: "ROCKET", - reactors: { totalCount: 140, nodes: [{ login: "a" }, { login: "b" }, { login: "c" }] }, - }, - ]), - ), - ); - - expect(decoded.comments[0]?.reactions).toEqual([ - { content: "thumbs-up", count: 2, actors: ["julius", "bilal"], viewerHasReacted: true }, - { content: "rocket", count: 140, actors: ["a", "b", "c"], viewerHasReacted: false }, - ]); - }); - - it("leaves the viewer's own login out of actors, matched case-insensitively, while count still counts them", () => { - const decoded = expectSuccess( - decodeReviewThreadCommentsJson( - JSON.stringify({ - data: { - viewer: { login: "Bilal" }, - repository: { pullRequest: { id: "PR_1" } }, - node: { - pullRequest: { id: "PR_1" }, - comments: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [ - { - id: "t1", - body: "nice", - createdAt: "2026-07-01T00:00:00Z", - reactionGroups: [ - { - content: "HEART", - viewerHasReacted: true, - reactors: { - totalCount: 2, - nodes: [{ login: "bilal" }, { login: "julius" }], - }, - }, - ], - }, - ], - }, - }, - }, - }), - ), - ); - - expect(decoded.comments[0]?.reactions).toEqual([ - { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true }, - ]); - }); -}); - -describe("repository access decoding", () => { - const repositoryJson = (viewerPermission?: string | null) => - JSON.stringify({ - data: { - repository: { - pullRequest: null, - mergeCommitAllowed: true, - squashMergeAllowed: false, - rebaseMergeAllowed: true, - ...(viewerPermission === undefined ? {} : { viewerPermission }), - }, - }, - }); - - it("reads merge settings with viewer permissions", () => { - expect( - expectSuccess(decodeViewerPermissionsJson(repositoryJson("ADMIN"))).mergeCapabilities, - ).toEqual({ merge: true, squash: false, rebase: true }); - }); - - it("fails rather than defaulting open when a setting is missing", () => { - const decoded = decodeViewerPermissionsJson( - JSON.stringify({ data: { repository: { pullRequest: null, mergeCommitAllowed: true } } }), - ); - expect(Result.isSuccess(decoded)).toBe(false); - }); - - it("counts the roles that can push as write, and the ones that cannot as read", () => { - for (const permission of ["ADMIN", "MAINTAIN", "WRITE"]) { - expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(permission))).canWrite).toBe( - true, - ); - } - for (const permission of ["TRIAGE", "READ", "NONE"]) { - expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(permission))).canWrite).toBe( - false, - ); - } - }); - - it("withholds write where gh names no permission, which is not a standing it gave", () => { - // The one place an unknown answer is not granted: a Merge button a reader cannot use wastes - // the press, where a missing one still leaves the pull request open on its host. - expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson())).canWrite).toBe(false); - expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(null))).canWrite).toBe(false); - }); -}); - -describe("viewer permission decoding", () => { - const viewerJson = (repository: Record) => - JSON.stringify({ - data: { - repository: { - mergeCommitAllowed: true, - squashMergeAllowed: false, - rebaseMergeAllowed: true, - ...repository, - }, - }, - }); - - it("reads the repository's role and the pull request's own viewer fields together", () => { - expect( - expectSuccess( - decodeViewerPermissionsJson( - viewerJson({ - viewerPermission: "READ", - pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, - }), - ), - ), - ).toEqual({ - mergeCapabilities: { merge: true, squash: false, rebase: true }, - canWrite: false, - canTriage: false, - canUpdate: true, - didAuthor: true, - }); - }); - - it("says no to a passer-by on a repository they can only read", () => { - expect( - expectSuccess( - decodeViewerPermissionsJson( - viewerJson({ - viewerPermission: "READ", - pullRequest: { viewerCanUpdate: false, viewerDidAuthor: false }, - }), - ), - ), - ).toEqual({ - mergeCapabilities: { merge: true, squash: false, rebase: true }, - canWrite: false, - canTriage: false, - canUpdate: false, - didAuthor: false, - }); - }); - - it("reads silence as permission, but not as authorship", () => { - // A node the viewer cannot see comes back null. Updating is a permission, so an unknown - // answer grants it and lets the host refuse; authorship is a fact about who wrote the change, - // and claiming it for someone who did not is how an author's own rules get handed out. - expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({ - mergeCapabilities: { merge: true, squash: false, rebase: true }, - canWrite: false, - canTriage: false, - canUpdate: true, - didAuthor: false, - }); - }); - - it("reads triage as enough to label, and not enough to write", () => { - const access = expectSuccess( - decodeViewerPermissionsJson( - viewerJson({ - viewerPermission: "TRIAGE", - pullRequest: { viewerCanUpdate: false, viewerDidAuthor: false }, - }), - ), - ); - expect(access.canTriage).toBe(true); - expect(access.canWrite).toBe(false); - }); -}); - -describe("label candidate decoding", () => { - const labelsJson = (input: { - readonly defined: ReadonlyArray>; - readonly applied?: ReadonlyArray; - readonly hasNextPage?: boolean; - }) => - JSON.stringify({ - data: { - repository: { - labels: { - pageInfo: { hasNextPage: input.hasNextPage ?? false }, - nodes: input.defined, - }, - pullRequest: { labels: { nodes: (input.applied ?? []).map((name) => ({ name })) } }, - }, - }, - }); - - it("marks the labels the pull request already wears", () => { - const list = expectSuccess( - decodeLabelCandidatesJson( - labelsJson({ - defined: [ - { name: "bug", color: "d73a4a", description: "Something is broken" }, - { name: "size:XL", color: "e4572e", description: null }, - ], - applied: ["size:XL"], - }), - ), - ); - expect(list.candidates).toEqual([ - { name: "bug", color: "d73a4a", description: "Something is broken", isApplied: false }, - { name: "size:XL", color: "e4572e", description: null, isApplied: true }, - ]); - expect(list.truncated).toBe(false); - }); - - it("keeps a worn label the repository no longer defines, so it can be taken off", () => { - const list = expectSuccess( - decodeLabelCandidatesJson(labelsJson({ defined: [{ name: "bug" }], applied: ["legacy"] })), - ); - expect(list.candidates.map((label) => [label.name, label.isApplied])).toEqual([ - ["legacy", true], - ["bug", false], - ]); - }); - - it("says so when the repository defines more labels than the read asked for", () => { - expect( - expectSuccess(decodeLabelCandidatesJson(labelsJson({ defined: [], hasNextPage: true }))) - .truncated, - ).toBe(true); - }); -}); - -describe("review thread decoding", () => { - const threadsJson = ( - nodes: ReadonlyArray>, - pullRequest: Record = {}, - ) => - JSON.stringify({ - data: { - repository: { - pullRequest: { - reviewThreads: { totalCount: nodes.length, nodes }, - author: null, - comments: { nodes: [] }, - reviewRequests: { nodes: [] }, - latestReviews: { nodes: [] }, - ...pullRequest, - }, - }, - }, - }); - - it("carries what the reader may do with the pull request, off the conversation read", () => { - // The same response the threads arrive in, so knowing this costs no request of its own. - expect( - expectSuccess( - decodeReviewThreadsJson( - threadsJson([], { viewerCanUpdate: false, viewerDidAuthor: false }), - ), - ).viewer, - ).toEqual({ canUpdate: false, didAuthor: false }); - expect(expectSuccess(decodeReviewThreadsJson(threadsJson([]))).viewer).toEqual({ - canUpdate: true, - didAuthor: false, - }); - }); - - const comment = (id: string, body: string) => ({ - id, - author: { login: "bilal", avatarUrl: "https://avatars/b.png" }, - body, - createdAt: "2026-07-01T00:00:00Z", - url: `https://github.com/acme/web/pull/1#discussion_r${id}`, - }); - - it("anchors a thread to its line and side, keeping the whole conversation", () => { - const reviewThreads = expectSuccess( - decodeReviewThreadsJson( - threadsJson([ - { - id: "PRRT_1", - isResolved: false, - isOutdated: false, - path: "src/a.ts", - line: 42, - diffSide: "LEFT", - comments: { - totalCount: 2, - nodes: [ - { ...comment("c1", "first"), lastEditedAt: "2026-07-02T00:00:00Z" }, - comment("c2", "second"), - ], - }, - }, - ]), - ), - ); - expect(reviewThreads.threads.map((entry) => entry.thread)).toEqual([ - { - id: "PRRT_1", - path: "src/a.ts", - line: 42, - side: "left", - isResolved: false, - isOutdated: false, - comments: [ - { - id: "c1", - author: { login: "bilal", name: null, avatarUrl: "https://avatars/b.png" }, - body: "first", - createdAt: "2026-07-01T00:00:00Z", - editedAt: "2026-07-02T00:00:00Z", - url: "https://github.com/acme/web/pull/1#discussion_rc1", - reactions: [], - }, - { - id: "c2", - author: { login: "bilal", name: null, avatarUrl: "https://avatars/b.png" }, - body: "second", - createdAt: "2026-07-01T00:00:00Z", - editedAt: null, - url: "https://github.com/acme/web/pull/1#discussion_rc2", - reactions: [], - }, - ], - }, - ]); - expect( - reviewThreadConversation(reviewThreads.threads.map((entry) => entry.thread))[0]?.editedAt, - ).toBe("2026-07-02T00:00:00Z"); - }); - - it("leaves an outdated thread without a line rather than pinning it to a stale one", () => { - const reviewThreads = expectSuccess( - decodeReviewThreadsJson( - threadsJson([ - { - id: "PRRT_2", - isResolved: true, - isOutdated: true, - path: "src/a.ts", - // GitHub reports no current line once the thread has fallen off the diff. - line: null, - diffSide: "RIGHT", - comments: { totalCount: 1, nodes: [comment("c3", "stale")] }, - }, - ]), - ), - ); - expect(reviewThreads.threads[0]?.thread).toMatchObject({ - line: null, - isOutdated: true, - isResolved: true, - }); - }); - - it("keeps a resolved thread in the conversation as well as against its line", () => { - const decoded = expectSuccess( - decodeReviewThreadsJson( - threadsJson([ - { - id: "PRRT_3", - isResolved: true, - path: "src/a.ts", - line: 7, - diffSide: "RIGHT", - comments: { totalCount: 1, nodes: [comment("c4", "done")] }, - }, - ]), - ), - ); - // A resolved conversation is finished work, not unsaid work: the timeline reads it and the - // diff pins it to its line, the same as any other. - const threads = decoded.threads.map((entry) => entry.thread); - expect(reviewThreadConversation(threads).map((comment) => comment.id)).toEqual(["c4"]); - expect(threads).toHaveLength(1); - }); - - it("reads edit times for issue comments and reviews without using reaction updates", () => { - const editedAt = "2026-10-02T12:06:00Z"; - const result = expectSuccess( - decodeReviewThreadsJson( - threadsJson([], { - comments: { - nodes: [ - { id: "edited", lastEditedAt: editedAt }, - { id: "reaction-only", lastEditedAt: null, updatedAt: editedAt }, - ], - }, - reviews: { nodes: [{ id: "review", lastEditedAt: editedAt }] }, - }), - ), - ); - expect([...result.editedAtById]).toEqual([ - ["edited", editedAt], - ["review", editedAt], - ]); - }); - - it("puts an issue comment's and a review's reactions in reactionsById, and the pull request's own in reactions", () => { - const result = expectSuccess( - decodeReviewThreadsJson( - threadsJson([], { - reactionGroups: [ - { - content: "HEART", - viewerHasReacted: true, - reactors: { totalCount: 1, nodes: [{ login: "bilal" }] }, - }, - ], - comments: { - nodes: [ - { - id: "c1", - reactionGroups: [ - { - content: "THUMBS_UP", - reactors: { totalCount: 1, nodes: [{ login: "julius" }] }, - }, - ], - }, - ], - }, - reviews: { - nodes: [ - { - id: "r1", - reactionGroups: [ - { content: "EYES", reactors: { totalCount: 1, nodes: [{ login: "hubot" }] } }, - ], - }, - ], - }, - }), - ), - ); - - expect(result.reactions).toEqual([ - { content: "heart", count: 1, actors: ["bilal"], viewerHasReacted: true }, - ]); - expect([...result.reactionsById]).toEqual([ - ["c1", [{ content: "thumbs-up", count: 1, actors: ["julius"], viewerHasReacted: false }]], - ["r1", [{ content: "eyes", count: 1, actors: ["hubot"], viewerHasReacted: false }]], - ]); - }); - - it("leaves the viewer's own login out of the pull request's own reactions, matched case-insensitively, while count still counts them", () => { - const result = expectSuccess( - decodeReviewThreadsJson( - JSON.stringify({ - data: { - viewer: { login: "Bilal" }, - repository: { - pullRequest: { - reviewThreads: { totalCount: 0, nodes: [] }, - reactionGroups: [ - { - content: "HEART", - viewerHasReacted: true, - reactors: { totalCount: 2, nodes: [{ login: "bilal" }, { login: "julius" }] }, - }, - ], - }, - }, - }, - }), - ), - ); - - expect(result.reactions).toEqual([ - { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true }, - ]); - }); -}); - -describe("decodePullRequestNodeIdJson", () => { - it("reads the pull request's own node id, which a reaction on its description is addressed by", () => { - expect( - expectSuccess( - decodePullRequestNodeIdJson( - JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }), - ), - ), - ).toBe("PR_kwDOA"); - }); -}); - -describe("REVIEW_THREADS_GRAPHQL_QUERY", () => { - it("caps the initial query after the 104-point rate-limit regression", () => { - const match = REVIEW_THREADS_GRAPHQL_QUERY.match( - /reviewThreads\(first: (\d+)[\s\S]*?comments\(first: (\d+)\)/u, - ); - - expect(match).not.toBeNull(); - if (match === null) throw new Error("expected review-thread connections"); - expect(Number(match[1]) * Number(match[2])).toBeLessThanOrEqual(1_000); - }); - - it("asks for reactionGroups on the pull request itself, its comments, its reviews and each thread's comments", () => { - expect(REVIEW_THREADS_GRAPHQL_QUERY.match(/reactionGroups/g)).toHaveLength(4); - // The reviews connection is new: only reactions were ever wanted off it. - expect(REVIEW_THREADS_GRAPHQL_QUERY).toContain("reviews(first:"); - }); -}); - -describe("reviewer candidate decoding", () => { - const candidatesJson = (input: { - readonly assignable: ReadonlyArray | null>; - readonly requested?: ReadonlyArray | null>; - readonly author?: string; - readonly hasNextPage?: boolean; - }) => - JSON.stringify({ - data: { - repository: { - assignableUsers: { - pageInfo: { hasNextPage: input.hasNextPage ?? false }, - nodes: input.assignable, - }, - pullRequest: { - author: input.author === undefined ? null : { login: input.author }, - reviewRequests: { - nodes: (input.requested ?? []).map((requestedReviewer) => ({ requestedReviewer })), - }, - }, - }, - }, - }); - - it("leaves the author out of the people their own pull request can be sent to", () => { - const list = expectSuccess( - decodeReviewerCandidatesJson( - candidatesJson({ - assignable: [{ login: "bilal" }, { login: "octocat", name: "The Octocat" }], - author: "bilal", - }), - ), - ); - expect(list.candidates).toEqual([ - { - id: "octocat", - kind: "user", - login: "octocat", - name: "The Octocat", - avatarUrl: null, - isRequested: false, - }, - ]); - expect(list.truncated).toBe(false); - }); - - it("marks whoever has already been asked, and leaves the rest to be asked", () => { - const list = expectSuccess( - decodeReviewerCandidatesJson( - candidatesJson({ - assignable: [{ login: "octocat" }, { login: "hubot" }], - requested: [{ login: "octocat" }], - }), - ), - ); - expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ - ["octocat", true], - ["hubot", false], - ]); - }); - - it("keeps a requested team apart from the people, so the request can be taken back", () => { - // A team is never among the assignable users, and a request that cannot be seen cannot be - // undone — so the ones GitHub reports are carried, marked as the teams they are. - const list = expectSuccess( - decodeReviewerCandidatesJson( - candidatesJson({ - assignable: [{ login: "octocat" }], - requested: [{ slug: "reviewers", name: "Reviewers" }], - }), - ), - ); - expect(list.candidates).toEqual([ - { - id: "reviewers", - kind: "team", - login: "reviewers", - name: "Reviewers", - avatarUrl: null, - isRequested: true, - }, - { - id: "octocat", - kind: "user", - login: "octocat", - name: null, - avatarUrl: null, - isRequested: false, - }, - ]); - }); - - it("says so when the repository has more people than the read asked for", () => { - expect( - expectSuccess( - decodeReviewerCandidatesJson( - candidatesJson({ assignable: [{ login: "octocat" }], hasNextPage: true }), - ), - ).truncated, - ).toBe(true); - }); -}); - -describe("reviewer request payload", () => { - it("sends people and teams in the two lists GitHub keeps them in", () => { - expect( - buildReviewerRequest([ - { id: "octocat", kind: "user" }, - { id: "reviewers", kind: "team" }, - { id: "hubot", kind: "user" }, - ]), - ).toEqual({ reviewers: ["octocat", "hubot"], team_reviewers: ["reviewers"] }); - }); - - it("sends both lists even where one of them is empty, which is what GitHub reads", () => { - expect(buildReviewerRequest([{ id: "octocat", kind: "user" }])).toEqual({ - reviewers: ["octocat"], - team_reviewers: [], - }); - }); -}); - -describe("review submission payload", () => { - it("sends the verdict, the summary and every line comment in one body", () => { - const payload = buildReviewSubmission({ - verdict: "request-changes", - body: "Two things.", - comments: [ - { - path: "src/a.ts", - position: { kind: "added", newLine: 12 }, - body: "rename this", - }, - { - path: "src/b.ts", - position: { kind: "deleted", oldLine: 3 }, - body: "why remove?", - }, - ], - }); - expect(payload).toEqual({ - event: "REQUEST_CHANGES", - body: "Two things.", - comments: [ - { path: "src/a.ts", line: 12, side: "RIGHT", body: "rename this" }, - { path: "src/b.ts", line: 3, side: "LEFT", body: "why remove?" }, - ], - }); - }); - - it("sends an approval with no words and no comments", () => { - expect(buildReviewSubmission({ verdict: "approve", body: "", comments: [] })).toEqual({ - event: "APPROVE", - body: "", - comments: [], - }); - }); -}); - -describe("decodePullRequestFilesJson", () => { - it("quotes literal backslashes without interpreting them as escapes", () => { - const result = expectSuccess( - decodePullRequestFilesJson( - JSON.stringify([ - { - filename: String.raw`src\notes.ts`, - status: "modified", - patch: "@@ -1 +1 @@\n-old\n+new", - }, - ]), - ), - ); - - expect(result.patch).toBe( - [ - String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`, - String.raw`--- "a/src\\notes.ts"`, - String.raw`+++ "b/src\\notes.ts"`, - "@@ -1 +1 @@", - "-old", - "+new", - "", - ].join("\n"), - ); - }); - - it("preserves spaces and literal backslashes in both rename paths", () => { - const result = expectSuccess( - decodePullRequestFilesJson( - JSON.stringify([ - { - previous_filename: String.raw` old\name.ts `, - filename: String.raw` new\name.ts `, - status: "renamed", - }, - ]), - ), - ); - - expect(result.patch).toBe( - [ - String.raw`diff --git "a/ old\\name.ts " "b/ new\\name.ts "`, - String.raw`rename from " old\\name.ts "`, - String.raw`rename to " new\\name.ts "`, - String.raw`--- "a/ old\\name.ts "`, - String.raw`+++ "b/ new\\name.ts "`, - "", - ].join("\n"), - ); - }); - - it("assembles a unified patch the files API does not return", () => { - const result = expectSuccess( - decodePullRequestFilesJson( - JSON.stringify([ - { filename: "src/app.ts", status: "modified", patch: "@@ -1 +1 @@\n-old\n+new" }, - ]), - ), - ); - - expect(result.patch).toBe( - [ - "diff --git a/src/app.ts b/src/app.ts", - "--- a/src/app.ts", - "+++ b/src/app.ts", - "@@ -1 +1 @@", - "-old", - "+new", - "", - ].join("\n"), - ); - expect(result.truncated).toBe(false); - expect(result.rawCount).toBe(1); - }); - - it("points an added file at /dev/null on the left and a removed one on the right", () => { - const result = expectSuccess( - decodePullRequestFilesJson( - JSON.stringify([ - { filename: "src/new.ts", status: "added", patch: "@@ -0,0 +1 @@\n+hello" }, - { filename: "src/gone.ts", status: "removed", patch: "@@ -1 +0,0 @@\n-bye" }, - ]), - ), - ); - - expect(result.patch).toBe( - [ - "diff --git a/src/new.ts b/src/new.ts", - "new file mode 100644", - "--- /dev/null", - "+++ b/src/new.ts", - "@@ -0,0 +1 @@", - "+hello", - "diff --git a/src/gone.ts b/src/gone.ts", - "deleted file mode 100644", - "--- a/src/gone.ts", - "+++ /dev/null", - "@@ -1 +0,0 @@", - "-bye", - "", - ].join("\n"), - ); - }); - - it("names both paths of a rename, counting its hunks against the old one", () => { - const result = expectSuccess( - decodePullRequestFilesJson( - JSON.stringify([ - { - filename: "src/new.ts", - status: "renamed", - previous_filename: "src/old.ts", - patch: "@@ -1 +1 @@\n-old\n+new", - }, - ]), - ), - ); - - expect(result.patch).toBe( - [ - "diff --git a/src/old.ts b/src/new.ts", - "rename from src/old.ts", - "rename to src/new.ts", - "--- a/src/old.ts", - "+++ b/src/new.ts", - "@@ -1 +1 @@", - "-old", - "+new", - "", - ].join("\n"), - ); - }); - - it("still lists a file GitHub sent no hunks for, and says what was withheld", () => { - const result = expectSuccess( - decodePullRequestFilesJson( - JSON.stringify([ - // Binary: it changed, and none of it can be shown. - { filename: "logo.png", status: "modified", additions: 4, deletions: 2 }, - { - filename: "src/app.ts", - status: "modified", - additions: 1, - deletions: 1, - patch: "@@ -1 +1 @@\n-old\n+new", - }, - ]), - ), - ); - - // Dropping it would take the file out of the change altogether, not just its contents. - expect(result.patch).toContain("diff --git a/logo.png b/logo.png"); - expect(result.patch).toContain("diff --git a/src/app.ts b/src/app.ts"); - expect(result.truncated).toBe(true); - expect(result.rawCount).toBe(2); - }); - - it("does not call a pure rename incomplete, since it has no hunks to withhold", () => { - const result = expectSuccess( - decodePullRequestFilesJson( - JSON.stringify([ - { - filename: "src/new.ts", - previous_filename: "src/old.ts", - status: "renamed", - additions: 0, - deletions: 0, - }, - ]), - ), - ); - - expect(result.patch).toContain("rename from src/old.ts"); - expect(result.truncated).toBe(false); - }); -}); - -describe("decodePullRequestFilesViewedJson", () => { - const page = ( - nodes: ReadonlyArray, - pageInfo: { hasNextPage: boolean; endCursor: string | null }, - ) => - JSON.stringify({ - data: { repository: { pullRequest: { files: { pageInfo, nodes } } } }, - }); - - it("reads each file's state and where the next page carries on", () => { - const decoded = decodePullRequestFilesViewedJson( - page( - [ - { path: "src/a.ts", viewerViewedState: "VIEWED" }, - { path: "src/b.ts", viewerViewedState: "UNVIEWED" }, - { path: "src/c.ts", viewerViewedState: "DISMISSED" }, - ], - { hasNextPage: true, endCursor: "cursor-2" }, - ), - ); - expect(Result.isSuccess(decoded)).toBe(true); - if (!Result.isSuccess(decoded)) return; - expect(decoded.success).toEqual({ - files: [ - { path: "src/a.ts", state: "viewed" }, - { path: "src/b.ts", state: "unviewed" }, - { path: "src/c.ts", state: "dismissed" }, - ], - nextCursor: "cursor-2", - }); - }); - - it("treats a state it has never heard of as unread rather than failing the page", () => { - const decoded = decodePullRequestFilesViewedJson( - page([{ path: "src/a.ts", viewerViewedState: "SOMETHING_NEW" }], { - hasNextPage: false, - endCursor: null, - }), - ); - expect(Result.isSuccess(decoded)).toBe(true); - if (!Result.isSuccess(decoded)) return; - expect(decoded.success).toEqual({ - files: [{ path: "src/a.ts", state: "unviewed" }], - nextCursor: null, - }); - }); - - it("answers empty for a pull request the host has nothing to say about", () => { - const decoded = decodePullRequestFilesViewedJson( - JSON.stringify({ data: { repository: { pullRequest: null } } }), - ); - expect(Result.isSuccess(decoded)).toBe(true); - if (!Result.isSuccess(decoded)) return; - expect(decoded.success).toEqual({ files: [], nextCursor: null }); - }); -}); - -describe("buildSetFilesViewedGraphQlMutation", () => { - it("asks for nothing when nothing was pressed", () => { - expect(buildSetFilesViewedGraphQlMutation("PR_1", [])).toBeNull(); - }); - - it("clears and restores in one document, each file under its own alias", () => { - const mutation = buildSetFilesViewedGraphQlMutation("PR_1", [ - { path: "src/a.ts", viewed: true }, - { path: "src/b.ts", viewed: false }, - ]); - expect(mutation).not.toBeNull(); - if (mutation === null) return; - expect(mutation.query).toContain( - "mutation($pullRequestId: ID!, $f0_path: String!, $f1_path: String!)", - ); - expect(mutation.query).toContain( - "f0: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $f0_path })", - ); - expect(mutation.query).toContain( - "f1: unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $f1_path })", - ); - expect(mutation.variables).toEqual({ - pullRequestId: "PR_1", - f0_path: "src/a.ts", - f1_path: "src/b.ts", - }); - }); - - it("keeps a path out of the document, so one cannot be read as part of it", () => { - const mutation = buildSetFilesViewedGraphQlMutation("PR_1", [ - { path: '") { __typename } evil: markFileAsViewed(input: { path: "x', viewed: true }, - ]); - expect(mutation).not.toBeNull(); - if (mutation === null) return; - expect(mutation.query).not.toContain("evil"); - expect(mutation.variables.f0_path).toBe( - '") { __typename } evil: markFileAsViewed(input: { path: "x', - ); - }); -}); - -describe("host-native stack decoding", () => { - /** A stack as the preview lists it, bottom to top, with the fields it answers today. */ - function stack(overrides: Record = {}) { - return { - id: 42, - number: 3, - node_id: "STK_kwDO", - url: "https://api.github.com/repos/acme/web/stacks/3", - base: { ref: "main", sha: "abc" }, - open: true, - created_at: "2026-09-01T00:00:00Z", - pull_requests: [ - { - number: 10, - head: { ref: "feat/one" }, - state: "closed", - merged_at: "2026-09-02T00:00:00Z", - }, - { number: 11, head: { ref: "feat/two" }, state: "open", merged_at: null }, - { number: 12, head: { ref: "feat/three" }, state: "closed", merged_at: null }, - ], - ...overrides, - }; - } - - /** The one stack a listing answered with, which these reads all expect to find. */ - function expectStack(overrides: Record = {}) { - const decoded = expectSuccess(decodePullRequestStacksJson(JSON.stringify([stack(overrides)]))); - if (decoded === null) throw new Error("expected a stack"); - return decoded; - } - - it("reads the first stack, bottom to top, with merged_at outranking state", () => { - expect(expectStack()).toEqual({ - id: "42", - number: 3, - url: "https://api.github.com/repos/acme/web/stacks/3", - base: "main", - layers: [ - { number: 10, headBranch: "feat/one", state: "merged" }, - { number: 11, headBranch: "feat/two", state: "open" }, - { number: 12, headBranch: "feat/three", state: "closed" }, - ], - }); - }); - - it("retains the detailed layer titles, draft state and expected revision", () => { - expect( - expectStack({ - pull_requests: [ - { - number: 11, - title: "Second layer", - draft: true, - head: { ref: "feat/two", sha: "abc123" }, - state: "open", - merged_at: null, - }, - ], - }).layers, - ).toEqual([ - { - number: 11, - title: "Second layer", - isDraft: true, - headSha: "abc123", - headBranch: "feat/two", - state: "open", - }, - ]); - }); - - it("accepts a base named as a bare branch, which is what the preview started out sending", () => { - expect(expectStack({ base: "develop" }).base).toBe("develop"); - }); - - it("prefers the page a person opens over the API URL, where the host reports one", () => { - expect(expectStack({ html_url: "https://github.com/acme/web/stacks/3" }).url).toBe( - "https://github.com/acme/web/stacks/3", - ); - }); - - it("falls back to the node id, then the number, for a stack without an id", () => { - expect(expectStack({ id: undefined }).id).toBe("STK_kwDO"); - expect(expectStack({ id: null, node_id: null }).id).toBe("3"); - }); - - it("reads an empty listing as not stacked", () => { - expect(expectSuccess(decodePullRequestStacksJson("[]"))).toBeNull(); - }); - - it("refuses a stack without a number or without its pull requests", () => { - expect( - Result.isSuccess(decodePullRequestStacksJson(JSON.stringify([stack({ number: undefined })]))), - ).toBe(false); - expect( - Result.isSuccess( - decodePullRequestStacksJson(JSON.stringify([stack({ pull_requests: undefined })])), - ), - ).toBe(false); - expect(Result.isSuccess(decodePullRequestStacksJson("{"))).toBe(false); - }); -}); - -describe("pull request stack membership batches", () => { - it("maps aliases while skipping missing pull requests and incomplete memberships", () => { - const memberships = expectSuccess( - decodePullRequestStackMembershipsJson( - JSON.stringify({ - data: { - s0: { - pullRequest: { - stack: { number: 3, size: 2, baseRefName: "main" }, - stackEntry: { position: 1 }, - }, - }, - s1: null, - s2: { pullRequest: null }, - s3: { pullRequest: { stack: null, stackEntry: null } }, - s4: { pullRequest: { stack: { number: 3, size: 2, baseRefName: "main" } } }, - }, - }), - ), - ); - expect([...memberships]).toEqual([[0, { number: 3, size: 2, base: "main", position: 1 }]]); - }); - - it("refuses malformed responses and unsafe query selectors", () => { - expect(Result.isFailure(decodePullRequestStackMembershipsJson('{"errors":[]}'))).toBe(true); - expect(buildPullRequestStackMembershipsGraphQlQuery('acme/web") { x } #', [1])).toBeNull(); - expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [0])).toBeNull(); - expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [1.5])).toBeNull(); - expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [])).toBeNull(); - expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [7, 8])?.variables).toEqual({ - s0_owner: "acme", - s0_name: "web", - s0_number: 7, - s1_owner: "acme", - s1_name: "web", - s1_number: 8, - }); - }); -}); - -describe("batched pull request summaries", () => { - it("refuses a repository GraphQL cannot address, rather than writing it into the document", () => { - expect( - buildPullRequestSummariesGraphQlQuery([{ repository: 'acme/web") { x } #', number: 1 }]), - ).toBeNull(); - expect( - buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 0 }]), - ).toBeNull(); - expect(buildPullRequestSummariesGraphQlQuery([])).toBeNull(); - }); - - it("files each answer by its alias and skips what GitHub or the decoder could not give", () => { - const decoded = decodePullRequestSummariesJson( - JSON.stringify({ - data: { - s0: { - pullRequest: { - number: 7, - title: "Merged", - url: "https://github.com/acme/web/pull/7", - author: { __typename: "Bot", login: "renovate", avatarUrl: "https://a/r.png" }, - headRefName: "feat/seven", - baseRefName: "main", - state: "MERGED", - mergedAt: "2026-08-24T00:00:00Z", - closedAt: "2026-08-24T00:00:00Z", - updatedAt: "2026-08-24T00:00:00Z", - commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] }, - }, - }, - s1: { pullRequest: null }, - s2: { pullRequest: { number: 9 } }, - rateLimit: { cost: 1 }, - }, - }), - ); - expect(Result.isSuccess(decoded)).toBe(true); - if (!Result.isSuccess(decoded)) return; - expect([...decoded.success.keys()]).toEqual([0]); - expect(decoded.success.get(0)).toMatchObject({ - number: 7, - state: "merged", - mergedAt: "2026-08-24T00:00:00Z", - author: { login: "renovate", isBot: true }, - checksState: "failing", - mergeability: "unknown", - additions: 0, - }); - // The document did not ask about stacks, so the summary does not claim an answer. - expect(decoded.success.get(0)).not.toHaveProperty("stack"); - }); - - it("reads stack membership only where the document asked for it", () => { - expect( - buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }], true)?.query, - ).toContain("stack { number size baseRefName } stackEntry { position }"); - expect( - buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }])?.query, - ).not.toContain("stack {"); - const pullRequest = (number: number, stack: Record) => ({ - pullRequest: { - number, - title: "Stacked", - url: `https://github.com/acme/web/pull/${number}`, - headRefName: `feat/${number}`, - baseRefName: "main", - state: "OPEN", - updatedAt: "2026-08-24T00:00:00Z", - ...stack, - }, - }); - const decoded = expectSuccess( - decodePullRequestSummariesJson( - JSON.stringify({ - data: { - s0: pullRequest(7, { stack: null, stackEntry: null }), - s1: pullRequest(8, { - stack: { number: 3, size: 2, baseRefName: "main" }, - stackEntry: { position: 2 }, - }), - }, - }), - ), - ); - expect(decoded.get(0)?.stack).toBeNull(); - expect(decoded.get(1)?.stack).toEqual({ number: 3, size: 2, base: "main", position: 2 }); - }); -}); - -describe("pull request watch fingerprints", () => { - it("asks for every pull request in one aliased read, and refuses an unsafe selector", () => { - const document = buildPullRequestWatchFingerprintsGraphQlQuery([ - { repository: "pingdotgg/t3code", number: 7 }, - { repository: "pingdotgg/lakebed", number: 8 }, - ]); - expect(document?.query).toContain( - "w0: repository(owner: $w0_owner, name: $w0_name) { pullRequest(number: $w0_number)", - ); - expect(document?.query).toContain("w1: repository(owner: $w1_owner, name: $w1_name)"); - expect(document?.variables).toEqual({ - w0_owner: "pingdotgg", - w0_name: "t3code", - w0_number: 7, - w1_owner: "pingdotgg", - w1_name: "lakebed", - w1_number: 8, - }); - expect( - buildPullRequestWatchFingerprintsGraphQlQuery([{ repository: 'evil") { x', number: 1 }]), - ).toBeNull(); - }); - - it("moves status with checks and remarks with comments, each without the other", () => { - const pullRequest = (input: { - readonly running: number; - readonly failed: number; - readonly commentEditedAt: string | null; - readonly reviews: number; - }) => ({ - state: "OPEN", - mergeable: "MERGEABLE", - headRefOid: "abc123", - comments: { totalCount: 1, nodes: [{ lastEditedAt: input.commentEditedAt }] }, - reviews: { totalCount: input.reviews, nodes: [] }, - reviewThreads: { totalCount: 0 }, - commits: { - nodes: [ - { - commit: { - statusCheckRollup: { - contexts: { - checkRunCountsByState: [ - { state: "IN_PROGRESS", count: input.running }, - { state: "FAILURE", count: input.failed }, - { state: "SUCCESS", count: 0 }, - ], - statusContextCountsByState: null, - }, - }, - }, - }, - ], - }, - }); - const decode = (...pullRequests: ReadonlyArray) => { - const decoded = decodePullRequestWatchFingerprintsJson( - JSON.stringify({ - data: Object.fromEntries( - pullRequests.map((value, index) => [`w${index}`, { pullRequest: value }]), - ), - }), - ); - if (!Result.isSuccess(decoded)) throw new Error("fingerprints did not decode"); - return decoded.success; - }; - const base = { running: 2, failed: 0, commentEditedAt: null, reviews: 1 }; - const fingerprint = (input: Parameters[0]) => - decode(pullRequest(input)).get(0)!; - const before = fingerprint(base); - const checkFinished = fingerprint({ ...base, running: 1, failed: 1 }); - const summaryEdited = fingerprint({ ...base, commentEditedAt: "2026-10-05T23:47:43Z" }); - const replied = fingerprint({ ...base, reviews: 2 }); - - expect(checkFinished.status).not.toBe(before.status); - expect(checkFinished.remarks).toBe(before.remarks); - expect(summaryEdited.remarks).not.toBe(before.remarks); - expect(summaryEdited.status).toBe(before.status); - expect(replied.remarks).not.toBe(before.remarks); - // A pull request GitHub had no answer for is left for a full read. - expect(decode(null, pullRequest(base)).has(0)).toBe(false); - }); -}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts deleted file mode 100644 index edca11f5a0d082ab4a0bec2e4d000ad0aa649418..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ /dev/null @@ -1,3462 +0,0 @@ -import * as Cause from "effect/Cause"; -import * as Exit from "effect/Exit"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; -import type { - PullRequestStackMembership, - PullRequestActor, - PullRequestPreview, - PullRequestCheck, - PullRequestCheckStatus, - PullRequestChecksState, - PullRequestComment, - PullRequestCommit, - PullRequestFileViewedState, - PullRequestLabel, - PullRequestMergeCapabilities, - PullRequestMergeMethod, - PullRequestOmittedFileStat, - PullRequestMergeability, - PullRequestReaction, - PullRequestReactionContent, - PullRequestReviewCommentDraft, - PullRequestReviewDecision, - PullRequestReviewPosition, - PullRequestReviewThread, - PullRequestReviewVerdict, - PullRequestReviewerCandidate, - PullRequestReviewerCandidateList, - PullRequestReviewerKind, - PullRequestLabelCandidate, - PullRequestLabelCandidateList, - PullRequestState, - PullRequestThreadComment, -} from "@t3tools/contracts"; -import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; - -import { aliasedGraphQlDocument, type GraphQlDocument } from "../sourceControl/githubGraphQl.ts"; -import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks"; - -/** - * Enum-ish GitHub CLI fields are decoded as plain strings and normalized here: a `gh` - * release that adds a conclusion or a review state must not fail the whole payload. - */ -const RawActorSchema = Schema.Struct({ - __typename: Schema.optional(Schema.String), - is_bot: Schema.optional(Schema.Boolean), - /** - * Optional because a review can be requested from a team or a mannequin, which the query has - * no fragment for and GraphQL answers with an empty object. A reviewer with no login names - * nobody to show, and must not fail the response the conversation travels in. - */ - login: Schema.optional(Schema.String), - /** The node id, which is how a listing's authors are resolved to avatars in one request. */ - id: Schema.optional(Schema.NullOr(Schema.String)), - name: Schema.optional(Schema.NullOr(Schema.String)), - /** Only the GraphQL API reports one; `gh pr view --json` has no avatar to give. */ - avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), -}); - -const RawLabelSchema = Schema.Struct({ - name: Schema.String, - color: Schema.optional(Schema.NullOr(Schema.String)), -}); - -const RawReviewRequestSchema = Schema.Struct({ - login: Schema.optional(Schema.NullOr(Schema.String)), - slug: Schema.optional(Schema.NullOr(Schema.String)), - name: Schema.optional(Schema.NullOr(Schema.String)), -}); - -/** One reviewer's most recent review: the state is all the verdict needs, the author is for who. */ -const RawLatestReviewSchema = Schema.Struct({ - author: Schema.optional(Schema.NullOr(RawActorSchema)), - state: Schema.optional(Schema.NullOr(Schema.String)), -}); - -const RawCheckSchema = Schema.Struct({ - __typename: Schema.optional(Schema.String), - name: Schema.optional(Schema.NullOr(Schema.String)), - context: Schema.optional(Schema.NullOr(Schema.String)), - status: Schema.optional(Schema.NullOr(Schema.String)), - conclusion: Schema.optional(Schema.NullOr(Schema.String)), - state: Schema.optional(Schema.NullOr(Schema.String)), - description: Schema.optional(Schema.NullOr(Schema.String)), - detailsUrl: Schema.optional(Schema.NullOr(Schema.String)), - targetUrl: Schema.optional(Schema.NullOr(Schema.String)), - /** - * What tells two same-named checks apart, and which run of one is the newest. All three ride - * along with `statusCheckRollup` already — it is asked for as a whole field — so reading them - * costs no request. Empty for an app-provided check run, which belongs to no workflow, and - * absent entirely on a commit status, which is not a run at all. - */ - workflowName: Schema.optional(Schema.NullOr(Schema.String)), - startedAt: Schema.optional(Schema.NullOr(Schema.String)), - completedAt: Schema.optional(Schema.NullOr(Schema.String)), - /** Branch protection requires this check; read by the detail query on github.com only. */ - isRequired: Schema.optional(Schema.NullOr(Schema.Boolean)), -}); - -const RawListItemSchema = Schema.Struct({ - number: Schema.Int, - title: Schema.String, - url: Schema.String, - author: Schema.optional(Schema.NullOr(RawActorSchema)), - headRefName: Schema.String, - baseRefName: Schema.String, - state: Schema.optional(Schema.NullOr(Schema.String)), - isDraft: Schema.optional(Schema.Boolean), - mergeable: Schema.optional(Schema.NullOr(Schema.String)), - reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), - additions: Schema.optional(Schema.Int), - deletions: Schema.optional(Schema.Int), - createdAt: Schema.String, - updatedAt: Schema.String, - mergedAt: Schema.optional(Schema.NullOr(Schema.String)), - reviewRequests: Schema.optional(Schema.Array(RawReviewRequestSchema)), - latestReviews: Schema.optional(Schema.NullOr(Schema.Array(RawLatestReviewSchema))), - labels: Schema.optional(Schema.Array(RawLabelSchema)), - /** - * Every check of the head commit, which is the only rollup `gh pr list --json` can give: there - * is no field for the one-word verdict. Measured against `pingdotgg/t3code`, asking for it costs - * 0.6s -> 7.9s at a hundred rows and 0.9s -> 2.1s at thirty, for 425 KB of checks a listing - * reduces to one word. The listing pays it because the alternative is a request per row; the - * cross-repository search below asks GitHub for the verdict itself instead. - */ - statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))), -}); - -const RawStackMembershipSchema = Schema.Struct({ - stack: Schema.optional( - Schema.NullOr( - Schema.Struct({ number: Schema.Int, size: Schema.Int, baseRefName: Schema.String }), - ), - ), - stackEntry: Schema.optional(Schema.NullOr(Schema.Struct({ position: Schema.Int }))), -}); - -/** - * A search's own answer, which is the listing's row one connection deeper: `gh pr list --json` - * flattens reviewers and labels, and GraphQL does not. Everything below the row is optional - * because a node that is not a pull request decodes as an empty object, which is skipped. - */ -const RawSearchItemSchema = Schema.Struct({ - ...RawStackMembershipSchema.fields, - number: Schema.Int, - title: Schema.String, - url: Schema.String, - author: Schema.optional(Schema.NullOr(RawActorSchema)), - headRefName: Schema.String, - baseRefName: Schema.String, - state: Schema.optional(Schema.NullOr(Schema.String)), - isDraft: Schema.optional(Schema.Boolean), - mergeable: Schema.optional(Schema.NullOr(Schema.String)), - reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), - latestReviews: Schema.optional( - Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLatestReviewSchema)) })), - ), - /** Asked for by the per-repository listing, and left out of the cross-repository search. */ - additions: Schema.optional(Schema.Int), - deletions: Schema.optional(Schema.Int), - createdAt: Schema.String, - updatedAt: Schema.String, - mergedAt: Schema.optional(Schema.NullOr(Schema.String)), - repository: Schema.optional(Schema.NullOr(Schema.Struct({ nameWithOwner: Schema.String }))), - reviewRequests: Schema.optional( - Schema.NullOr( - Schema.Struct({ - nodes: Schema.optional( - Schema.NullOr( - Schema.Array( - Schema.NullOr( - Schema.Struct({ - requestedReviewer: Schema.optional( - Schema.NullOr( - Schema.Struct({ - ...RawActorSchema.fields, - slug: Schema.optional(Schema.NullOr(Schema.String)), - }), - ), - ), - }), - ), - ), - ), - ), - }), - ), - ), - labels: Schema.optional( - Schema.NullOr( - Schema.Struct({ - nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.NullOr(RawLabelSchema)))), - }), - ), - ), - /** - * GraphQL answers the rollup a listing actually wants — one enum for the head commit, rather - * than the whole check array `gh pr list --json` insists on. Measured at a hundred rows across - * this repository: 0.8s -> 3.0s and 15 KB, against 425 KB for the same verdict over `gh`. - */ - commits: Schema.optional( - Schema.NullOr( - Schema.Struct({ - nodes: Schema.optional( - Schema.NullOr( - Schema.Array( - Schema.NullOr( - Schema.Struct({ - commit: Schema.optional( - Schema.NullOr( - Schema.Struct({ - statusCheckRollup: Schema.optional( - Schema.NullOr(Schema.Struct({ state: Schema.String })), - ), - }), - ), - ), - }), - ), - ), - ), - ), - }), - ), - ), -}); - -const RawRowConnectionSchema = Schema.Struct({ - pageInfo: Schema.optional( - Schema.NullOr( - Schema.Struct({ - hasNextPage: Schema.Boolean, - endCursor: Schema.optional(Schema.NullOr(Schema.String)), - }), - ), - ), - // Row by row: a node that is not a pull request — or one field GitHub changes — is skipped - // rather than blanking every repository at once. - nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), -}); - -const RawSearchSchema = Schema.Struct({ - data: Schema.Struct({ search: RawRowConnectionSchema }), -}); - -const RawRepositoryPullRequestsSchema = Schema.Struct({ - data: Schema.Struct({ - repository: Schema.NullOr(Schema.Struct({ pullRequests: RawRowConnectionSchema })), - }), -}); - -/** One aliased lookup per row, so the response is keyed by the position it was asked in. */ -const RawStatsSchema = Schema.Struct({ - data: Schema.optional( - Schema.NullOr( - Schema.Record( - Schema.String, - Schema.NullOr( - Schema.Struct({ - pullRequest: Schema.optional( - Schema.NullOr( - Schema.Struct({ - additions: Schema.optional(Schema.NullOr(Schema.Int)), - deletions: Schema.optional(Schema.NullOr(Schema.Int)), - }), - ), - ), - }), - ), - ), - ), - ), -}); - -const RawStackMembershipsSchema = Schema.Struct({ - data: Schema.Record( - Schema.String, - Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(RawStackMembershipSchema) })), - ), -}); - -/** How many of a reaction's people the hover names before it counts the rest. */ -const REACTORS_PER_GROUP = 10; - -/** - * A reaction group as every reactable node reports it. `reactors` is bounded rather than paged: - * a hover says who reacted, and a hundred and forty names is a count, not a sentence. - */ -const REACTION_GROUPS_FIELDS = `reactionGroups { - content - viewerHasReacted - reactors(first: ${REACTORS_PER_GROUP}) { - totalCount - nodes { - ... on User { login } - ... on Bot { login } - ... on Organization { login } - ... on Mannequin { login } - } - } -}`; - -/** GitHub's reaction names, which are the same eight the contract carries under other spellings. */ -const REACTION_CONTENT_BY_GITHUB: Readonly> = { - THUMBS_UP: "thumbs-up", - THUMBS_DOWN: "thumbs-down", - LAUGH: "laugh", - HOORAY: "hooray", - CONFUSED: "confused", - HEART: "heart", - ROCKET: "rocket", - EYES: "eyes", -}; - -const GITHUB_REACTION_BY_CONTENT: Readonly> = { - "thumbs-up": "THUMBS_UP", - "thumbs-down": "THUMBS_DOWN", - laugh: "LAUGH", - hooray: "HOORAY", - confused: "CONFUSED", - heart: "HEART", - rocket: "ROCKET", - eyes: "EYES", -}; - -export function gitHubReactionContent(content: PullRequestReactionContent): string { - return GITHUB_REACTION_BY_CONTENT[content]; -} - -const RawReactionGroupsSchema = Schema.optional( - Schema.NullOr( - Schema.Array( - Schema.Struct({ - content: Schema.optional(Schema.NullOr(Schema.String)), - viewerHasReacted: Schema.optional(Schema.Boolean), - reactors: Schema.optional( - Schema.NullOr( - Schema.Struct({ - totalCount: Schema.optional(Schema.Int), - nodes: Schema.optional( - Schema.NullOr( - Schema.Array( - Schema.NullOr( - Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) }), - ), - ), - ), - ), - }), - ), - ), - }), - ), - ), -); - -type RawReactionGroups = typeof RawReactionGroupsSchema.Type; - -/** - * The groups GitHub answered with, as the contract carries them. A group with nobody behind it is - * dropped: GitHub answers with a group per content it knows, including the ones nobody chose. The - * viewer's own login is left out of `actors` — the page names them "You" instead, and leaving it - * in would name them twice — but `count` still counts them along with everyone else. - */ -function toReactions( - groups: RawReactionGroups, - viewer: string | null, -): ReadonlyArray { - const normalizedViewer = viewer?.toLowerCase() ?? null; - const reactions: PullRequestReaction[] = []; - for (const group of groups ?? []) { - const content = REACTION_CONTENT_BY_GITHUB[trimmed(group.content)?.toUpperCase() ?? ""]; - if (content === undefined) continue; - const logins = (group.reactors?.nodes ?? []).flatMap((node) => trimmed(node?.login) ?? []); - const count = Math.max(group.reactors?.totalCount ?? logins.length, logins.length); - if (count <= 0) continue; - const actors = - normalizedViewer === null - ? logins - : logins.filter((login) => login.toLowerCase() !== normalizedViewer); - reactions.push({ content, count, actors, viewerHasReacted: group.viewerHasReacted === true }); - } - return reactions; -} - -const RawCommentSchema = Schema.Struct({ - id: Schema.String, - lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)), - author: Schema.optional(Schema.NullOr(RawActorSchema)), - body: Schema.optional(Schema.String), - createdAt: Schema.String, - url: Schema.optional(Schema.NullOr(Schema.String)), - /** Only ever present on a GraphQL read; `gh pr view --json` reports no reaction at all. */ - reactionGroups: RawReactionGroupsSchema, -}); - -const RawReviewSchema = Schema.Struct({ - id: Schema.String, - lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)), - author: Schema.optional(Schema.NullOr(RawActorSchema)), - body: Schema.optional(Schema.String), - state: Schema.optional(Schema.NullOr(Schema.String)), - submittedAt: Schema.optional(Schema.NullOr(Schema.String)), - url: Schema.optional(Schema.NullOr(Schema.String)), -}); - -const RawCommitSchema = Schema.Struct({ - oid: Schema.String, - messageHeadline: Schema.optional(Schema.String), - committedDate: Schema.String, - authors: Schema.optional( - Schema.Array( - Schema.Struct({ - email: Schema.optional(Schema.NullOr(Schema.String)), - id: Schema.optional(Schema.NullOr(Schema.String)), - login: Schema.optional(Schema.NullOr(Schema.String)), - name: Schema.optional(Schema.NullOr(Schema.String)), - }), - ), - ), -}); - -const RawDetailSchema = Schema.Struct({ - ...RawListItemSchema.fields, - /** GitHub's explicit distinction between a fork head and a branch in the base repository. */ - isCrossRepository: Schema.optional(Schema.Boolean), - /** Names the fork a pull request came from, which is what qualifies its head ref. */ - headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), - /** The exact head revision, used to find workflow runs that GitHub has not started yet. */ - headRefOid: Schema.optional(Schema.NullOr(Schema.String)), - body: Schema.optional(Schema.String), - changedFiles: Schema.optional(Schema.Int), - closedAt: Schema.optional(Schema.NullOr(Schema.String)), - /** The standing instruction and strategy GitHub will use once its requirements are met. */ - autoMergeRequest: Schema.optional( - Schema.NullOr(Schema.Struct({ mergeMethod: Schema.optional(Schema.NullOr(Schema.String)) })), - ), -}); - -/** `GET /repos/{owner}/{repo}/actions/runs`, one page of it. */ -const RawWorkflowRunsSchema = Schema.Struct({ - workflow_runs: Schema.Array( - Schema.Struct({ - id: Schema.Int, - name: Schema.optional(Schema.NullOr(Schema.String)), - html_url: Schema.optional(Schema.NullOr(Schema.String)), - }), - ), -}); - -const RawPullRequestHeadsSchema = Schema.Struct({ - data: Schema.Struct({ - repository: Schema.NullOr( - Schema.Struct({ - pullRequests: Schema.Struct({ - pageInfo: Schema.optional(RawPageInfoSchemaFields()), - nodes: Schema.Array( - Schema.Struct({ - number: Schema.Int, - headRefOid: Schema.String, - isCrossRepository: Schema.optional(Schema.Boolean), - headRepositoryOwner: Schema.optional( - Schema.NullOr(Schema.Struct({ login: Schema.String })), - ), - }), - ), - }), - }), - ), - }), -}); - -const RawActivityConnection = (node: S) => - Schema.optional( - Schema.NullOr( - Schema.Struct({ - pageInfo: Schema.optional(RawPageInfoSchemaFields()), - nodes: Schema.Array(node), - }), - ), - ); - -const RawActivitySchema = Schema.Struct({ - data: Schema.Struct({ - repository: Schema.Struct({ - pullRequest: Schema.Struct({ - author: Schema.optional(Schema.NullOr(RawActorSchema)), - comments: RawActivityConnection(RawCommentSchema), - reviews: RawActivityConnection(RawReviewSchema), - commits: Schema.optional( - Schema.NullOr( - Schema.Struct({ - nodes: Schema.Array( - Schema.Struct({ - commit: Schema.Struct({ - oid: Schema.String, - messageHeadline: Schema.optional(Schema.String), - committedDate: Schema.String, - authors: Schema.optional( - Schema.NullOr( - Schema.Struct({ - nodes: Schema.Array( - Schema.Struct({ - email: Schema.optional(Schema.NullOr(Schema.String)), - name: Schema.optional(Schema.NullOr(Schema.String)), - user: Schema.optional( - Schema.NullOr( - Schema.Struct({ - login: Schema.optional(Schema.NullOr(Schema.String)), - }), - ), - ), - }), - ), - }), - ), - ), - }), - }), - ), - }), - ), - ), - }), - }), - }), -}); - -/** Where a connection carries on from, which is what every paged read below follows. */ -function RawPageInfoSchemaFields() { - return Schema.Struct({ - hasNextPage: Schema.optional(Schema.Boolean), - endCursor: Schema.optional(Schema.NullOr(Schema.String)), - }); -} -const RawPageInfoSchema = RawPageInfoSchemaFields(); - -/** - * What GitHub says the viewer may do with a pull request. Both are optional so that an install - * that answers without them still delivers the conversation they travel with; an absent field - * reads as granted, which is what an unknown permission is. - */ -const RawViewerFieldsSchema = Schema.Struct({ - viewerCanUpdate: Schema.optional(Schema.Boolean), - viewerDidAuthor: Schema.optional(Schema.Boolean), -}); - -const RawThreadCommentsSchema = Schema.Struct({ - totalCount: Schema.optional(Schema.Int), - pageInfo: Schema.optional(RawPageInfoSchema), - nodes: Schema.Array(RawCommentSchema), -}); - -/** `gh pr view --json` cannot reach review threads, so they come from the GraphQL API. */ -const RawReviewThreadsSchema = Schema.Struct({ - data: Schema.Struct({ - // Rides along in the same request: GitHub names who reacted but never says whether that is - // the reader, so the comparison is made here rather than paid for with a request of its own. - viewer: Schema.optional( - Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })), - ), - repository: Schema.Struct({ - pullRequest: Schema.Struct({ - reviewThreads: Schema.Struct({ - totalCount: Schema.optional(Schema.Int), - pageInfo: Schema.optional(RawPageInfoSchema), - nodes: Schema.Array( - Schema.Struct({ - id: Schema.optional(Schema.NullOr(Schema.String)), - isResolved: Schema.optional(Schema.Boolean), - isOutdated: Schema.optional(Schema.Boolean), - path: Schema.optional(Schema.NullOr(Schema.String)), - /** Null once the thread's line has left the diff, which `isOutdated` reports. */ - line: Schema.optional(Schema.NullOr(Schema.Int)), - diffSide: Schema.optional(Schema.NullOr(Schema.String)), - comments: RawThreadCommentsSchema, - }), - ), - }), - ...RawViewerFieldsSchema.fields, - author: Schema.optional(Schema.NullOr(RawActorSchema)), - reactionGroups: RawReactionGroupsSchema, - comments: Schema.optional( - Schema.NullOr( - Schema.Struct({ - nodes: Schema.Array( - Schema.Struct({ - id: Schema.optional(Schema.NullOr(Schema.String)), - author: Schema.optional(Schema.NullOr(RawActorSchema)), - lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)), - reactionGroups: RawReactionGroupsSchema, - }), - ), - }), - ), - ), - /** - * Reviews for their reactions and actor identity: the words and the verdict arrive with - * `gh pr view --json reviews`, which reports no reaction of any kind. - */ - reviews: Schema.optional( - Schema.NullOr( - Schema.Struct({ - nodes: Schema.Array( - Schema.Struct({ - id: Schema.optional(Schema.NullOr(Schema.String)), - author: Schema.optional(Schema.NullOr(RawActorSchema)), - lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)), - reactionGroups: RawReactionGroupsSchema, - }), - ), - }), - ), - ), - reviewRequests: Schema.optional( - Schema.NullOr( - Schema.Struct({ - nodes: Schema.Array( - Schema.Struct({ - // Null for a team, which is a request nobody in particular owns. - requestedReviewer: Schema.optional(Schema.NullOr(RawActorSchema)), - }), - ), - }), - ), - ), - latestReviews: Schema.optional( - Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawLatestReviewSchema) })), - ), - reviewDismissals: Schema.optional( - Schema.NullOr( - Schema.Struct({ - pageInfo: Schema.optional(RawPageInfoSchema), - nodes: Schema.Array( - Schema.Struct({ - dismissalMessage: Schema.optional(Schema.NullOr(Schema.String)), - review: Schema.optional( - Schema.NullOr( - Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)) }), - ), - ), - }), - ), - }), - ), - ), - commits: Schema.optional( - Schema.NullOr( - Schema.Struct({ - nodes: Schema.Array( - Schema.Struct({ - commit: Schema.Struct({ - oid: Schema.String, - messageHeadline: Schema.optional(Schema.NullOr(Schema.String)), - committedDate: Schema.optional(Schema.NullOr(Schema.String)), - additions: Schema.optional(Schema.Int), - deletions: Schema.optional(Schema.Int), - parents: Schema.optional( - Schema.NullOr(Schema.Struct({ totalCount: Schema.optional(Schema.Int) })), - ), - authors: Schema.optional( - Schema.NullOr( - Schema.Struct({ - nodes: Schema.Array( - Schema.Struct({ - name: Schema.optional(Schema.NullOr(Schema.String)), - avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), - user: Schema.optional( - Schema.NullOr( - Schema.Struct({ - login: Schema.optional(Schema.NullOr(Schema.String)), - }), - ), - ), - }), - ), - }), - ), - ), - }), - }), - ), - }), - ), - ), - }), - }), - }), -}); - -/** Requested together, so a response missing any of them fails rather than defaulting open: - * guessing `true` would offer a merge method the repository forbids. */ -const RawRepositoryAccessSchema = Schema.Struct({ - mergeCommitAllowed: Schema.Boolean, - squashMergeAllowed: Schema.Boolean, - rebaseMergeAllowed: Schema.Boolean, - /** - * ADMIN, MAINTAIN, WRITE, TRIAGE, READ or NONE. Optional rather than required, unlike the - * three above: an install that does not report it leaves the viewer's standing unknown, which - * is answered by granting rather than by failing the whole detail read. - */ - viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), -}); - -const RawCoreSchema = Schema.Struct({ - data: Schema.Struct({ - repository: Schema.Struct({ - ...RawRepositoryAccessSchema.fields, - pullRequest: Schema.Struct({ - ...RawDetailSchema.fields, - ...RawViewerFieldsSchema.fields, - viewerCanUpdateBranch: Schema.Boolean, - baseRef: Schema.NullOr( - Schema.Struct({ - compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })), - }), - ), - reviewRequests: Schema.Struct({ - nodes: Schema.Array( - Schema.Struct({ requestedReviewer: Schema.NullOr(RawReviewRequestSchema) }), - ), - }), - labels: Schema.Struct({ nodes: Schema.Array(RawLabelSchema) }), - commits: Schema.Struct({ - nodes: Schema.Array( - Schema.Struct({ - commit: Schema.Struct({ - statusCheckRollup: Schema.NullOr( - Schema.Struct({ - contexts: Schema.Struct({ - nodes: Schema.Array( - Schema.Struct({ - ...RawCheckSchema.fields, - checkSuite: Schema.optional( - Schema.NullOr( - Schema.Struct({ - workflowRun: Schema.NullOr( - Schema.Struct({ - workflow: Schema.NullOr(Schema.Struct({ name: Schema.String })), - }), - ), - }), - ), - ), - }), - ), - pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean }), - }), - }), - ), - }), - }), - ), - }), - }), - }), - }), -}); -const decodeCore = decodeJsonResult(RawCoreSchema); - -const RawPullRequestFileSchema = Schema.Struct({ - filename: Schema.String, - status: Schema.optional(Schema.NullOr(Schema.String)), - /** Only on a rename, where it names the file the hunks are counted against. */ - previous_filename: Schema.optional(Schema.NullOr(Schema.String)), - /** Absent for a binary file, and for one whose diff GitHub considers too large. */ - patch: Schema.optional(Schema.NullOr(Schema.String)), - /** Whether anything was withheld is the difference between a binary file and a pure rename. */ - additions: Schema.optional(Schema.NullOr(Schema.Int)), - deletions: Schema.optional(Schema.NullOr(Schema.Int)), -}); - -/** Resolves a listing's authors to avatars, which no `gh` JSON field carries. */ -export const ACTOR_AVATARS_GRAPHQL_QUERY = `query($ids: [ID!]!) { - nodes(ids: $ids) { - ... on User { login avatarUrl } - ... on Bot { login avatarUrl } - } -}`; - -const RawActorAvatarsSchema = Schema.Struct({ - data: Schema.Struct({ - nodes: Schema.Array(Schema.NullOr(RawActorSchema)), - }), -}); - -const decodeActorAvatars = decodeJsonResult(RawActorAvatarsSchema); - -export function decodeActorAvatarsJson( - raw: string, -): Result.Result, DecodeFailure> { - const decoded = decodeActorAvatars(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const avatarsByLogin = new Map(); - for (const node of decoded.success.data.nodes) { - const login = trimmed(node?.login); - const avatarUrl = trimmed(node?.avatarUrl); - if (login !== null && avatarUrl !== null) avatarsByLogin.set(login, avatarUrl); - } - return Result.succeed(avatarsByLogin); -} - -/** - * Pull refs let the comparison share the detail read without first resolving a fork branch. - * `isRequired` is asked for on github.com only: an older Enterprise server may not know it, and - * an unknown field fails the whole read. - */ -function checkContextNodesSelection(host: string): string { - const required = - host.toLowerCase() === "github.com" ? " isRequired(pullRequestNumber: $number)" : ""; - return `nodes { - __typename - ... on StatusContext { context state targetUrl createdAt description${required} } - ... on CheckRun { - name status conclusion startedAt completedAt detailsUrl${required} - checkSuite { workflowRun { workflow { name } } } - } - }`; -} - -export const pullRequestCoreGraphQlQuery = (host: string) => { - return `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { - repository(owner: $owner, name: $name) { - mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission - pullRequest(number: $number) { - number title url body state isDraft mergeable reviewDecision - additions deletions changedFiles createdAt updatedAt mergedAt closedAt - headRefName baseRefName headRefOid isCrossRepository - headRepositoryOwner { login } - author { login avatarUrl ... on User { id name } } - autoMergeRequest { mergeMethod } - viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch - baseRef { compare(headRef: $headRef) { behindBy } } - reviewRequests(first: 100) { - nodes { requestedReviewer { ... on User { login name } ... on Bot { login } ... on Team { slug name } } } - } - labels(first: 100) { nodes { name color } } - commits(last: 1) { - nodes { commit { statusCheckRollup { contexts(first: 100) { - ${checkContextNodesSelection(host)} - pageInfo { hasNextPage } - } } } } - } - } - } -}`; -}; - -export const PULL_REQUEST_PREVIEW_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - number title url state isDraft createdAt - author { login avatarUrl ... on User { name } } - } - } -}`; - -const decodePullRequestPreview = decodeJsonResult( - Schema.Struct({ - data: Schema.Struct({ - repository: Schema.Struct({ - pullRequest: Schema.Struct({ - number: Schema.Int, - title: Schema.String, - url: Schema.String, - state: Schema.String, - isDraft: Schema.Boolean, - createdAt: Schema.String, - author: Schema.NullOr(RawActorSchema), - }), - }), - }), - }), -); - -export function decodePullRequestPreviewJson( - raw: string, -): Result.Result, DecodeFailure> { - return Result.map(decodePullRequestPreview(raw), ({ data }) => ({ - ...data.repository.pullRequest, - author: toActor(data.repository.pullRequest.author), - state: toState(data.repository.pullRequest), - })); -} - -/** - * The conversation a pull request carries outside its review threads: who opened it, its issue - * comments, its reviews and its newest commits. The two remark connections page independently, so - * each is switched on only while it has more to give; the first read asks for everything. - */ -export const PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $head: Boolean!, $withComments: Boolean!, $commentsAfter: String, $withReviews: Boolean!, $reviewsAfter: String) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - author @include(if: $head) { __typename login avatarUrl ... on User { name } } - commits(last: 100) @include(if: $head) { - nodes { commit { oid messageHeadline committedDate authors(first: 10) { nodes { name email user { login } } } } } - } - comments(first: 100, after: $commentsAfter) @include(if: $withComments) { - pageInfo { hasNextPage endCursor } - nodes { id body createdAt lastEditedAt url author { __typename login avatarUrl ... on User { name } } } - } - reviews(first: 100, after: $reviewsAfter) @include(if: $withReviews) { - pageInfo { hasNextPage endCursor } - nodes { id body state submittedAt lastEditedAt url author { __typename login avatarUrl ... on User { name } } } - } - } - } -}`; - -/** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */ -const GRAPHQL_PAGE_SIZE = 100; - -/** - * The ceiling on `search`, which refuses anything larger with EXCESSIVE_PAGINATION (measured: - * `first: 101` is an error, `first: 100` is not). - */ -export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; - -/** - * Every repository of a host in one read, which is what makes a listing one request rather than - * one process per repository. - * - * `additions` and `deletions` are deliberately absent: measured over twelve repositories at a - * hundred rows, this query answers in ~4.0s with them left out and ~7.1s with them in, for two - * numbers at the end of a row. They are read afterwards, by `buildPullRequestStatsGraphQlQuery`. - * - * The row count is written into the document rather than sent as a variable because every - * variable here travels as a string — and it is this module's own number, clamped by the caller, - * never a reader's. - * - * `first` on the two inner connections is a bound rather than a page: a pull request with more - * than twenty labels shows twenty, and one that has asked more than twenty people for a review - * is already past what a row can say. - */ -export function pullRequestSearchGraphQlQuery( - rows: number, - includeStacks = false, - includeStats = false, -): string { - return `query($q: String!, $after: String) { - search(query: $q, type: ISSUE, first: ${pageRows(rows)}, after: $after) { - pageInfo { hasNextPage endCursor } - nodes { - ... on PullRequest { - ${pullRequestRowSelection(includeStacks, includeStats)} - } - } - } -}`; -} - -/** - * A repository's pull requests without search, newest created first: the order `gh pr list` lists - * in when it does not search, for a repository GitHub's search index does not cover. - */ -export function pullRequestListGraphQlQuery(rows: number, includeStacks = false): string { - return `query($owner: String!, $name: String!, $states: [PullRequestState!], $after: String) { - repository(owner: $owner, name: $name) { - pullRequests(first: ${pageRows(rows)}, after: $after, states: $states, orderBy: { field: CREATED_AT, direction: DESC }) { - pageInfo { hasNextPage endCursor } - nodes { ${pullRequestRowSelection(includeStacks, true)} } - } - } -}`; -} - -function pageRows(rows: number): number { - return Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS); -} - -/** One listing row, the same whether it came from a search or from a repository's own list. */ -function pullRequestRowSelection(includeStacks: boolean, includeStats: boolean): string { - return `${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""} - number - title - url - author { __typename login avatarUrl ... on User { name } } - headRefName - baseRefName - state - isDraft - mergeable - reviewDecision - latestReviews(first: 20) { nodes { state author { login } } } - ${includeStats ? "additions deletions" : ""} - createdAt - updatedAt - mergedAt - repository { nameWithOwner } - reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } ... on Team { slug } } } } - labels(first: 20) { nodes { name color } } - commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }`; -} - -/** - * One page of review threads with their comments, and the people on the review. `$cursor` is - * null for the first page and the last page's `endCursor` after that, so a pull request with - * more threads than one page holds is walked rather than cut off at the first fifty. - * - * Only ten comments ride with each thread. A hundred threads times a hundred comments made - * GitHub reserve 10,000 nested rows and charge 104 points; unfinished threads are paged from - * their own cursor below. - * - * Reviewers come from here rather than from `gh pr view --json reviewRequests` for two reasons: - * that field holds only requests still outstanding, so anyone who has already reviewed drops off - * it, and neither it nor any other `gh` JSON field carries an avatar. A reviewer can be a person - * or an app, and both are asked for by name because they are different GraphQL types. - * - * `viewerCanUpdate` and `viewerDidAuthor` ride along here for the same reason: they belong to the - * pull request this query is already standing on, so what the reader may do with it arrives with - * the conversation rather than costing a request of its own. - * - * Commits are asked for with `last` rather than `first`: `gh pr view --json commits` pages from - * the start, so a pull request with more than a hundred commits loses the newest ones from its - * view entirely. This query gives back the newest hundred, which is what a reader scoping a diff - * wants, and stands in for the `gh` list wherever it came back non-empty. - */ -export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) { - viewer { login } - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - reviewThreads(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { - totalCount - pageInfo { hasNextPage endCursor } - nodes { - id - isResolved - isOutdated - path - line - diffSide - comments(first: 10) { - totalCount - pageInfo { hasNextPage endCursor } - nodes { id author { __typename login avatarUrl } body createdAt lastEditedAt url ${REACTION_GROUPS_FIELDS} } - } - } - } - viewerCanUpdate - viewerDidAuthor - author { __typename login avatarUrl } - ${REACTION_GROUPS_FIELDS} - comments(first: ${GRAPHQL_PAGE_SIZE}) { - nodes { id lastEditedAt author { __typename login avatarUrl } ${REACTION_GROUPS_FIELDS} } - } - reviews(first: ${GRAPHQL_PAGE_SIZE}) { nodes { id lastEditedAt author { __typename login avatarUrl } ${REACTION_GROUPS_FIELDS} } } - reviewRequests(first: 50) { - nodes { - requestedReviewer { - ... on User { login name avatarUrl } - ... on Bot { __typename login avatarUrl } - } - } - } - latestReviews(first: 50) { - nodes { state author { __typename login avatarUrl } } - } - reviewDismissals: timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}) { - pageInfo { hasNextPage endCursor } - nodes { ... on ReviewDismissedEvent { dismissalMessage review { id } } } - } - commits(last: ${GRAPHQL_PAGE_SIZE}) { - nodes { - commit { - oid - messageHeadline - committedDate - additions - deletions - parents(first: 1) { totalCount } - authors(first: 3) { nodes { name avatarUrl user { login } } } - } - } - } - } - } -}`; - -/** - * The rest of one thread's conversation. GraphQL pages a connection nested inside another only - * from the inner node itself, so a thread longer than a page is followed on its own — a request - * GitHub makes necessary, and one no ordinary pull request ever provokes. - */ -export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $threadId: ID!, $cursor: String) { - viewer { login } - repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } } - node(id: $threadId) { - ... on PullRequestReviewThread { - pullRequest { id } - comments(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { - pageInfo { hasNextPage endCursor } - nodes { id author { __typename login avatarUrl } body createdAt lastEditedAt url ${REACTION_GROUPS_FIELDS} } - } - } - } -}`; - -const RawReviewThreadCommentsSchema = Schema.Struct({ - data: Schema.Struct({ - viewer: Schema.optional( - Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })), - ), - repository: Schema.NullOr( - Schema.Struct({ pullRequest: Schema.NullOr(Schema.Struct({ id: Schema.String })) }), - ), - /** Null for an id that names nothing the viewer can read, which is not a thread to page. */ - node: Schema.NullOr( - Schema.Struct({ - pullRequest: Schema.optional(Schema.Struct({ id: Schema.String })), - comments: Schema.optional(RawThreadCommentsSchema), - }), - ), - }), -}); - -export const REVIEW_THREAD_REPLY_GRAPHQL_MUTATION = `mutation($threadId: ID!, $body: String!) { - addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { - comment { id } - } -}`; - -/** - * The pull request's own node id, which is what a reaction on its description is addressed by. - * Read only when one is being written: the conversation carries an id for every remark in it, and - * the pull request is the one subject nothing in it names. - */ -export const PULL_REQUEST_NODE_ID_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } } -}`; - -const RawPullRequestNodeIdSchema = Schema.Struct({ - data: Schema.Struct({ - repository: Schema.Struct({ - pullRequest: Schema.Struct({ id: Schema.String }), - }), - }), -}); - -const decodePullRequestNodeId = decodeJsonResult(RawPullRequestNodeIdSchema); - -export function decodePullRequestNodeIdJson(raw: string): Result.Result { - const decoded = decodePullRequestNodeId(raw); - return Result.isSuccess(decoded) - ? Result.succeed(decoded.success.data.repository.pullRequest.id) - : Result.fail(decoded.failure); -} - -/** - * Where a client-given reaction subject actually hangs: the pull request itself, or the pull - * request an issue comment, a review comment, or a review belongs to. Read before a mutation - * reaches it, so a subject named for one pull request cannot react on another's behalf. - */ -export const REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $subjectId: ID!) { - repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } } - node(id: $subjectId) { - id - ... on IssueComment { pullRequest { id } } - ... on PullRequestReviewComment { pullRequest { id } } - ... on PullRequestReview { pullRequest { id } } - } -}`; - -const RawReactionSubjectScopeSchema = Schema.Struct({ - data: Schema.Struct({ - repository: Schema.NullOr( - Schema.Struct({ pullRequest: Schema.NullOr(Schema.Struct({ id: Schema.String })) }), - ), - node: Schema.NullOr( - Schema.Struct({ - id: Schema.String, - pullRequest: Schema.optional(Schema.Struct({ id: Schema.String })), - }), - ), - }), -}); - -const decodeReactionSubjectScope = decodeJsonResult(RawReactionSubjectScopeSchema); - -/** - * True when the subject named is the pull request itself, or hangs off it — false for anything - * else, including a subject or a pull request this host could not find. - */ -export function decodeReactionSubjectScopeJson(raw: string): Result.Result { - const decoded = decodeReactionSubjectScope(raw); - if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - const expected = decoded.success.data.repository?.pullRequest?.id ?? null; - const node = decoded.success.data.node; - const actual = node === null ? null : (node.pullRequest?.id ?? node.id); - return Result.succeed(expected !== null && actual !== null && expected === actual); -} - -export const ADD_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) { - addReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } } -}`; - -export const REMOVE_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) { - removeReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } } -}`; - -export const RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) { - resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } -}`; - -export const UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) { - unresolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } -}`; - -/** - * Rewrites the pull request's own words. Both are nullable so that one document serves a change - * to the title, to the description, or to the two together: a variable the request does not send - * puts no entry in the input at all, which leaves that field as it was rather than clearing it. - */ -export const UPDATE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $title: String, $body: String) { - updatePullRequest(input: { pullRequestId: $pullRequestId, title: $title, body: $body }) { - pullRequest { id } - } -}`; - -/** Creates a new pull request that reverses a merged pull request. */ -export const REVERT_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { - revertPullRequest(input: { pullRequestId: $pullRequestId }) { - revertPullRequest { id } - } -}`; - -/** - * The two comment mutations name their comment differently. The variable is spelled the same in - * both, so a rewrite sends one set of variables whichever kind of remark it is. - */ -export const UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { - updateIssueComment(input: { id: $commentId, body: $body }) { issueComment { id } } -}`; - -export const UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { - updatePullRequestReviewComment(input: { pullRequestReviewCommentId: $commentId, body: $body }) { - pullRequestReviewComment { id } - } -}`; - -/** The body of `POST /repos/{owner}/{repo}/pulls/{number}/reviews`, which sends a review whole. */ -const ReviewSubmissionSchema = Schema.Struct({ - event: Schema.Literals(["COMMENT", "APPROVE", "REQUEST_CHANGES"]), - body: Schema.String, - comments: Schema.Array( - Schema.Struct({ - path: Schema.String, - line: Schema.Int, - side: Schema.Literals(["LEFT", "RIGHT"]), - body: Schema.String, - }), - ), -}); - -const REVIEW_EVENTS: Record = { - comment: "COMMENT", - approve: "APPROVE", - "request-changes": "REQUEST_CHANGES", -}; - -/** - * The dismissal events past the page the thread read carries. A pull request rarely has any: - * this is followed only while the embedded page reports more. - */ -export const REVIEW_DISMISSALS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { - pageInfo { hasNextPage endCursor } - nodes { ... on ReviewDismissedEvent { dismissalMessage review { id } } } - } - } - } -}`; - -function gitHubReviewPosition(position: PullRequestReviewPosition): { - readonly line: number; - readonly side: "LEFT" | "RIGHT"; -} { - switch (position.kind) { - case "added": - return { line: position.newLine, side: "RIGHT" }; - case "deleted": - return { line: position.oldLine, side: "LEFT" }; - case "context": - return position.side === "left" - ? { line: position.oldLine, side: "LEFT" } - : { line: position.newLine, side: "RIGHT" }; - } -} - -/** The whole review as one request body, which is how GitHub keeps it invisible until sent. */ -export function buildReviewSubmission(input: { - readonly verdict: PullRequestReviewVerdict; - readonly body: string; - readonly comments: ReadonlyArray; -}): typeof ReviewSubmissionSchema.Type { - return { - event: REVIEW_EVENTS[input.verdict], - body: input.body, - comments: input.comments.map((comment) => ({ - path: comment.path, - ...gitHubReviewPosition(comment.position), - body: comment.body, - })), - }; -} - -export interface GitHubPullRequestListItem { - readonly stack?: PullRequestStackMembership; - /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */ - readonly authorId: string | null; - readonly number: number; - readonly title: string; - readonly url: string; - readonly author: PullRequestActor | null; - readonly headBranch: string; - readonly baseBranch: string; - readonly state: PullRequestState; - readonly isDraft: boolean; - readonly mergeability: PullRequestMergeability; - /** Null where GitHub has no verdict to summarise, which includes a draft nobody has reviewed. */ - readonly reviewDecision: PullRequestReviewDecision | null; - readonly additions: number; - readonly deletions: number; - readonly createdAt: string; - readonly updatedAt: string; - readonly reviewRequestLogins: ReadonlyArray; - /** At least one outstanding request targets a team rather than an individual login. */ - readonly hasTeamReviewRequest: boolean; - readonly labels: ReadonlyArray; - /** Null where the head commit reported no checks, which is not the same as passing none. */ - readonly checksState: PullRequestChecksState | null; -} - -export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { - /** True only when GitHub says the head belongs to another repository. */ - readonly isCrossRepository?: boolean; - /** The owner of the head branch's repository; null where `gh` did not say. */ - readonly headRepositoryOwner: string | null; - readonly headSha?: string | null; - readonly body: string; - readonly changedFiles: number; - readonly mergedAt: string | null; - readonly closedAt: string | null; - readonly checks: ReadonlyArray; - /** Absent where `gh` did not answer for auto-merge at all, which is not the same as off. */ - readonly autoMergeEnabled?: boolean; - /** Absent where auto-merge is off or GitHub did not report the stored strategy. */ - readonly autoMergeMethod?: PullRequestMergeMethod; -} - -export interface GitHubWorkflowRunApproval { - readonly id: number; - readonly name: string; - readonly url: string | null; -} - -export interface GitHubPullRequestHead { - readonly number: number; - readonly headSha: string; - readonly isCrossRepository?: boolean; - readonly headRepositoryOwner: string | null; -} - -export interface GitHubPullRequestActivity { - readonly author: PullRequestActor | null; - readonly comments: ReadonlyArray; - readonly commits: ReadonlyArray; -} - -function trimmed(value: string | null | undefined): string | null { - const text = value?.trim() ?? ""; - return text.length > 0 ? text : null; -} - -/** - * Null once a connection has nothing further, which is what ends every walk below. GitHub sends - * an `endCursor` on a page that is also the last one, so the flag is what decides, not the - * cursor's presence. - */ -function nextCursorOf( - pageInfo: Schema.Schema.Type | undefined, -): string | null { - return pageInfo?.hasNextPage === true ? trimmed(pageInfo.endCursor) : null; -} - -/** - * The viewer's standing on one pull request. The two halves take opposite defaults on purpose. - * - * Updating is a permission, so an install that does not report it grants it and lets the host's - * own refusal explain anything that fails. Authorship is not a permission but a fact about who - * wrote the thing, and it is read to decide what an author may do to their own change — so an - * unknown answer is "not the author", which grants nothing it should not. - */ -function toPullRequestViewerFields( - raw: Schema.Schema.Type | null | undefined, -): { readonly canUpdate: boolean; readonly didAuthor: boolean } { - return { canUpdate: raw?.viewerCanUpdate !== false, didAuthor: raw?.viewerDidAuthor === true }; -} - -function toActor(raw: Schema.Schema.Type | null | undefined) { - const login = trimmed(raw?.login); - return login === null - ? null - : { - login, - name: trimmed(raw?.name), - avatarUrl: trimmed(raw?.avatarUrl), - ...(raw?.__typename === "Bot" || raw?.is_bot === true ? { isBot: true } : {}), - }; -} - -function toCommitActor( - raw: NonNullable["authors"]>[number], -): PullRequestActor | null { - // An email-linked GitHub account has a login; an unlinked signature only has a name or email. - // Keep that signature visible instead of silently turning a co-authored commit into one author. - const login = trimmed(raw.login) ?? trimmed(raw.name) ?? trimmed(raw.email); - return login === null ? null : { login, name: trimmed(raw.name), avatarUrl: null }; -} - -/** An author off the GraphQL commits connection, which names an account by `user.login` where - * `gh pr view --json commits` names it by a flat `login` copied off the signature. */ -function toGraphqlCommitActor(raw: { - readonly name?: string | null | undefined; - readonly avatarUrl?: string | null | undefined; - readonly user?: { readonly login?: string | null | undefined } | null | undefined; -}): PullRequestActor | null { - const login = trimmed(raw.user?.login) ?? trimmed(raw.name); - return login === null - ? null - : { login, name: trimmed(raw.name), avatarUrl: trimmed(raw.avatarUrl) }; -} - -function toState(raw: { - readonly state?: string | null | undefined; - readonly mergedAt?: string | null | undefined; -}): PullRequestState { - if (trimmed(raw.mergedAt) !== null) return "merged"; - const state = raw.state?.trim().toUpperCase(); - if (state === "MERGED") return "merged"; - if (state === "CLOSED") return "closed"; - return "open"; -} - -function toMergeability(value: string | null | undefined): PullRequestMergeability { - switch (value?.trim().toUpperCase()) { - case "MERGEABLE": - return "mergeable"; - case "CONFLICTING": - return "conflicting"; - default: - return "unknown"; - } -} - -function toMergeMethod(value: string | null | undefined): PullRequestMergeMethod | undefined { - switch (value?.trim().toUpperCase()) { - case "MERGE": - return "merge"; - case "SQUASH": - return "squash"; - case "REBASE": - return "rebase"; - default: - return undefined; - } -} - -/** - * GitHub's own `reviewDecision` counts only reviews that satisfy the branch rules, so an - * approval from an app (a review bot) or from anyone without the required permission leaves it - * empty. The reviewers still said something, and a row should show it: when GitHub reports no - * verdict, the latest review per reviewer decides, changes requested outranking approval. - */ -function toReviewDecisionWithReviews( - value: string | null | undefined, - // `gh pr list` hands the reviews as an array; the GraphQL reads hand a connection. - latestReviews: - | ReadonlyArray> - | { readonly nodes: ReadonlyArray> } - | null - | undefined, -): PullRequestReviewDecision | null { - const summarized = toReviewDecision(value); - if (summarized === "approved" || summarized === "changes-requested") return summarized; - const reviews = - latestReviews === null || latestReviews === undefined - ? [] - : "nodes" in latestReviews - ? latestReviews.nodes - : latestReviews; - const states = new Set(reviews.map((review) => review.state?.trim().toUpperCase() ?? "")); - if (states.has("CHANGES_REQUESTED")) return "changes-requested"; - if (states.has("APPROVED")) return "approved"; - return summarized; -} - -function toReviewDecision(value: string | null | undefined): PullRequestReviewDecision | null { - switch (value?.trim().toUpperCase()) { - case "APPROVED": - return "approved"; - case "CHANGES_REQUESTED": - return "changes-requested"; - case "REVIEW_REQUIRED": - return "review-required"; - default: - return null; - } -} - -function toLabels( - raw: ReadonlyArray> | undefined, -): ReadonlyArray { - return (raw ?? []).flatMap((label) => { - const name = trimmed(label.name); - return name === null ? [] : [{ name, color: trimmed(label.color) }]; - }); -} - -/** - * User review requests only. Team requests are tracked separately because a slug cannot be - * compared with the viewer's login. - */ -function toReviewRequestLogins( - raw: ReadonlyArray> | undefined, -): ReadonlyArray { - return (raw ?? []).flatMap((request) => { - const login = trimmed(request.login); - return login === null ? [] : [login]; - }); -} - -function hasTeamReviewRequest( - raw: ReadonlyArray> | undefined, -): boolean { - return (raw ?? []).some( - (request) => - trimmed(request.login) === null && - (trimmed(request.slug) !== null || trimmed(request.name) !== null), - ); -} - -function toCheckStatus(raw: Schema.Schema.Type): PullRequestCheckStatus { - // Commit statuses report a single `state`; check runs report `status` plus a `conclusion` - // that only exists once the run has completed. - const status = raw.status?.trim().toUpperCase(); - if (status !== undefined && status !== "COMPLETED" && status !== "") { - return "pending"; - } - switch ((raw.conclusion ?? raw.state)?.trim().toUpperCase()) { - case "SUCCESS": - return "success"; - case "ACTION_REQUIRED": - return "action-required"; - case "FAILURE": - case "ERROR": - case "TIMED_OUT": - case "STARTUP_FAILURE": - return "failure"; - case "CANCELLED": - return "cancelled"; - case "SKIPPED": - return "skipped"; - case "PENDING": - case "EXPECTED": - return "pending"; - default: - return "neutral"; - } -} - -/** What GitHub writes where a run has not reached that moment yet, which is not a time. */ -const UNSET_TIMESTAMP = "0001-01-01T00:00:00Z"; - -function realTimestamp(value: string | null | undefined): string | null { - const at = trimmed(value); - return at === null || at === UNSET_TIMESTAMP ? null : at; -} - -/** Only a row the rollup gives no name of any kind, which is not a check anyone can show. */ -function isNamelessCheck(raw: Schema.Schema.Type): boolean { - return trimmed(raw.name) === null && trimmed(raw.context) === null; -} - -/** - * The rollup as the deduper reads it: a check, the workflow that owns it, and when the run last - * had something to say. A queued run reports a completion time it has not reached, so the start - * stands in for it rather than sorting the newest run to the bottom. - */ -function toCheckEntries( - raw: ReadonlyArray> | null | undefined, -): ReadonlyArray<{ - readonly check: PullRequestCheck; - readonly workflowName: string | null; - readonly at: string | null; -}> { - return (raw ?? []).flatMap((check) => { - const name = trimmed(check.name) ?? trimmed(check.context); - if (name === null) return []; - return [ - { - check: { - name, - status: toCheckStatus(check), - description: trimmed(check.description), - url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), - ...(typeof check.isRequired === "boolean" ? { required: check.isRequired } : {}), - }, - workflowName: trimmed(check.workflowName), - at: realTimestamp(check.completedAt) ?? realTimestamp(check.startedAt), - }, - ]; - }); -} - -/** - * The one word a listing row has space for. A failure outranks anything still running, the way - * GitHub's own indicator reads: a run that has already gone red will not go green by finishing. - * - * Null rather than "passing" for a head commit with no checks at all, so a repository that runs - * none shows nothing instead of a green tick it never earned. A cancelled run is a failure, as - * GitHub's own rollup and the client's detail rollup both read it; skipped and neutral count - * towards neither, so the row and the detail header never disagree about one head commit. - * - * Counted off the deduped checks rather than the raw rollup, so the word and the list under it - * cannot disagree: the run a re-run replaced is not a verdict twice. A row with no name at all is - * counted as it comes, since the cross-repository search dresses GitHub's own rollup enum as one - * nameless row, and nothing nameless can collide with anything. - */ -function rollupChecksState( - raw: ReadonlyArray> | null | undefined, -): PullRequestChecksState | null { - const statuses = [ - ...toChecks(raw).map((check) => check.status), - ...(raw ?? []).filter(isNamelessCheck).map((check) => toCheckStatus(check)), - ]; - if (statuses.length === 0) return null; - if (statuses.includes("failure") || statuses.includes("cancelled")) return "failing"; - if (statuses.includes("pending") || statuses.includes("action-required")) return "pending"; - return statuses.includes("success") ? "passing" : null; -} - -function toChecks( - raw: ReadonlyArray> | null | undefined, -): ReadonlyArray { - return dedupeChecks(toCheckEntries(raw)); -} - -/** The states that are a verdict in themselves, rather than a wrapper around line comments. */ -function isReviewVerdict(reviewState: string | null): boolean { - switch (reviewState?.toUpperCase()) { - case "APPROVED": - case "CHANGES_REQUESTED": - case "DISMISSED": - return true; - default: - return false; - } -} - -function toComments(raw: { - readonly comments?: ReadonlyArray> | undefined; - readonly reviews?: ReadonlyArray> | undefined; -}): ReadonlyArray { - const issueComments = (raw.comments ?? []).map((comment): PullRequestComment => ({ - id: comment.id, - kind: "issue-comment", - author: toActor(comment.author), - body: comment.body ?? "", - createdAt: comment.createdAt, - editedAt: comment.lastEditedAt ?? null, - url: trimmed(comment.url), - path: null, - reviewState: null, - })); - // A review with no body is kept only when its state is the event itself — an approval, a - // request for changes, a dismissal. GitHub also opens a bodiless `COMMENTED` review as the - // container for line comments, and those comments are read from the review threads, so - // keeping the container too would show a row with a name and nothing under it. - const reviews = (raw.reviews ?? []).flatMap((review): ReadonlyArray => { - const submittedAt = trimmed(review.submittedAt); - const reviewState = trimmed(review.state); - if ( - submittedAt === null || - ((review.body ?? "").trim().length === 0 && !isReviewVerdict(reviewState)) - ) { - return []; - } - return [ - { - id: review.id, - kind: "review", - author: toActor(review.author), - body: review.body ?? "", - createdAt: submittedAt, - editedAt: review.lastEditedAt ?? null, - url: trimmed(review.url), - path: null, - reviewState, - }, - ]; - }); - return [...issueComments, ...reviews].toSorted((left, right) => - left.createdAt.localeCompare(right.createdAt), - ); -} - -function toCommits( - commits: ReadonlyArray> | undefined, -): ReadonlyArray { - return (commits ?? []).map((commit) => ({ - oid: commit.oid, - messageHeadline: commit.messageHeadline ?? "", - committedDate: commit.committedDate, - authors: (commit.authors ?? []).flatMap((author) => { - const actor = toCommitActor(author); - return actor === null ? [] : [actor]; - }), - })); -} - -function toListItem(raw: Schema.Schema.Type): GitHubPullRequestListItem { - return { - authorId: trimmed(raw.author?.id), - number: raw.number, - title: raw.title, - url: raw.url, - author: toActor(raw.author), - headBranch: raw.headRefName, - baseBranch: raw.baseRefName, - state: toState(raw), - isDraft: raw.isDraft ?? false, - mergeability: toMergeability(raw.mergeable), - reviewDecision: toReviewDecisionWithReviews(raw.reviewDecision, raw.latestReviews), - additions: raw.additions ?? 0, - deletions: raw.deletions ?? 0, - createdAt: raw.createdAt, - updatedAt: raw.updatedAt, - reviewRequestLogins: toReviewRequestLogins(raw.reviewRequests), - hasTeamReviewRequest: hasTeamReviewRequest(raw.reviewRequests), - labels: toLabels(raw.labels), - checksState: rollupChecksState(raw.statusCheckRollup), - }; -} - -function toDetail(raw: Schema.Schema.Type): GitHubPullRequestDetail { - const autoMergeMethod = toMergeMethod(raw.autoMergeRequest?.mergeMethod); - return { - ...toListItem(raw), - ...(typeof raw.isCrossRepository === "boolean" - ? { isCrossRepository: raw.isCrossRepository } - : {}), - headRepositoryOwner: trimmed(raw.headRepositoryOwner?.login), - headSha: trimmed(raw.headRefOid), - body: raw.body ?? "", - changedFiles: raw.changedFiles ?? 0, - mergedAt: trimmed(raw.mergedAt), - closedAt: trimmed(raw.closedAt), - checks: toChecks(raw.statusCheckRollup), - // A JSON null is GitHub saying "nobody armed this"; a missing key is GitHub not saying, and - // the difference survives here rather than being flattened into false. - ...(raw.autoMergeRequest === undefined - ? {} - : { autoMergeEnabled: raw.autoMergeRequest !== null }), - ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }), - }; -} - -const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); -const decodeSearch = decodeJsonResult(RawSearchSchema); -const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema); -const decodeStats = decodeJsonResult(RawStatsSchema); -const decodeDetail = decodeJsonResult(RawDetailSchema); -const decodeWorkflowRuns = decodeJsonResult(RawWorkflowRunsSchema); -const decodePullRequestHeads = decodeJsonResult(RawPullRequestHeadsSchema); -const decodeActivity = decodeJsonResult(RawActivitySchema); -const decodeRepositoryPullRequests = decodeJsonResult(RawRepositoryPullRequestsSchema); -const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema); -const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema); -const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema); - -type DecodeFailure = Cause.Cause; - -export interface GitHubPullRequestListBatch { - readonly items: ReadonlyArray; - /** Rows GitHub returned, counted before decoding, so a skipped row cannot hide a next page. */ - readonly rawCount: number; - /** Where the next page starts, or null once GitHub has handed over every row. */ - readonly endCursor: string | null; -} - -/** - * One page of a repository's own pull request list. Malformed rows are skipped rather than - * failing the page: one unexpected pull request must not blank the whole list. A repository the - * viewer cannot see answers as an empty page, which is what `gh pr list` printed for it too. - */ -export function decodePullRequestListJson( - raw: string, -): Result.Result { - const decoded = decodeRepositoryPullRequests(raw); - if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - const connection = decoded.success.data.repository?.pullRequests; - const nodes = connection?.nodes ?? []; - const items: GitHubPullRequestListItem[] = []; - for (const entry of nodes) { - const row = toRow(entry); - if (row !== null) items.push(row.item); - } - return Result.succeed({ - items, - rawCount: nodes.length, - endCursor: - connection?.pageInfo?.hasNextPage === true ? trimmed(connection.pageInfo.endCursor) : null, - }); -} - -/** - * A row as GraphQL answers it: reviewers and labels arrive as connections, and the checks as - * GitHub's one-word rollup rather than the whole check list. Null for a node that is not a pull - * request, or one whose fields no longer decode. - */ -function toRow( - entry: unknown, -): { readonly item: GitHubPullRequestListItem; readonly repository: string | null } | null { - const decodedNode = decodeSearchItem(entry); - if (!Exit.isSuccess(decodedNode)) return null; - const node = decodedNode.value; - const stack = toStackMembership(node); - const reviewRequests = (node.reviewRequests?.nodes ?? []).flatMap( - (request): ReadonlyArray<{ readonly login?: string; readonly slug?: string }> => { - const reviewer = request?.requestedReviewer; - const login = trimmed(reviewer?.login); - if (login !== null) return [{ login }]; - const slug = trimmed(reviewer?.slug); - return slug === null ? [] : [{ slug }]; - }, - ); - return { - item: { - ...toListItem({ - ...node, - latestReviews: (node.latestReviews?.nodes ?? []).flatMap((review) => - review === null ? [] : [review], - ), - reviewRequests, - labels: (node.labels?.nodes ?? []).flatMap((label) => (label === null ? [] : [label])), - // The rollup arrives as one enum. Dressed as a single check here so it is read the same - // way the detail's checks are. - statusCheckRollup: (node.commits?.nodes ?? []).flatMap((commitNode) => { - const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); - return state === null ? [] : [{ state }]; - }), - }), - ...(stack === undefined ? {} : { stack }), - }, - repository: trimmed(node.repository?.nameWithOwner), - }; -} - -export interface GitHubPullRequestSearchItem extends GitHubPullRequestListItem { - /** `owner/name` as GitHub spells it, which is how a row from a search finds its repository. */ - readonly repository: string; -} - -export interface GitHubPullRequestSearchBatch { - readonly items: ReadonlyArray; - /** Rows the search returned, counted before decoding, so a skipped row cannot hide a next page. */ - readonly rawCount: number; - /** More rows than this slice asked for, which is truncation for every repository in it. */ - readonly hasNextPage: boolean; - /** Where the next page of the same search starts. */ - readonly endCursor: string | null; -} - -/** - * A search answers with the same row a repository's list does, each naming its repository. - * - * Rows that are not pull requests decode as empty and are skipped, the way a malformed listing - * row is — `is:pr` already excludes them, and one surprise must not blank a whole host. - */ -export function decodePullRequestSearchJson( - raw: string, -): Result.Result { - const decoded = decodeSearch(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const search = decoded.success.data.search; - const nodes = search.nodes ?? []; - const items: GitHubPullRequestSearchItem[] = []; - for (const entry of nodes) { - const row = toRow(entry); - if (row === null || row.repository === null) continue; - items.push({ ...row.item, repository: row.repository }); - } - return Result.succeed({ - items, - rawCount: nodes.length, - hasNextPage: search.pageInfo?.hasNextPage ?? false, - endCursor: search.pageInfo?.hasNextPage === true ? trimmed(search.pageInfo.endCursor) : null, - }); -} - -function toStackMembership( - raw: Schema.Schema.Type, -): PullRequestStackMembership | undefined { - return raw.stack && raw.stackEntry - ? { - number: raw.stack.number, - size: raw.stack.size, - base: raw.stack.baseRefName, - position: raw.stackEntry.position, - } - : undefined; -} - -/** What GitHub allows in an owner or repository name. */ -const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/; - -/** - * The owner and name of an `owner/name` selector, or null for one GitHub cannot name. A batch is - * one document, and a single unanswerable alias fails all of it, so these are refused up front. - */ -function repositoryParts( - repository: string, -): { readonly owner: string; readonly name: string } | null { - const [owner, name, ...rest] = repository.trim().split("/"); - if (rest.length > 0 || owner === undefined || name === undefined) return null; - return REPOSITORY_PART.test(owner) && REPOSITORY_PART.test(name) ? { owner, name } : null; -} - -const isPullRequestNumber = (number: number) => Number.isSafeInteger(number) && number > 0; - -/** One aliased `repository { pullRequest { selection } }` per change request, on one host. */ -function aliasedPullRequestsDocument( - name: string, - alias: string, - changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, - selection: string, -): GraphQlDocument | null { - const addressed = changeRequests.map((changeRequest) => ({ - parts: repositoryParts(changeRequest.repository), - number: changeRequest.number, - })); - if (addressed.some(({ parts, number }) => parts === null || !isPullRequestNumber(number))) { - return null; - } - return aliasedGraphQlDocument({ - operation: "query", - name, - alias, - items: addressed, - variables: ({ parts, number }) => ({ - owner: ["String!", parts!.owner], - name: ["String!", parts!.name], - number: ["Int!", number], - }), - field: ({ owner, name, number }) => - `repository(owner: ${owner}, name: ${name}) { pullRequest(number: ${number}) { ${selection} } }`, - }); -} - -/** - * The line counts for rows a listing already handed over, as one aliased lookup each. - * - * Aliases rather than `nodes(ids:)` because the caller asks in the terms the page holds — a - * repository and a number — and never sees a node id. Null for a selector GitHub cannot name, - * which the caller reports rather than sends, and for an empty request. - */ -export function buildPullRequestStatsGraphQlQuery( - changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, -): GraphQlDocument | null { - return aliasedPullRequestsDocument( - "PullRequestStats", - "s", - changeRequests, - "additions deletions", - ); -} - -/** Stack membership for the visible rows of a per-repository listing. */ -export function buildPullRequestStackMembershipsGraphQlQuery( - repository: string, - numbers: ReadonlyArray, -): GraphQlDocument | null { - return aliasedPullRequestsDocument( - "PullRequestStackMemberships", - "s", - numbers.map((number) => ({ repository, number })), - STACK_MEMBERSHIP_SELECTION, - ); -} - -const decodeStackMemberships = decodeJsonResult(RawStackMembershipsSchema); - -export function decodePullRequestStackMembershipsJson( - raw: string, -): Result.Result, DecodeFailure> { - const decoded = decodeStackMemberships(raw); - if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - const memberships = new Map(); - for (const [alias, value] of Object.entries(decoded.success.data)) { - const index = /^s(\d+)$/.exec(alias)?.[1]; - if (index === undefined || value?.pullRequest == null) continue; - const stack = toStackMembership(value.pullRequest); - if (stack !== undefined) memberships.set(Number(index), stack); - } - return Result.succeed(memberships); -} - -/** - * The counts by the position they were asked in. A repository or a pull request GitHub answered - * nothing for is simply absent, which leaves the row with whatever it already had. - */ -export function decodePullRequestStatsJson( - raw: string, -): Result.Result< - ReadonlyMap, - DecodeFailure -> { - const decoded = decodeStats(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const stats = new Map(); - for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { - const index = /^s(\d+)$/.exec(alias)?.[1]; - const pullRequest = value?.pullRequest; - if (index === undefined || pullRequest == null) continue; - stats.set(Number(index), { - additions: pullRequest.additions ?? 0, - deletions: pullRequest.deletions ?? 0, - }); - } - return Result.succeed(stats); -} - -/** - * The fields a linked thread keeps current, for many pull requests in one aliased read. Same - * shape as the search row where the two overlap: the checks arrive as GitHub's one-word rollup - * rather than the whole check list `gh pr view` hands back, which is what keeps a batch cheap. - */ -const STACK_MEMBERSHIP_SELECTION = "stack { number size baseRefName } stackEntry { position }"; - -const PULL_REQUEST_SUMMARY_SELECTION = - "number title url state isDraft mergeable reviewDecision additions deletions changedFiles " + - "updatedAt mergedAt closedAt headRefName baseRefName " + - "author { __typename login avatarUrl ... on User { name } } " + - "latestReviews(first: 20) { nodes { state author { login } } } " + - "commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }"; - -/** Summaries for pull requests anywhere on one host, one aliased lookup each; null when unsafe. */ -export function buildPullRequestSummariesGraphQlQuery( - changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, - includeStacks = false, -): GraphQlDocument | null { - return aliasedPullRequestsDocument( - "PullRequestSummaries", - "s", - changeRequests, - `${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""}`, - ); -} - -/** One pull request's summary, aliased the way the batch is so the same decoder reads it. */ -export function pullRequestSummaryGraphQlQuery(includeStacks = false): string { - return `query($owner: String!, $name: String!, $number: Int!) { - s0: repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""} } } -}`; -} - -const RawSummarySchema = Schema.Struct({ - ...RawSearchItemSchema.fields, - changedFiles: Schema.optional(Schema.NullOr(Schema.Int)), - additions: Schema.optional(Schema.NullOr(Schema.Int)), - deletions: Schema.optional(Schema.NullOr(Schema.Int)), - closedAt: Schema.optional(Schema.NullOr(Schema.String)), - createdAt: Schema.optional(Schema.String), -}); -const decodeSummaries = decodeJsonResult( - Schema.Struct({ - data: Schema.optional( - Schema.NullOr( - Schema.Record( - Schema.String, - Schema.NullOr(Schema.Struct({ pullRequest: Schema.optional(Schema.Unknown) })), - ), - ), - ), - }), -); -const decodeSummaryEntry = Schema.decodeUnknownExit(RawSummarySchema); - -export interface GitHubPullRequestSummary { - readonly number: number; - readonly title: string; - readonly url: string; - readonly headBranch: string; - readonly baseBranch: string; - readonly state: PullRequestState; - readonly isDraft: boolean; - readonly closedAt: string | null; - readonly mergedAt: string | null; - readonly updatedAt: string; - readonly author: PullRequestActor | null; - readonly additions: number; - readonly deletions: number; - readonly changedFiles: number; - readonly reviewDecision: PullRequestReviewDecision | null; - readonly checksState: PullRequestChecksState | null; - readonly mergeability: PullRequestMergeability; - /** Null when GitHub says the pull request is in no stack; absent when the read did not ask. */ - readonly stack?: PullRequestStackMembership | null; -} - -/** - * Summaries by the position they were asked in. A pull request GitHub answered nothing for, or - * one whose fields no longer decode, is absent rather than failing the rest of the batch. - */ -export function decodePullRequestSummariesJson( - raw: string, -): Result.Result, DecodeFailure> { - const decoded = decodeSummaries(raw); - if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - const summaries = new Map(); - for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { - const index = /^s(\d+)$/.exec(alias)?.[1]; - if (index === undefined || value?.pullRequest == null) continue; - const entry = decodeSummaryEntry(value.pullRequest); - if (!Exit.isSuccess(entry)) continue; - const pr = entry.value; - summaries.set(Number(index), { - number: pr.number, - title: pr.title, - url: pr.url, - headBranch: pr.headRefName, - baseBranch: pr.baseRefName, - state: toState(pr), - isDraft: pr.isDraft ?? false, - closedAt: trimmed(pr.closedAt), - mergedAt: trimmed(pr.mergedAt), - updatedAt: pr.updatedAt, - author: toActor(pr.author), - additions: pr.additions ?? 0, - deletions: pr.deletions ?? 0, - changedFiles: pr.changedFiles ?? 0, - reviewDecision: toReviewDecisionWithReviews( - pr.reviewDecision, - (pr.latestReviews?.nodes ?? []).flatMap((review) => (review === null ? [] : [review])), - ), - // One enum for the head commit, dressed as a single check like the search row's. - checksState: rollupChecksState( - (pr.commits?.nodes ?? []).flatMap((commitNode) => { - const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); - return state === null ? [] : [{ state }]; - }), - ), - mergeability: toMergeability(pr.mergeable), - ...(pr.stack === undefined ? {} : { stack: toStackMembership(pr) ?? null }), - }); - } - return Result.succeed(summaries); -} - -const WATCH_FINGERPRINT_EDITS = "totalCount nodes { lastEditedAt }"; -const WATCH_FINGERPRINT_CHECK_COUNTS = - "checkRunCountsByState { state count } statusContextCountsByState { state count }"; - -/** - * What a pull request watch needs to notice, priced by GitHub at one point for twenty-five pull - * requests, where the detail and activity reads it gates cost sixteen for one. Counts and the - * newest edit cover new comments, reviews (a reply in a review thread is a review), threads, and - * a bot rewriting its summary; check counts by state move whenever a check starts or finishes. - * Comments come most recently updated first, since an edit moves `updatedAt`, so an edit to an - * old comment on a long pull request is still in the page. Reviews have no such order: an edit - * to a review older than the last hundred waits for the watch's periodic full read, as do edits - * to comments inside review threads, which cost a point per pull request to ask for. - */ -const PULL_REQUEST_WATCH_FINGERPRINT_SELECTION = - "state mergeable headRefOid " + - `comments(first: 100, orderBy: { field: UPDATED_AT, direction: DESC }) { ${WATCH_FINGERPRINT_EDITS} } ` + - `reviews(last: 100) { ${WATCH_FINGERPRINT_EDITS} } ` + - "reviewThreads { totalCount } " + - `commits(last: 1) { nodes { commit { statusCheckRollup { contexts { ${WATCH_FINGERPRINT_CHECK_COUNTS} } } } } }`; - -/** Watch fingerprints for pull requests on one host, one aliased lookup each; null when unsafe. */ -export function buildPullRequestWatchFingerprintsGraphQlQuery( - changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, -): GraphQlDocument | null { - return aliasedPullRequestsDocument( - "PullRequestWatchFingerprints", - "w", - changeRequests, - PULL_REQUEST_WATCH_FINGERPRINT_SELECTION, - ); -} - -/** - * Two parts, so a watch reads only what moved: `status` (state, mergeability, head, and checks) - * needs the detail read, and `remarks` also needs the far costlier activity read. - */ -export interface GitHubPullRequestWatchFingerprint { - readonly status: string; - readonly remarks: string; -} - -const RawEditedSchema = Schema.Struct({ - totalCount: Schema.Int, - nodes: Schema.Array(Schema.NullOr(Schema.Struct({ lastEditedAt: Schema.NullOr(Schema.String) }))), -}); -const RawStateCountsSchema = Schema.NullOr( - Schema.Array(Schema.Struct({ state: Schema.String, count: Schema.Int })), -); -const decodeWatchFingerprintEntry = Schema.decodeUnknownExit( - Schema.Struct({ - state: Schema.String, - mergeable: Schema.NullOr(Schema.String), - headRefOid: Schema.String, - comments: RawEditedSchema, - reviews: RawEditedSchema, - reviewThreads: Schema.Struct({ totalCount: Schema.Int }), - commits: Schema.Struct({ - nodes: Schema.Array( - Schema.NullOr( - Schema.Struct({ - commit: Schema.Struct({ - statusCheckRollup: Schema.NullOr( - Schema.Struct({ - contexts: Schema.Struct({ - checkRunCountsByState: RawStateCountsSchema, - statusContextCountsByState: RawStateCountsSchema, - }), - }), - ), - }), - }), - ), - ), - }), - }), -); - -// An edit only ever moves `lastEditedAt` forward, so the newest one stands for them all. -const newestEdit = (connection: typeof RawEditedSchema.Type) => - connection.nodes.reduce( - (newest, node) => - node?.lastEditedAt != null && node.lastEditedAt > newest ? node.lastEditedAt : newest, - "", - ); - -const stateCounts = (counts: typeof RawStateCountsSchema.Type) => - (counts ?? []) - .filter(({ count }) => count > 0) - .map(({ state, count }) => `${state}:${count}`) - .toSorted() - .join(","); - -/** Fingerprints by alias index; a pull request GitHub returned nothing for is left out. */ -export function decodePullRequestWatchFingerprintsJson( - raw: string, -): Result.Result, DecodeFailure> { - const decoded = decodeSummaries(raw); - if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - const fingerprints = new Map(); - for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { - const index = /^w(\d+)$/.exec(alias)?.[1]; - if (index === undefined || value?.pullRequest == null) continue; - const entry = decodeWatchFingerprintEntry(value.pullRequest); - if (!Exit.isSuccess(entry)) continue; - const pr = entry.value; - const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts; - fingerprints.set(Number(index), { - status: [ - pr.state, - pr.mergeable ?? "", - pr.headRefOid, - stateCounts(contexts?.checkRunCountsByState ?? null), - stateCounts(contexts?.statusContextCountsByState ?? null), - ].join(" "), - remarks: [ - pr.comments.totalCount, - newestEdit(pr.comments), - pr.reviews.totalCount, - newestEdit(pr.reviews), - pr.reviewThreads.totalCount, - ].join(" "), - }); - } - return Result.succeed(fingerprints); -} - -export interface GitHubPullRequestCore extends GitHubPullRequestDetail { - readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess; - readonly comparison: GitHubBaseComparison | null; - readonly checksTruncated: boolean; -} - -export function decodePullRequestCoreJson( - raw: string, -): Result.Result { - const decoded = decodeCore(raw); - if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - const repository = decoded.success.data.repository; - const pr = repository.pullRequest; - const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts; - return Result.succeed({ - ...toDetail({ - ...pr, - reviewRequests: pr.reviewRequests.nodes.flatMap(({ requestedReviewer }) => - requestedReviewer === null ? [] : [requestedReviewer], - ), - labels: pr.labels.nodes, - statusCheckRollup: toCheckContexts(contexts?.nodes ?? []), - }), - viewerAccess: { - canWrite: toCanWrite(repository.viewerPermission), - canTriage: toCanTriage(repository.viewerPermission), - ...toPullRequestViewerFields(pr), - mergeCapabilities: { - merge: repository.mergeCommitAllowed, - squash: repository.squashMergeAllowed, - rebase: repository.rebaseMergeAllowed, - }, - }, - comparison: - pr.state !== "OPEN" || pr.baseRef?.compare == null - ? null - : { - behindBy: pr.baseRef.compare.behindBy, - viewerCanUpdate: pr.viewerCanUpdateBranch, - }, - checksTruncated: contexts?.pageInfo.hasNextPage === true, - }); -} - -export function decodePullRequestDetailJson( - raw: string, -): Result.Result { - const decoded = decodeDetail(raw); - return Result.isSuccess(decoded) - ? Result.succeed(toDetail(decoded.success)) - : Result.fail(decoded.failure); -} - -export interface GitHubWorkflowRunPage { - readonly runs: ReadonlyArray; - /** Runs on the page, counted before decoding, which is what decides whether to page on. */ - readonly rawCount: number; -} - -/** One page of `actions/runs`. */ -export function decodeWorkflowRunsJson( - raw: string, -): Result.Result { - const decoded = decodeWorkflowRuns(raw); - if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - return Result.succeed({ - runs: decoded.success.workflow_runs.map((run) => ({ - id: run.id, - name: trimmed(run.name) ?? `Workflow run ${run.id}`, - url: trimmed(run.html_url), - })), - rawCount: decoded.success.workflow_runs.length, - }); -} - -/** Open pull requests whose head branch has one name, from whichever repository it lives in. */ -export const PULL_REQUEST_HEADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $head: String!, $after: String) { - repository(owner: $owner, name: $name) { - pullRequests(first: 100, after: $after, states: [OPEN], headRefName: $head) { - pageInfo { hasNextPage endCursor } - nodes { number headRefOid isCrossRepository headRepositoryOwner { login } } - } - } -}`; - -export function decodePullRequestHeadsJson(raw: string): Result.Result< - { - readonly heads: ReadonlyArray; - readonly nextCursor: string | null; - }, - DecodeFailure -> { - const decoded = decodePullRequestHeads(raw); - if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - const connection = decoded.success.data.repository?.pullRequests; - return Result.succeed({ - heads: (connection?.nodes ?? []).map((pullRequest) => ({ - number: pullRequest.number, - headSha: pullRequest.headRefOid, - ...(typeof pullRequest.isCrossRepository === "boolean" - ? { isCrossRepository: pullRequest.isCrossRepository } - : {}), - headRepositoryOwner: trimmed(pullRequest.headRepositoryOwner?.login), - })), - nextCursor: nextCursorOf(connection?.pageInfo), - }); -} - -export interface GitHubPullRequestActivityPage { - /** Only on the first page, which is the one that asks for them. */ - readonly author?: PullRequestActor | null; - readonly commits?: ReadonlyArray; - /** Issue comments and reviews, each list unsorted. */ - readonly remarks: ReadonlyArray; - readonly nextCommentsCursor: string | null; - readonly nextReviewsCursor: string | null; -} - -/** One page of `PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY`. */ -export function decodePullRequestActivityJson( - raw: string, -): Result.Result { - const decoded = decodeActivity(raw); - if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - const pr = decoded.success.data.repository.pullRequest; - return Result.succeed({ - ...(pr.author === undefined ? {} : { author: toActor(pr.author) }), - ...(pr.commits === undefined - ? {} - : { - commits: toCommits( - (pr.commits?.nodes ?? []).map(({ commit }) => ({ - ...commit, - authors: (commit.authors?.nodes ?? []).map((author) => ({ - ...author, - login: author.user?.login ?? null, - })), - })), - ), - }), - remarks: toComments({ - comments: pr.comments?.nodes ?? [], - reviews: pr.reviews?.nodes ?? [], - }), - nextCommentsCursor: nextCursorOf(pr.comments?.pageInfo), - nextReviewsCursor: nextCursorOf(pr.reviews?.pageInfo), - }); -} - -/** Every check context of the head commit, a page at a time, for a rollup past one page. */ -export const pullRequestCheckContextsGraphQlQuery = (host: string) => - `query($owner: String!, $name: String!, $number: Int!, $after: String) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - headRefOid - commits(last: 1) { - nodes { commit { statusCheckRollup { contexts(first: 100, after: $after) { - ${checkContextNodesSelection(host)} - pageInfo { hasNextPage endCursor } - } } } } - } - } - } -}`; - -const RawCheckContextNodeSchema = Schema.Struct({ - ...RawCheckSchema.fields, - checkSuite: Schema.optional( - Schema.NullOr( - Schema.Struct({ - workflowRun: Schema.NullOr( - Schema.Struct({ workflow: Schema.NullOr(Schema.Struct({ name: Schema.String })) }), - ), - }), - ), - ), -}); - -const decodeCheckContexts = decodeJsonResult( - Schema.Struct({ - data: Schema.Struct({ - repository: Schema.Struct({ - pullRequest: Schema.Struct({ - headRefOid: Schema.String, - commits: Schema.Struct({ - nodes: Schema.Array( - Schema.Struct({ - commit: Schema.Struct({ - statusCheckRollup: Schema.NullOr( - Schema.Struct({ - contexts: Schema.Struct({ - nodes: Schema.Array(RawCheckContextNodeSchema), - pageInfo: RawPageInfoSchema, - }), - }), - ), - }), - }), - ), - }), - }), - }), - }), - }), -); - -/** A check context as the rollup reports it; opaque outside this module. */ -export type GitHubCheckContext = Schema.Schema.Type; - -function toCheckContexts( - nodes: ReadonlyArray>, -): ReadonlyArray { - return nodes.map((check) => ({ - ...check, - workflowName: check.checkSuite?.workflowRun?.workflow?.name ?? null, - })); -} - -export function decodePullRequestCheckContextsJson(raw: string): Result.Result< - { - readonly headSha: string; - readonly contexts: ReadonlyArray; - readonly nextCursor: string | null; - }, - DecodeFailure -> { - const decoded = decodeCheckContexts(raw); - if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - const pr = decoded.success.data.repository.pullRequest; - const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts; - return Result.succeed({ - headSha: pr.headRefOid, - contexts: toCheckContexts(contexts?.nodes ?? []), - nextCursor: nextCursorOf(contexts?.pageInfo), - }); -} - -/** The checks and their one-word verdict, from every context of the head commit. */ -export function pullRequestChecksFromContexts(contexts: ReadonlyArray): { - readonly checks: ReadonlyArray; - readonly checksState: PullRequestChecksState | null; -} { - return { checks: toChecks(contexts), checksState: rollupChecksState(contexts) }; -} - -export interface GitHubReviewThreadComments { - readonly comments: ReadonlyArray; - /** Dismissal reasons by the dismissed review's node id, read off the timeline. */ - readonly dismissalsByReviewId: ReadonlyMap; - /** Whole conversations, kept anchored so the diff can pin them to their line. */ - readonly reviewThreads: ReadonlyArray; - /** The host's own count of the conversation, which a bounded read can fall short of. */ - readonly commentCount: number; - readonly truncated: boolean; - readonly reviewThreadsTruncated: boolean; - /** The pull request's own reactions, which sit on its description. */ - readonly reactions: ReadonlyArray; - /** Reactions by node id, for the comments and reviews the `gh` JSON read carries no reaction on. */ - readonly reactionsById: ReadonlyMap>; - readonly editedAtById: ReadonlyMap; - /** - * Everyone on the review: those still asked and those who have already answered. Whoever has - * reviewed is no longer an outstanding request, so asking only for requests reports nobody on - * a pull request that has in fact been reviewed. - */ - readonly reviewers: ReadonlyArray; - /** - * Avatars by login, for the actors `gh pr view --json` reports without one — which is all of - * them, since no `gh` JSON field carries an avatar. Collected from everyone this query names, - * so an app's avatar arrives the same way a person's does. - */ - readonly avatarsByLogin: ReadonlyMap; - readonly botLogins: ReadonlySet; - /** Per-commit line counts carried by the same bounded pull-request query. */ - readonly commitStats: ReadonlyMap< - string, - { readonly additions: number; readonly deletions: number } - >; - /** - * The newest hundred commits, oldest to newest, off the same query's `commits(last: ...)`. - * Empty wherever the read never happened (an install too old for the field, a degraded page), - * which the caller reads as "keep the `gh pr view` list" rather than as "this pull request has - * no commits". - */ - readonly commits: ReadonlyArray; - /** What GitHub says the reader may do with this pull request, read off the same response. */ - readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean }; -} - -/** One thread as this page found it, with what it takes to finish reading it. */ -export interface GitHubReviewThreadEntry { - readonly thread: PullRequestReviewThread; - /** How many comments GitHub says the thread holds, read or not. */ - readonly commentCount: number; - /** Where the rest of this thread's comments carry on from, or null once it is whole. */ - readonly nextCommentCursor: string | null; -} - -export interface GitHubReviewThreadPage { - readonly threads: ReadonlyArray; - /** Where the next page of threads starts, or null once the host has handed them all over. */ - readonly nextCursor: string | null; - /** The pull request's own reactions, which sit on its description. */ - readonly reactions: ReadonlyArray; - /** - * Reactions by node id, for the conversation comments and reviews `gh pr view --json` answers - * for without any. Only ids with a reaction are here; the rest carry none. - */ - readonly reactionsById: ReadonlyMap>; - readonly editedAtById: ReadonlyMap; - readonly reviewers: ReadonlyArray; - readonly avatarsByLogin: ReadonlyMap; - readonly botLogins: ReadonlySet; - readonly commitStats: ReadonlyMap< - string, - { readonly additions: number; readonly deletions: number } - >; - readonly commits: ReadonlyArray; - readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean }; - /** Dismissal reasons by the dismissed review's node id, which the review itself never carries. */ - readonly dismissalsByReviewId: ReadonlyMap; - /** Where the rest of the dismissal events start, or null once this page carried them all. */ - readonly nextDismissalCursor: string | null; -} - -/** - * The threads as one flat conversation, which is what the timeline reads. Every comment of - * every thread, resolved or not: a resolved conversation is still what was said, and a reply is - * as much of it as the remark it answers. - */ -export function reviewThreadConversation( - threads: ReadonlyArray, -): ReadonlyArray { - return threads.flatMap((thread) => - thread.comments.map((comment): PullRequestComment => ({ - id: comment.id, - kind: "review-comment", - author: comment.author, - body: comment.body, - createdAt: comment.createdAt, - editedAt: comment.editedAt ?? null, - url: comment.url, - path: thread.path, - reviewState: null, - reactions: comment.reactions ?? [], - })), - ); -} - -/** One page of review threads. Following the cursors it hands back is the caller's job. */ -function toDismissalEntries( - nodes: - | ReadonlyArray<{ - readonly dismissalMessage?: string | null | undefined; - readonly review?: { readonly id?: string | null | undefined } | null | undefined; - }> - | undefined, -): Map { - const entries = new Map(); - for (const node of nodes ?? []) { - const reviewId = trimmed(node.review?.id); - const message = trimmed(node.dismissalMessage); - if (reviewId !== null && message !== null) entries.set(reviewId, message); - } - return entries; -} - -const RawReviewDismissalsSchema = Schema.Struct({ - data: Schema.Struct({ - repository: Schema.Struct({ - pullRequest: Schema.Struct({ - timelineItems: Schema.Struct({ - pageInfo: Schema.optional(RawPageInfoSchema), - nodes: Schema.Array( - Schema.Struct({ - dismissalMessage: Schema.optional(Schema.NullOr(Schema.String)), - review: Schema.optional( - Schema.NullOr(Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)) })), - ), - }), - ), - }), - }), - }), - }), -}); - -const decodeReviewDismissals = decodeJsonResult(RawReviewDismissalsSchema); - -/** One further page of dismissal events, in the shape the thread read's own page carries. */ -export function decodeReviewDismissalsJson(raw: string): Result.Result< - { - readonly dismissalsByReviewId: ReadonlyMap; - readonly nextCursor: string | null; - }, - DecodeFailure -> { - const decoded = decodeReviewDismissals(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const items = decoded.success.data.repository.pullRequest.timelineItems; - return Result.succeed({ - dismissalsByReviewId: toDismissalEntries(items.nodes), - nextCursor: nextCursorOf(items.pageInfo), - }); -} - -export function decodeReviewThreadsJson( - raw: string, -): Result.Result { - const decoded = decodeReviewThreads(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const viewer = trimmed(decoded.success.data.viewer?.login); - const threads = decoded.success.data.repository.pullRequest.reviewThreads; - const entries = threads.nodes.flatMap((thread): ReadonlyArray => { - const path = trimmed(thread.path); - const id = trimmed(thread.id); - if (path === null || id === null || thread.comments.nodes.length === 0) return []; - return [ - { - thread: { - id, - path, - // Null once the thread's line has left the diff, which is exactly when GitHub reports - // it outdated. Such a thread is listed rather than pinned to a line it no longer has. - line: - thread.line !== null && thread.line !== undefined && thread.line > 0 - ? thread.line - : null, - side: thread.diffSide?.toUpperCase() === "LEFT" ? "left" : "right", - isResolved: thread.isResolved === true, - isOutdated: thread.isOutdated === true, - comments: thread.comments.nodes.map((comment) => ({ - id: comment.id, - author: toActor(comment.author), - body: comment.body ?? "", - createdAt: comment.createdAt, - editedAt: comment.lastEditedAt ?? null, - url: trimmed(comment.url), - reactions: toReactions(comment.reactionGroups, viewer), - })), - }, - commentCount: thread.comments.totalCount ?? thread.comments.nodes.length, - nextCommentCursor: nextCursorOf(thread.comments.pageInfo), - }, - ]; - }); - const pullRequest = decoded.success.data.repository.pullRequest; - const avatarsByLogin = new Map(); - const botLogins = new Set(); - for (const raw of [ - pullRequest.author, - ...(pullRequest.comments?.nodes ?? []).map((node) => node.author), - ...(pullRequest.reviews?.nodes ?? []).map((node) => node.author), - ...(pullRequest.reviewRequests?.nodes ?? []).map((node) => node.requestedReviewer), - ...(pullRequest.latestReviews?.nodes ?? []).map((node) => node.author), - ...threads.nodes.flatMap((thread) => thread.comments.nodes.map((comment) => comment.author)), - ]) { - const login = trimmed(raw?.login); - const avatarUrl = trimmed(raw?.avatarUrl); - if (login !== null && avatarUrl !== null) avatarsByLogin.set(login, avatarUrl); - if (login !== null && toActor(raw)?.isBot) botLogins.add(login); - } - const reviewers = new Map(); - for (const raw of [ - ...(pullRequest.reviewRequests?.nodes ?? []).map((node) => node.requestedReviewer), - ...(pullRequest.latestReviews?.nodes ?? []).map((node) => node.author), - ]) { - const actor = toActor(raw); - // Keyed by login, so someone who was asked and then answered appears once. - if (actor !== null && !reviewers.has(actor.login)) reviewers.set(actor.login, actor); - } - const commitStats = new Map(); - const commits: PullRequestCommit[] = []; - for (const node of pullRequest.commits?.nodes ?? []) { - const commit = node.commit; - const oid = trimmed(commit.oid); - if (oid === null) continue; - // GitHub measures a merge commit against its first parent, so merging the base into the head - // reports every upstream change as if it belonged to the pull request. There is no useful - // per-commit stat to show for that integration commit without another comparison request. - if ( - (commit.parents?.totalCount ?? 1) <= 1 && - commit.additions !== undefined && - commit.deletions !== undefined - ) { - commitStats.set(oid, { - additions: Math.max(0, commit.additions), - deletions: Math.max(0, commit.deletions), - }); - } - const committedDate = trimmed(commit.committedDate); - if (committedDate === null) continue; - commits.push({ - oid, - messageHeadline: commit.messageHeadline ?? "", - committedDate, - authors: (commit.authors?.nodes ?? []).flatMap((author) => { - const actor = toGraphqlCommitActor(author); - return actor === null ? [] : [actor]; - }), - }); - } - const reactionsById = new Map>(); - const editedAtById = new Map(); - for (const node of [ - ...(pullRequest.comments?.nodes ?? []), - ...(pullRequest.reviews?.nodes ?? []), - ]) { - const id = trimmed(node.id); - if (id === null) continue; - const editedAt = trimmed(node.lastEditedAt); - if (editedAt !== null) editedAtById.set(id, editedAt); - const reactions = toReactions(node.reactionGroups, viewer); - if (reactions.length > 0) reactionsById.set(id, reactions); - } - return Result.succeed({ - threads: entries, - nextCursor: nextCursorOf(threads.pageInfo), - reactions: toReactions(pullRequest.reactionGroups, viewer), - reactionsById, - editedAtById, - reviewers: [...reviewers.values()], - avatarsByLogin, - botLogins, - commitStats, - commits, - viewer: toPullRequestViewerFields(pullRequest), - dismissalsByReviewId: toDismissalEntries(pullRequest.reviewDismissals?.nodes), - nextDismissalCursor: nextCursorOf(pullRequest.reviewDismissals?.pageInfo), - }); -} - -/** The rest of one thread's comments, in the shape the first page already delivered them. */ -export function decodeReviewThreadCommentsJson(raw: string): Result.Result< - { - readonly belongsToPullRequest: boolean; - readonly comments: ReadonlyArray; - readonly nextCursor: string | null; - }, - DecodeFailure -> { - const decoded = decodeReviewThreadComments(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const viewer = trimmed(decoded.success.data.viewer?.login); - const comments = decoded.success.data.node?.comments; - return Result.succeed({ - belongsToPullRequest: - decoded.success.data.repository?.pullRequest?.id !== undefined && - decoded.success.data.repository?.pullRequest?.id === - decoded.success.data.node?.pullRequest?.id, - comments: (comments?.nodes ?? []).map((comment) => ({ - id: comment.id, - author: toActor(comment.author), - body: comment.body ?? "", - createdAt: comment.createdAt, - editedAt: comment.lastEditedAt ?? null, - url: trimmed(comment.url), - reactions: toReactions(comment.reactionGroups, viewer), - })), - nextCursor: nextCursorOf(comments?.pageInfo), - }); -} - -/** Repository settings returned alongside the pull request viewer permissions. */ -export interface GitHubRepositoryAccess { - readonly mergeCapabilities: PullRequestMergeCapabilities; - readonly canWrite: boolean; -} - -/** - * Whether the viewer's role on the repository is one that can push, which is what merging needs. - * TRIAGE and READ are not: a triager moves issues about and neither of them lands a commit. - * - * An install that reports no permission at all does not count as write. This is the exception to - * "an unknown permission is granted": write is what merging and closing somebody else's change - * need, and offering those to a reader who cannot use them wastes the press and reads as the app - * being wrong. Everything softer — commenting, reviewing, resolving — keeps the granting default, - * because being unable to say something is the worse failure there. - */ -function toCanWrite(viewerPermission: string | null | undefined): boolean { - switch (viewerPermission?.trim().toUpperCase()) { - case "ADMIN": - case "MAINTAIN": - case "WRITE": - return true; - default: - return false; - } -} - -/** Triage is the least role GitHub lets label a pull request; it is not a write. */ -function toCanTriage(viewerPermission: string | null | undefined): boolean { - return viewerPermission?.trim().toUpperCase() === "TRIAGE" || toCanWrite(viewerPermission); -} - -/** - * Who a review may be asked of, and who it has already been asked of, in one read. - * - * `assignableUsers` is the list GitHub's own reviewer picker is built from — everyone with access - * to the repository — rather than `collaborators`, which the REST API refuses to anyone without - * push access and which would therefore be empty for exactly the reader most likely to be looking. - * - * Teams are asked for only where one has already been requested, so a request to a team can be - * taken back. The teams a repository could newly be sent to live on the owning organization and - * need `read:org`, which a repository-scoped token need not carry — and a query GitHub refuses - * fails whole, taking the people down with the teams. - */ -export interface GitHubBaseComparison { - /** Null where the host could not compare, which the page reads as "unknown". */ - readonly behindBy: number | null; - readonly viewerCanUpdate: boolean; -} - -export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - assignableUsers(first: ${GRAPHQL_PAGE_SIZE}) { - pageInfo { hasNextPage } - nodes { login name avatarUrl } - } - pullRequest(number: $number) { - author { login } - reviewRequests(first: ${GRAPHQL_PAGE_SIZE}) { - nodes { - requestedReviewer { - ... on User { login name avatarUrl } - ... on Team { slug name avatarUrl } - ... on Bot { login avatarUrl } - } - } - } - } - } -}`; - -/** A team answers with a slug where a user answers with a login, and nothing else differs. */ -const RawRequestedReviewerSchema = Schema.Struct({ - ...RawActorSchema.fields, - slug: Schema.optional(Schema.NullOr(Schema.String)), -}); - -const RawReviewerCandidatesSchema = Schema.Struct({ - data: Schema.Struct({ - repository: Schema.Struct({ - assignableUsers: Schema.Struct({ - pageInfo: Schema.optional(RawPageInfoSchema), - nodes: Schema.Array(Schema.NullOr(RawActorSchema)), - }), - /** Null for a number that names no pull request the viewer can see. */ - pullRequest: Schema.NullOr( - Schema.Struct({ - author: Schema.optional(Schema.NullOr(RawActorSchema)), - reviewRequests: Schema.optional( - Schema.NullOr( - Schema.Struct({ - nodes: Schema.Array( - Schema.Struct({ - requestedReviewer: Schema.optional(Schema.NullOr(RawRequestedReviewerSchema)), - }), - ), - }), - ), - ), - }), - ), - }), - }), -}); - -const decodeReviewerCandidates = decodeJsonResult(RawReviewerCandidatesSchema); - -/** - * The people this pull request may be sent to, with whoever is already on it marked. The author is - * dropped rather than shown as an unusable row: GitHub refuses a review request from the person - * who opened the pull request, so offering them is offering a failure. - * - * Whoever has been asked leads the list even where GitHub does not count them assignable — an - * outside collaborator, an app — because a request that cannot be seen cannot be taken back. - */ -export function decodeReviewerCandidatesJson( - raw: string, -): Result.Result { - const decoded = decodeReviewerCandidates(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const repository = decoded.success.data.repository; - const pullRequest = repository.pullRequest; - const author = trimmed(pullRequest?.author?.login); - const candidates = new Map(); - for (const node of pullRequest?.reviewRequests?.nodes ?? []) { - const raw = node.requestedReviewer; - const slug = trimmed(raw?.slug); - const id = slug ?? trimmed(raw?.login); - if (id === null) continue; - candidates.set(`${slug === null ? "user" : "team"} ${id}`, { - id, - kind: slug === null ? "user" : "team", - login: id, - name: trimmed(raw?.name), - avatarUrl: trimmed(raw?.avatarUrl), - isRequested: true, - }); - } - for (const node of repository.assignableUsers.nodes) { - const login = trimmed(node?.login); - if (login === null || login === author || candidates.has(`user ${login}`)) continue; - candidates.set(`user ${login}`, { - id: login, - kind: "user", - login, - name: trimmed(node?.name), - avatarUrl: trimmed(node?.avatarUrl), - isRequested: false, - }); - } - return Result.succeed({ - candidates: [...candidates.values()], - truncated: repository.assignableUsers.pageInfo?.hasNextPage === true, - }); -} - -/** - * The body of `POST`/`DELETE /repos/{owner}/{repo}/pulls/{number}/requested_reviewers`, which - * takes people and teams in two lists of its own. The same body serves both methods, because - * GitHub takes a request back from exactly whoever it was made of. - */ -const ReviewerRequestSchema = Schema.Struct({ - reviewers: Schema.Array(Schema.String), - team_reviewers: Schema.Array(Schema.String), -}); - -export function buildReviewerRequest( - reviewers: ReadonlyArray<{ readonly id: string; readonly kind: PullRequestReviewerKind }>, -): typeof ReviewerRequestSchema.Type { - return { - reviewers: reviewers.flatMap((reviewer) => (reviewer.kind === "user" ? [reviewer.id] : [])), - team_reviewers: reviewers.flatMap((reviewer) => - reviewer.kind === "team" ? [reviewer.id] : [], - ), - }; -} - -export const LABEL_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - labels(first: ${GRAPHQL_PAGE_SIZE}, orderBy: { field: NAME, direction: ASC }) { - pageInfo { hasNextPage } - nodes { name color description } - } - pullRequest(number: $number) { - labels(first: ${GRAPHQL_PAGE_SIZE}) { nodes { name } } - } - } -}`; - -const RawLabelCandidatesSchema = Schema.Struct({ - data: Schema.Struct({ - repository: Schema.Struct({ - labels: Schema.optional( - Schema.NullOr( - Schema.Struct({ - pageInfo: Schema.optional(RawPageInfoSchema), - nodes: Schema.Array( - Schema.NullOr( - Schema.Struct({ - ...RawLabelSchema.fields, - description: Schema.optional(Schema.NullOr(Schema.String)), - }), - ), - ), - }), - ), - ), - /** Null for a number that names no pull request the viewer can see. */ - pullRequest: Schema.NullOr( - Schema.Struct({ - labels: Schema.optional( - Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLabelSchema)) })), - ), - }), - ), - }), - }), -}); - -const decodeLabelCandidates = decodeJsonResult(RawLabelCandidatesSchema); - -/** - * The repository's labels, with the ones already on this pull request marked. A label the pull - * request wears that the repository no longer defines — deleted since, or past the page — leads - * the list anyway, because a label that cannot be seen cannot be taken off. - */ -export function decodeLabelCandidatesJson( - raw: string, -): Result.Result { - const decoded = decodeLabelCandidates(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const repository = decoded.success.data.repository; - const applied = new Set( - (repository.pullRequest?.labels?.nodes ?? []).flatMap((label) => { - const name = trimmed(label?.name); - return name === null ? [] : [name]; - }), - ); - const candidates = new Map(); - for (const node of repository.labels?.nodes ?? []) { - const name = trimmed(node?.name); - if (name === null) continue; - candidates.set(name, { - name, - color: trimmed(node?.color), - description: trimmed(node?.description), - isApplied: applied.has(name), - }); - } - const missing = [...applied].filter((name) => !candidates.has(name)); - return Result.succeed({ - candidates: [ - ...missing.map((name) => ({ name, color: null, description: null, isApplied: true })), - ...candidates.values(), - ], - truncated: repository.labels?.pageInfo?.hasNextPage === true, - }); -} - -/** The body of `POST /repos/{owner}/{repo}/issues/{number}/labels`, which adds to what is there. */ -export function buildLabelRequest(labels: ReadonlyArray): { - readonly labels: ReadonlyArray; -} { - return { labels }; -} - -/** - * Everything GitHub says about what the signed-in account may do here. `canWrite` is about the - * repository, the other two about this pull request in particular — which is why an author with - * only read access can still be told apart from a passer-by. - */ -export interface GitHubViewerAccess { - readonly canWrite: boolean; - /** - * The viewer's role reaches triage, which is the least that may label. Everyone who can write - * can triage; a triager is the one role that can label without being able to merge. - */ - readonly canTriage: boolean; - /** GitHub's own `viewerCanUpdate`, true for the author as well as for anyone with write. */ - readonly canUpdate: boolean; - readonly didAuthor: boolean; - /** - * GitHub's own `viewerCanUpdateBranch`, read with the base comparison rather than here: it is - * false for a branch that is already current, so it answers "may update, and there is - * something to update" at once. Absent where the comparison was not read. - */ - readonly canUpdateBranch?: boolean; -} - -/** Core detail and write checks share one read of permissions and merge settings. */ -export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission - pullRequest(number: $number) { viewerCanUpdate viewerDidAuthor } - } -}`; - -const RawViewerPermissionsSchema = Schema.Struct({ - data: Schema.Struct({ - repository: Schema.Struct({ - ...RawRepositoryAccessSchema.fields, - /** Null for a number that names no pull request the viewer can see. */ - pullRequest: Schema.NullOr(RawViewerFieldsSchema), - }), - }), -}); - -const decodeViewerPermissions = decodeJsonResult(RawViewerPermissionsSchema); - -export function decodeViewerPermissionsJson( - raw: string, -): Result.Result { - const decoded = decodeViewerPermissions(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const repository = decoded.success.data.repository; - return Result.succeed({ - mergeCapabilities: { - merge: repository.mergeCommitAllowed, - squash: repository.squashMergeAllowed, - rebase: repository.rebaseMergeAllowed, - }, - canWrite: toCanWrite(repository.viewerPermission), - canTriage: toCanTriage(repository.viewerPermission), - ...toPullRequestViewerFields(repository.pullRequest), - }); -} - -export interface GitHubPullRequestFilesPatch { - readonly patch: string; - /** At least one file's hunks were withheld by GitHub, so they are missing from the patch. */ - readonly truncated: boolean; - /** Files GitHub returned, counted before decoding, so the caller can page. */ - readonly rawCount: number; - /** GitHub's own counts for the files whose hunks it withheld. */ - readonly omittedFileStats: ReadonlyArray; -} - -/** - * The files API returns hunks per file with no `diff --git` header, so the unified patch every - * diff viewer expects is assembled here. This decodes one page; walking pages is the caller's - * job, which is why the raw file count comes back with the patch. - */ -export function decodePullRequestFilesJson( - raw: string, -): Result.Result { - return Result.map(decodeUnknownList(raw), toFilesPatch); -} - -/** - * The files of one commit, which the commit endpoint lists and pages the same way, only wrapped - * in an object. An empty commit carries no `files` at all, which is a commit with nothing in it. - */ -export function decodeCommitFilesJson( - raw: string, -): Result.Result { - return Result.map(decodeCommitFiles(raw), (commit) => toFilesPatch(commit.files ?? [])); -} - -const decodeCommitFiles = decodeJsonResult( - Schema.Struct({ files: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))) }), -); - -function toFilesPatch(entries: ReadonlyArray): GitHubPullRequestFilesPatch { - const sections: string[] = []; - const omittedFileStats: PullRequestOmittedFileStat[] = []; - let truncated = false; - for (const entry of entries) { - const file = decodeFileEntry(entry); - if (Exit.isFailure(file)) continue; - const value = file.value; - const hunks = value.patch ?? ""; - const status = value.status?.trim().toLowerCase(); - if (hunks.length === 0) { - // A file with no hunks is still a file that changed: a pure rename has none to give, and - // a binary one has none that can be shown. Both are listed, and only the second is a hole - // in the patch — leaving them out entirely would drop them from the change altogether. - const additions = value.additions ?? 0; - const deletions = value.deletions ?? 0; - if (additions + deletions > 0) { - truncated = true; - omittedFileStats.push({ path: value.filename, additions, deletions }); - } - } - // A rename counts its hunks against the old path, which is the only place it is named. - const oldPath = - status === "renamed" ? value.previous_filename || value.filename : value.filename; - const header = [ - `diff --git ${quoteGitPatchPath(`a/${oldPath}`)} ${quoteGitPatchPath(`b/${value.filename}`)}`, - // The files API reports no file mode, so the ordinary one stands in: the viewer reads - // these lines as "added" and "removed" rather than for the mode they carry. - ...(status === "added" ? ["new file mode 100644"] : []), - ...(status === "removed" ? ["deleted file mode 100644"] : []), - ...(status === "renamed" - ? [ - `rename from ${quoteGitPatchPath(oldPath)}`, - `rename to ${quoteGitPatchPath(value.filename)}`, - ] - : []), - `--- ${status === "added" ? "/dev/null" : quoteGitPatchPath(`a/${oldPath}`)}`, - `+++ ${status === "removed" ? "/dev/null" : quoteGitPatchPath(`b/${value.filename}`)}`, - ].join("\n"); - sections.push(hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.replace(/\n?$/, "\n")}`); - } - return { - patch: sections.join(""), - truncated, - rawCount: entries.length, - omittedFileStats, - }; -} - -/** - * Which files of a pull request the signed-in account has cleared. GraphQL only, since the REST - * files endpoint the patch is read from carries no viewed state, so this is a second read rather - * than a wider version of the first. - */ -export const PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $after: String) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - files(first: 100, after: $after) { - pageInfo { hasNextPage endCursor } - nodes { path viewerViewedState } - } - } - } -}`; - -const RawPullRequestFilesViewedSchema = Schema.Struct({ - data: Schema.Struct({ - repository: Schema.NullOr( - Schema.Struct({ - pullRequest: Schema.NullOr( - Schema.Struct({ - files: Schema.Struct({ - pageInfo: Schema.Struct({ - hasNextPage: Schema.Boolean, - endCursor: Schema.NullOr(Schema.String), - }), - nodes: Schema.NullOr( - Schema.Array( - Schema.NullOr( - Schema.Struct({ - path: Schema.String, - // Decoded as a plain string and narrowed below: a GitHub release that adds - // a fourth state must not fail the whole page. - viewerViewedState: Schema.String, - }), - ), - ), - ), - }), - }), - ), - }), - ), - }), -}); - -const decodePullRequestFilesViewed = decodeJsonResult(RawPullRequestFilesViewedSchema); - -export interface GitHubPullRequestFilesViewedPage { - readonly files: ReadonlyArray<{ - readonly path: string; - readonly state: PullRequestFileViewedState; - }>; - /** Where the next page carries on, or null once the host has no more to give. */ - readonly nextCursor: string | null; -} - -/** Anything this host does not name is treated as unread, which is the state that asks for least. */ -function toFileViewedState(raw: string): PullRequestFileViewedState { - switch (raw.trim().toUpperCase()) { - case "VIEWED": - return "viewed"; - case "DISMISSED": - return "dismissed"; - default: - return "unviewed"; - } -} - -export function decodePullRequestFilesViewedJson( - raw: string, -): Result.Result { - const decoded = decodePullRequestFilesViewed(raw); - if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - const files = decoded.success.data.repository?.pullRequest?.files; - if (files === undefined) return Result.succeed({ files: [], nextCursor: null }); - return Result.succeed({ - files: (files.nodes ?? []).flatMap((node) => - node === null || node.path.length === 0 - ? [] - : [{ path: node.path, state: toFileViewedState(node.viewerViewedState) }], - ), - nextCursor: files.pageInfo.hasNextPage ? files.pageInfo.endCursor : null, - }); -} - -/** - * One document that clears and restores as many files as the reader ticked, rather than one - * request each. GitHub has no bulk form of `markFileAsViewed`/`unmarkFileAsViewed`, which each - * take a single path, so the batching is done with aliases; top-level mutation fields run in - * write order, so the last word about a path is the one that sticks. - */ -export function buildSetFilesViewedGraphQlMutation( - pullRequestId: string, - files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>, -): GraphQlDocument | null { - return aliasedGraphQlDocument({ - operation: "mutation", - alias: "f", - items: files, - shared: { pullRequestId: ["ID!", pullRequestId] }, - variables: (file) => ({ path: ["String!", file.path] }), - field: ({ path }, file) => - `${file.viewed ? "markFileAsViewed" : "unmarkFileAsViewed"}(input: { pullRequestId: $pullRequestId, path: ${path} }) { clientMutationId }`, - }); -} - -/** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ -const RawStackPullRequestSchema = Schema.Struct({ - title: Schema.optional(Schema.String), - draft: Schema.optional(Schema.Boolean), - number: Schema.Int, - head: Schema.Struct({ ref: Schema.String, sha: Schema.optional(Schema.String) }), - state: Schema.optional(Schema.NullOr(Schema.String)), - merged_at: Schema.optional(Schema.NullOr(Schema.String)), -}); - -/** - * A stack as `GET /repos/{owner}/{repo}/stacks` answers it, in a public preview whose shape may - * still move. Only what a stack is made of is required — where it lives, what it stands on, and - * its pull requests — and `base` is accepted both as the ref object the preview sends today and - * as the bare branch name it started out as. - */ -const RawStackSchema = Schema.Struct({ - id: Schema.optional(Schema.NullOr(Schema.Union([Schema.Int, Schema.String]))), - number: Schema.Int, - node_id: Schema.optional(Schema.NullOr(Schema.String)), - url: Schema.String, - html_url: Schema.optional(Schema.NullOr(Schema.String)), - base: Schema.Union([Schema.String, Schema.Struct({ ref: Schema.String })]), - pull_requests: Schema.Array(RawStackPullRequestSchema), -}); - -const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema)); - -export interface GitHubPullRequestStackLayer { - readonly title?: string; - readonly isDraft?: boolean; - readonly headSha?: string; - readonly number: number; - readonly headBranch: string; - readonly state: PullRequestState; -} - -export interface GitHubPullRequestStack { - readonly id: string; - readonly number: number; - readonly url: string; - readonly base: string; - /** Bottom to top, which is the order GitHub lists them in. */ - readonly layers: ReadonlyArray; -} - -/** - * The first stack of a `?pull_request=` listing, or null for an empty one: a pull request is in - * at most one stack, so the array is GitHub's way of saying "none" rather than a page. - */ -export function decodePullRequestStacksJson( - raw: string, -): Result.Result { - const decoded = decodeStacks(raw); - if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - const stack = decoded.success[0]; - if (stack === undefined) return Result.succeed(null); - return Result.succeed({ - id: stack.id == null ? (trimmed(stack.node_id) ?? String(stack.number)) : String(stack.id), - number: stack.number, - // The page a person opens where the preview reports one; the API URL is what it always has. - url: trimmed(stack.html_url) ?? stack.url, - base: typeof stack.base === "string" ? stack.base : stack.base.ref, - layers: stack.pull_requests.map((pullRequest) => ({ - ...(pullRequest.title === undefined ? {} : { title: pullRequest.title }), - ...(pullRequest.draft === undefined ? {} : { isDraft: pullRequest.draft }), - ...(pullRequest.head.sha === undefined ? {} : { headSha: pullRequest.head.sha }), - number: pullRequest.number, - headBranch: pullRequest.head.ref, - state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }), - })), - }); -} diff --git a/apps/server/src/pullRequest/githubStackActions.test.ts b/apps/server/src/pullRequest/githubStackActions.test.ts deleted file mode 100644 index 7600b9dc9a26e84908c299215ed6b665a76667bf..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/githubStackActions.test.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import * as Layer from "effect/Layer"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; -import * as FileSystem from "effect/FileSystem"; -import * as Redacted from "effect/Redacted"; -import { ChildProcessSpawner } from "effect/process"; -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; - -/** - * One request as the fake saw it, flattened to words: the path or document, then each variable - * or body field as `name=value`, so an assertion reads as "this request carried that value". - */ -type Call = ReadonlyArray; -type Send = ( - call: Call, - kind: "graphql" | "rest", -) => Effect.Effect; - -/** Every git command the cascade ran, in order; none actually runs. */ -let gitCalls: Array> = []; -const fakeGit = Layer.mergeAll( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => - Effect.sync(() => { - gitCalls.push(input.args); - const stdout = - input.args[0] === "rev-parse" || input.args[0] === "merge-base" ? "new-sha\n" : ""; - return { - exitCode: ChildProcessSpawner.ExitCode(0), - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }; - }), - }), - FileSystem.layerNoop({ makeTempDirectoryScoped: () => Effect.succeed("/tmp/scratch") }), -); - -const runGitHubStackAction = (send: Send, input: Parameters[0]) => - runStackAction(input).pipe( - Effect.provide( - Layer.mergeAll( - fakeGit, - Layer.mock(GitHubApi.GitHubApi)({ - credential: () => Effect.succeed({ token: Redacted.make("token"), fingerprint: "fp" }), - graphql: (request) => { - // GitHub refuses a document that declares a variable it never uses. - const declared = [...request.query.matchAll(/\$(\w+)\s*:/g)].map((match) => match[1]!); - const unused = declared.filter( - (name) => !new RegExp(`\\$${name}(?!\\w)(?!\\s*:)`).test(request.query), - ); - if (unused.length > 0) { - return Effect.die(new Error(`Variables declared but not used: ${unused.join(", ")}`)); - } - return send(words(request.query, request.variables), "graphql"); - }, - rest: (request) => - send(words(request.path, request.body), "rest").pipe( - Effect.map((body) => ({ - status: 200, - headers: {}, - body, - truncated: false, - invalidUtf8: false, - })), - ), - }), - ), - ), - ); - -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); - -function words(head: string, fields: unknown): Call { - return [ - head, - ...Object.entries((fields ?? {}) as Record).map( - ([key, value]) => `${key}=${typeof value === "string" ? value : encodeJson(value)}`, - ), - ]; -} - -const isMutation = (call: Call) => call[0]!.startsWith("mutation"); - -const stack = [ - { - number: 50, - url: "https://api.github.com/repos/acme/web/stacks/50", - base: { ref: "main" }, - pull_requests: [ - { - number: 1, - title: "Base", - head: { ref: "base", sha: "aaa" }, - state: "closed", - merged_at: "2026-01-01T00:00:00Z", - }, - { - number: 2, - title: "Middle", - head: { ref: "middle", sha: "bbb" }, - state: "open", - draft: false, - }, - { number: 3, title: "Top", head: { ref: "top", sha: "ccc" }, state: "open", draft: false }, - ], - }, -]; -const input = { - cwd: "/repo", - repository: "acme/web", - host: "github.com", - number: 3, - stackNumber: 50, - expectedStackHeads: [ - { number: 2, headSha: "bbb" }, - { number: 3, headSha: "ccc" }, - ], - action: "merge" as const, -}; -const access = { - data: { - repository: { - pr2: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, - pr3: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, - }, - }, -}; - -const branch = (number: number, headRefOid: string, behindBy = 1, processed: string[] = []) => ({ - data: { - processed: processed.map((headRefOid) => ({ headRefOid })), - repository: { - pullRequest: { id: `PR_${number}`, headRefOid, baseRef: { compare: { behindBy } } }, - }, - }, -}); -const rebased = { - data: { updatePullRequestBranch: { pullRequest: { headRefOid: "rebased-sha" } } }, -}; -const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased]; - -function fake(responses: readonly unknown[]) { - const calls: Call[] = []; - const execute: Send = (call) => - Effect.sync(() => { - calls.push(call); - const value = responses[calls.length - 1]; - if (value === undefined) throw new Error("Unexpected GitHub request"); - return encodeJson(value); - }); - return { execute, calls }; -} - -it.effect("submits one atomic merge with the reviewed head and respects the merge queue", () => - Effect.gen(function* () { - const api = fake([stack, { status: "enqueued", details: {} }]); - yield* runGitHubStackAction(api.execute, { ...input, mergeMethod: "squash" }); - expect(api.calls).toHaveLength(2); - expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); - expect(api.calls[1]).toContain("sha=ccc"); - expect(api.calls[1]).toContain("merge_action=default"); - expect(api.calls[1]).toContain("merge_method=squash"); - }), -); - -it.effect("merges through the selected layer without including later draft layers", () => - Effect.gen(function* () { - const fiveLayers = [ - { - ...stack[0], - pull_requests: Array.from({ length: 5 }, (_, index) => ({ - number: index + 1, - head: { ref: `layer-${index + 1}`, sha: `sha-${index + 1}` }, - state: "open", - draft: index >= 3, - })), - }, - ]; - const api = fake([fiveLayers, { status: "merged", details: {} }]); - yield* runGitHubStackAction(api.execute, { - ...input, - number: 3, - expectedStackHeads: [1, 2, 3].map((number) => ({ number, headSha: `sha-${number}` })), - }); - expect(api.calls).toHaveLength(2); - expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); - expect(api.calls[1]).toContain("sha=sha-3"); - expect(api.calls[1]).toContain("merge_action=default"); - }), -); - -it.effect("rejects stale reviewed heads below a selected middle layer", () => - Effect.gen(function* () { - const api = fake([stack]); - const result = yield* runGitHubStackAction(api.execute, { - ...input, - number: 2, - expectedStackHeads: [{ number: 2, headSha: "old-head" }], - }).pipe(Effect.result); - expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); - expect(api.calls).toHaveLength(1); - }), -); - -it.effect("does not merge from an already merged layer", () => - Effect.gen(function* () { - const api = fake([stack]); - const result = yield* runGitHubStackAction(api.execute, { - ...input, - number: 1, - expectedStackHeads: [], - }).pipe(Effect.result); - expect(result).toMatchObject({ - _tag: "Failure", - failure: { _tag: "GitHubStackUnsupportedError" }, - }); - expect(api.calls).toHaveLength(1); - }), -); - -it.effect("polls an accepted merge and reports a later rule rejection", () => - Effect.gen(function* () { - const api = fake([ - stack, - { status: "pending", details: { uuid: "operation" } }, - { status: "failed", details: { message: "Required checks have not passed" } }, - ]); - const fiber = yield* runGitHubStackAction(api.execute, input).pipe( - Effect.result, - Effect.forkChild, - ); - yield* TestClock.adjust("1 second"); - const result = yield* Fiber.join(fiber); - expect(result).toMatchObject({ - _tag: "Failure", - failure: { _tag: "GitHubStackMergeRejectedError" }, - }); - expect(api.calls[2]).toContain("repos/acme/web/pulls/3/merge-async/operation"); - }), -); - -it.effect("retains stack identity and a rejection response without a message", () => - Effect.gen(function* () { - const rejection = { status: "failed", details: {} }; - const api = fake([stack, rejection]); - const result = yield* runGitHubStackAction(api.execute, input).pipe(Effect.result); - expect(result).toMatchObject({ - _tag: "Failure", - failure: { - _tag: "GitHubStackMergeRejectedError", - repository: input.repository, - number: input.number, - stackNumber: input.stackNumber, - cause: rejection, - }, - }); - }), -); - -it.effect("refuses a changed stack before performing any mutation", () => - Effect.gen(function* () { - const api = fake([stack]); - const result = yield* runGitHubStackAction(api.execute, { - ...input, - expectedStackHeads: [ - { number: 2, headSha: "old" }, - { number: 3, headSha: "ccc" }, - ], - }).pipe(Effect.result); - expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); - expect(api.calls).toHaveLength(1); - }), -); - -it.effect("refuses the entire rebase before mutation when a later fork denies write access", () => - Effect.gen(function* () { - const api = fake([ - stack, - { - data: { - repository: { - ...access.data.repository, - pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false }, - }, - }, - }, - ]); - const result = yield* runGitHubStackAction(api.execute, { - ...input, - action: "update-branch", - }).pipe(Effect.result); - expect(result).toMatchObject({ - _tag: "Failure", - failure: { _tag: "GitHubStackPermissionError" }, - }); - expect(api.calls).toHaveLength(2); - }), -); - -it.effect("allows a fork that explicitly permits maintainer updates", () => - Effect.gen(function* () { - const api = fake([ - stack, - { - data: { - repository: { - ...access.data.repository, - pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: true }, - }, - }, - }, - ...rebaseResponses, - ]); - yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); - expect(gitCalls.filter((args) => args[0] === "push")).toHaveLength(2); - }), -); - -it.effect("bounds polling and reports a still-running merge without claiming success", () => - Effect.gen(function* () { - const api = fake([ - stack, - ...Array.from({ length: 40 }, () => ({ status: "pending", details: { uuid: "operation" } })), - ]); - const fiber = yield* runGitHubStackAction(api.execute, input).pipe( - Effect.result, - Effect.forkChild, - ); - yield* TestClock.adjust("6 minutes"); - expect(yield* Fiber.join(fiber)).toMatchObject({ - _tag: "Failure", - failure: { _tag: "GitHubStackMergePendingError" }, - }); - expect(api.calls.length).toBeLessThan(40); - }), -); - -it.effect("rebases the open layers bottom to top in a scratch clone, each onto the one below", () => - Effect.gen(function* () { - gitCalls = []; - const api = fake([stack, access]); - yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); - // GitHub is only read; every change is a git push with a lease on the reviewed head. - expect(api.calls.some(isMutation)).toBe(false); - const rebases = gitCalls.filter((args) => args[0] === "rebase"); - expect(rebases).toEqual([ - ["rebase", "--quiet", "--onto", "origin/main", "new-sha"], - ["rebase", "--quiet", "--onto", "new-sha", "bbb"], - ]); - expect(gitCalls.filter((args) => args[0] === "push")).toEqual([ - [ - "push", - "--quiet", - "--force-with-lease=refs/heads/middle:bbb", - "origin", - "new-sha:refs/heads/middle", - ], - [ - "push", - "--quiet", - "--force-with-lease=refs/heads/top:ccc", - "origin", - "new-sha:refs/heads/top", - ], - ]); - }), -); - -it.effect("checks every layer's write access before any git runs", () => - Effect.gen(function* () { - gitCalls = []; - const api = fake([ - stack, - { - data: { - repository: { - ...access.data.repository, - pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false }, - }, - }, - }, - ]); - yield* Effect.flip(runGitHubStackAction(api.execute, { ...input, action: "update-branch" })); - expect(gitCalls).toEqual([]); - }), -); diff --git a/apps/server/src/pullRequest/githubStackActions.ts b/apps/server/src/pullRequest/githubStackActions.ts deleted file mode 100644 index c2e8fadb751dc4dc3c63aa03f606c6ca33e4e8b9..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/githubStackActions.ts +++ /dev/null @@ -1,288 +0,0 @@ -import type { - PullRequestAction, - PullRequestMergeMethod, - PullRequestStackHead, -} from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Clock from "effect/Clock"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; - -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import { aliasedGraphQlDocument } from "../sourceControl/githubGraphQl.ts"; -import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; -import { cascadeRebaseStack } from "./githubStackRebase.ts"; - -const stackErrorIdentity = { - repository: Schema.String, - number: Schema.Int, - stackNumber: Schema.Int, -}; - -export class GitHubStackChangedError extends Schema.TaggedError()( - "GitHubStackChangedError", - { ...stackErrorIdentity, completed: Schema.Int }, -) { - override get message(): string { - return this.completed > 0 - ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.` - : "The stack changed. Refresh it before trying again."; - } -} - -export class GitHubStackUnsupportedError extends Schema.TaggedError()( - "GitHubStackUnsupportedError", - stackErrorIdentity, -) { - override get message(): string { - return "This operation is not supported for this stack."; - } -} - -export class GitHubStackResponseInvalidError extends Schema.TaggedError()( - "GitHubStackResponseInvalidError", - { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) }, -) { - override get message(): string { - return "GitHub returned an unreadable stack operation response."; - } -} - -export class GitHubStackMergeRejectedError extends Schema.TaggedError()( - "GitHubStackMergeRejectedError", - { ...stackErrorIdentity, cause: Schema.Defect() }, -) { - override get message(): string { - return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements."; - } -} - -export class GitHubStackMergePendingError extends Schema.TaggedError()( - "GitHubStackMergePendingError", - stackErrorIdentity, -) { - override get message(): string { - return "The merge is still running on GitHub. Check its status there before submitting another request."; - } -} - -export class GitHubStackPermissionError extends Schema.TaggedError()( - "GitHubStackPermissionError", - stackErrorIdentity, -) { - override get message(): string { - return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying."; - } -} - -export class GitHubStackRebaseFailedError extends Schema.TaggedError()( - "GitHubStackRebaseFailedError", - { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() }, -) { - override get message(): string { - // A conflict or a refused push already says which layer and what to do about it. - return this.cause instanceof Error && this.cause.message !== "" - ? this.cause.message - : `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`; - } -} - -export type GitHubStackActionError = - | GitHubStackChangedError - | GitHubStackUnsupportedError - | GitHubStackResponseInvalidError - | GitHubStackMergeRejectedError - | GitHubStackMergePendingError - | GitHubStackPermissionError - | GitHubStackRebaseFailedError; - -const MergeResponse = Schema.Struct({ - status: Schema.Literals(["pending", "merged", "enqueued", "failed"]), - details: Schema.Struct({ - uuid: Schema.optional(Schema.String), - message: Schema.optional(Schema.String), - }), -}); - -const decodeBranchAccess = Schema.decodeEffect( - Schema.fromJsonString( - Schema.Struct({ - data: Schema.Struct({ - repository: Schema.NullOr( - Schema.Record( - Schema.String, - Schema.NullOr( - Schema.Struct({ - headRepository: Schema.NullOr( - Schema.Struct({ viewerPermission: Schema.NullOr(Schema.String) }), - ), - maintainerCanModify: Schema.Boolean, - }), - ), - ), - ), - }), - }), - ), -); - -const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse)); - -/** Remote-only updates: a stack rebase works in a scratch clone, never the environment's checkout. */ -export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* (input: { - cwd: string; - repository: string; - host: string; - number: number; - stackNumber: number; - expectedStackHeads?: ReadonlyArray; - action: PullRequestAction; - mergeMethod?: PullRequestMergeMethod; -}) { - const api = yield* GitHubApi.GitHubApi; - const identity = { - repository: input.repository, - number: input.number, - stackNumber: input.stackNumber, - }; - if (input.action !== "merge" && input.action !== "update-branch") - return yield* new GitHubStackUnsupportedError({ ...identity }); - const endpoint = `repos/${input.repository}`; - const read = yield* api.rest({ - host: input.host, - operation: "runGitHubStackAction", - path: `${endpoint}/stacks?pull_request=${input.number}`, - }); - const decoded = decodePullRequestStacksJson(read.body); - if (Result.isFailure(decoded)) - return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure }); - const stack = decoded.success; - const targetIndex = stack?.layers.findIndex((layer) => layer.number === input.number) ?? -1; - const target = stack?.layers[targetIndex]; - if ( - stack?.number !== input.stackNumber || - target === undefined || - (input.action === "update-branch" && targetIndex !== stack.layers.length - 1) - ) { - return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); - } - const affectedLayers = - input.action === "merge" ? stack.layers.slice(0, targetIndex + 1) : stack.layers; - const open = affectedLayers.filter((layer) => layer.state !== "merged"); - if (input.action === "merge" && target.state !== "open") - return yield* new GitHubStackUnsupportedError({ ...identity }); - if ( - !input.expectedStackHeads || - input.expectedStackHeads.length !== open.length || - new Set(input.expectedStackHeads.map((layer) => layer.number)).size !== open.length || - open.some( - (layer) => - !layer.headSha || - !input.expectedStackHeads?.some( - (expected) => expected.number === layer.number && expected.headSha === layer.headSha, - ), - ) - ) { - return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); - } - if (open.length === 0 || open.some((layer) => layer.state !== "open")) - return yield* new GitHubStackUnsupportedError({ ...identity }); - if (input.action === "update-branch") { - const [owner, name] = input.repository.split("/"); - const permissions = yield* api.graphql({ - host: input.host, - operation: "runGitHubStackAction", - allowReserve: true, - // `open` is non-empty here, so there is always a document. - ...aliasedGraphQlDocument({ - operation: "query", - alias: "pr", - key: (layer) => layer.number, - items: open, - shared: { owner: ["String!", owner], name: ["String!", name] }, - variables: (layer) => ({ number: ["Int!", layer.number] }), - field: ({ number }) => - `pullRequest(number: ${number}) { headRepository { viewerPermission } maintainerCanModify }`, - within: (fields) => `repository(owner: $owner, name: $name) {\n${fields}\n}`, - })!, - }); - const access = yield* decodeBranchAccess(permissions).pipe( - Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), - ); - // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent - // will make it stale. Check branch write access separately before touching any layer. - if ( - open.some((layer) => { - const pr = access.data.repository?.[`pr${layer.number}`]; - return ( - !pr?.headRepository || - (!pr.maintainerCanModify && - !["ADMIN", "MAINTAIN", "WRITE"].includes(pr.headRepository.viewerPermission ?? "")) - ); - }) - ) - return yield* new GitHubStackPermissionError({ ...identity }); - // GitHub's own "Rebase stack" has no API, and its per-PR "update branch" replays the old - // copy of every lower layer into the one above it. The cascade moves each layer's own commits. - yield* cascadeRebaseStack({ - host: input.host, - repository: input.repository, - base: stack.base, - layers: open.map((layer) => ({ - number: layer.number, - headBranch: layer.headBranch, - headSha: layer.headSha!, - })), - }).pipe( - Effect.mapError((cause) => - cause._tag === "GitHubStackRebaseConflictError" || - cause._tag === "GitHubStackRebaseGitError" - ? new GitHubStackRebaseFailedError({ - ...identity, - number: cause.number, - completed: cause.completed, - cause, - }) - : cause, - ), - ); - return; - } - if (open.some((layer) => layer.isDraft)) - return yield* new GitHubStackUnsupportedError({ ...identity }); - const decode = (raw: string) => - decodeMergeResponse(raw).pipe( - Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), - ); - const request = yield* api.rest({ - host: input.host, - operation: "runGitHubStackAction", - method: "PUT", - path: `${endpoint}/pulls/${input.number}/merge-async`, - body: { - merge_method: input.mergeMethod ?? "merge", - merge_action: "default", - sha: target.headSha, - }, - }); - let result = yield* decode(request.body); - const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000; - for ( - let attempt = 0; - result.status === "pending" && (yield* Clock.currentTimeMillis) < deadline; - attempt++ - ) { - const uuid = result.details.uuid; - if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity }); - yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000)); - const poll = yield* api.rest({ - host: input.host, - operation: "runGitHubStackAction", - path: `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`, - }); - result = yield* decode(poll.body); - } - if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity }); - if (result.status === "failed") - return yield* new GitHubStackMergeRejectedError({ ...identity, cause: result }); -}); diff --git a/apps/server/src/pullRequest/githubStackRebase.test.ts b/apps/server/src/pullRequest/githubStackRebase.test.ts deleted file mode 100644 index 26b801d13bd8f6cab9b0153299418b2321a9f399..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/githubStackRebase.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Redacted from "effect/Redacted"; - -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { cascadeRebaseStack } from "./githubStackRebase.ts"; - -const layer = Layer.mergeAll( - Layer.mock(GitHubApi.GitHubApi)({ - credential: () => Effect.succeed({ token: Redacted.make("token"), fingerprint: "fp" }), - }), - VcsProcess.layer, -).pipe(Layer.provideMerge(NodeServices.layer)); - -/** - * A bare "GitHub" holding the live-run stack: `main` moved ahead after the stack was cut, and a - * second layer sits on the first. Returns each branch's head. - */ -const setup = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const process = yield* VcsProcess.VcsProcess; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cascade-test-" }); - const remote = `${root}/remote.git`; - const work = `${root}/work`; - const git = (cwd: string, ...args: string[]) => - process - .run({ - operation: "test", - command: "git", - args, - cwd, - env: { - GIT_AUTHOR_NAME: "t", - GIT_AUTHOR_EMAIL: "t@t", - GIT_COMMITTER_NAME: "t", - GIT_COMMITTER_EMAIL: "t@t", - }, - }) - .pipe(Effect.map((out) => out.stdout.trim())); - const write = (path: string, text: string) => fs.writeFileString(`${work}/${path}`, text); - yield* git(root, "init", "--quiet", "--bare", "-b", "main", remote); - yield* git(root, "clone", "--quiet", remote, work); - yield* write("index.ts", "export const answer = 41;\n"); - yield* git(work, "add", "-A"); - yield* git(work, "commit", "--quiet", "-m", "Initial"); - yield* git(work, "switch", "--quiet", "-c", "feat/answer-42"); - yield* write("index.ts", "export const answer = 42;\n"); - yield* git(work, "commit", "--quiet", "-am", "A: set the answer to 42"); - yield* git(work, "switch", "--quiet", "-c", "feat/answer-doc"); - yield* write("index.ts", "/** The answer. */\nexport const answer = 42;\n"); - yield* git(work, "commit", "--quiet", "-am", "B: document the answer"); - yield* git(work, "switch", "--quiet", "main"); - yield* write("README.md", "main moved ahead\n"); - yield* git(work, "add", "-A"); - yield* git(work, "commit", "--quiet", "-m", "Docs"); - yield* git(work, "push", "--quiet", "origin", "main", "feat/answer-42", "feat/answer-doc"); - const head = (branch: string) => git(remote, "rev-parse", `refs/heads/${branch}`); - return { - remote, - head, - git: (...args: string[]) => git(remote, ...args), - layers: [ - { number: 1, headBranch: "feat/answer-42", headSha: yield* head("feat/answer-42") }, - { number: 2, headBranch: "feat/answer-doc", headSha: yield* head("feat/answer-doc") }, - ], - }; -}); - -it.layer(layer)("cascadeRebaseStack", (it) => { - it.effect("moves each layer's own commits onto the rebased layer below it", () => - Effect.gen(function* () { - const repo = yield* setup; - const completed = yield* cascadeRebaseStack({ - host: "github.com", - repository: "acme/web", - base: "main", - layers: repo.layers, - remote: repo.remote, - }); - assert.strictEqual(completed, 2); - - const main = yield* repo.head("main"); - const bottom = yield* repo.head("feat/answer-42"); - const top = yield* repo.head("feat/answer-doc"); - // Bottom sits on the new main; top sits on the new bottom with only its own commit. - expect(yield* repo.git("rev-parse", `${bottom}~1`)).toBe(main); - expect(yield* repo.git("rev-parse", `${top}~1`)).toBe(bottom); - expect(yield* repo.git("log", "--format=%s", `${bottom}..${top}`)).toBe( - "B: document the answer", - ); - }).pipe(Effect.scoped), - ); - - it.effect("refuses to overwrite a layer pushed after it was reviewed", () => - Effect.gen(function* () { - const repo = yield* setup; - const stale = [ - repo.layers[0]!, - // Reviewed at the bottom layer's head, but the branch is really at its own commit. - { ...repo.layers[1]!, headSha: repo.layers[0]!.headSha }, - ]; - const error = yield* Effect.flip( - cascadeRebaseStack({ - host: "github.com", - repository: "acme/web", - base: "main", - layers: stale, - remote: repo.remote, - }), - ); - expect(error).toMatchObject({ _tag: "GitHubStackRebaseGitError", number: 2, completed: 1 }); - expect(yield* repo.head("feat/answer-doc")).toBe(repo.layers[1]!.headSha); - }).pipe(Effect.scoped), - ); - - it.effect("stops at a conflicting layer and leaves it untouched", () => - Effect.gen(function* () { - const repo = yield* setup; - // Make main conflict with the bottom layer's change to the same line. - const fs = yield* FileSystem.FileSystem; - const process = yield* VcsProcess.VcsProcess; - const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cascade-conflict-" }); - const run = (...args: string[]) => - process.run({ - operation: "test", - command: "git", - args, - cwd: scratch, - env: { - GIT_AUTHOR_NAME: "t", - GIT_AUTHOR_EMAIL: "t@t", - GIT_COMMITTER_NAME: "t", - GIT_COMMITTER_EMAIL: "t@t", - }, - }); - yield* run("clone", "--quiet", repo.remote, "."); - yield* fs.writeFileString(`${scratch}/index.ts`, "export const answer = 43;\n"); - yield* run("commit", "--quiet", "-am", "Conflict"); - yield* run("push", "--quiet", "origin", "main"); - - const error = yield* Effect.flip( - cascadeRebaseStack({ - host: "github.com", - repository: "acme/web", - base: "main", - layers: repo.layers, - remote: repo.remote, - }), - ); - expect(error).toMatchObject({ - _tag: "GitHubStackRebaseConflictError", - number: 1, - completed: 0, - }); - expect(yield* repo.head("feat/answer-42")).toBe(repo.layers[0]!.headSha); - }).pipe(Effect.scoped), - ); -}); diff --git a/apps/server/src/pullRequest/githubStackRebase.ts b/apps/server/src/pullRequest/githubStackRebase.ts deleted file mode 100644 index 2cd2b5f88bb7ebe4cfa09f5e8702e42b3ae84c32..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/githubStackRebase.ts +++ /dev/null @@ -1,155 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Redacted from "effect/Redacted"; -import * as Schema from "effect/Schema"; -import * as Base64 from "effect/encoding/Base64"; - -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; - -/** One open layer of a stack, bottom to top, with the head the reader reviewed. */ -export interface CascadeLayer { - readonly number: number; - readonly headBranch: string; - readonly headSha: string; -} - -/** A layer's rebase hit a conflict; the layers below it are already rebased on GitHub. */ -export class GitHubStackRebaseConflictError extends Schema.TaggedError()( - "GitHubStackRebaseConflictError", - { number: Schema.Int, completed: Schema.Int }, -) { - override get message(): string { - return `PR #${this.number} conflicts with the layer below it. ${this.completed} layers were rebased; resolve #${this.number} with \`gh stack rebase\` and push.`; - } -} - -/** Git failed for a reason other than a conflict: the fetch, or a push GitHub refused. */ -export class GitHubStackRebaseGitError extends Schema.TaggedError()( - "GitHubStackRebaseGitError", - { step: Schema.String, number: Schema.Int, completed: Schema.Int, cause: Schema.Defect() }, -) { - override get message(): string { - return `Stack rebase stopped at PR #${this.number} while ${this.step}. ${this.completed} layers were rebased.`; - } -} - -/** The git URL of a repository on a GitHub host, which is also where its token is good. */ -function remoteUrl(host: string, repository: string): string { - return `https://${host.trim().toLowerCase()}/${repository}.git`; -} - -/** - * GitHub's own "Rebase stack": every open layer, bottom to top, onto the new head of the layer - * below it (the bottom one onto the stack's base). Each layer moves only its own commits — - * `git rebase --onto ` — so a rebased lower layer is never replayed into - * the one above it, which is what GitHub's per-PR "update branch" does and why it conflicts. - * - * It works in a throwaway clone, so the environment's checkout never moves, and pushes each - * layer with a lease on the head that was reviewed, so a push that landed meanwhile is refused - * rather than overwritten. The stacks API has no rebase endpoint to call instead. - */ -export const cascadeRebaseStack = Effect.fn("cascadeRebaseStack")(function* (input: { - readonly host: string; - readonly repository: string; - readonly base: string; - readonly layers: ReadonlyArray; - /** Where to fetch and push; the repository's own URL on its host unless a test points elsewhere. */ - readonly remote?: string; -}) { - const api = yield* GitHubApi.GitHubApi; - const process = yield* VcsProcess.VcsProcess; - const fileSystem = yield* FileSystem.FileSystem; - const { token } = yield* api.credential(input.host); - const first = input.layers[0]; - if (first === undefined) return 0; - const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-stack-rebase-" }).pipe( - Effect.mapError( - (cause) => - new GitHubStackRebaseGitError({ - step: "preparing", - number: first.number, - completed: 0, - cause, - }), - ), - ); - // The token rides in a header for this process only: never in the URL, argv, or git config. - const remote = input.remote ?? remoteUrl(input.host, input.repository); - const authorization = `AUTHORIZATION: basic ${Base64.encode(`x-access-token:${Redacted.value(token)}`)}`; - const env = { - GIT_TERMINAL_PROMPT: "0", - GIT_CONFIG_COUNT: "3", - GIT_CONFIG_KEY_0: `http.${remote}.extraheader`, - GIT_CONFIG_VALUE_0: authorization, - GIT_CONFIG_KEY_1: "user.name", - GIT_CONFIG_VALUE_1: "T3 Code", - GIT_CONFIG_KEY_2: "user.email", - GIT_CONFIG_VALUE_2: "noreply@t3.codes", - }; - const git = (args: ReadonlyArray, allowNonZeroExit = false) => - process.run({ - operation: "cascadeRebaseStack", - command: "git", - args, - cwd: directory, - env, - timeoutMs: 120_000, - ...(allowNonZeroExit ? { allowNonZeroExit: true } : {}), - }); - const failed = (step: string, number: number, completed: number) => (cause: unknown) => - new GitHubStackRebaseGitError({ step, number, completed, cause }); - - yield* git(["init", "--quiet"]).pipe(Effect.mapError(failed("preparing", first.number, 0))); - yield* git(["remote", "add", "origin", remote]).pipe( - Effect.mapError(failed("preparing", first.number, 0)), - ); - yield* git([ - "fetch", - "--quiet", - "--no-tags", - "origin", - `+refs/heads/${input.base}:refs/remotes/origin/${input.base}`, - ...input.layers.map( - (layer) => `+refs/heads/${layer.headBranch}:refs/remotes/origin/${layer.headBranch}`, - ), - ]).pipe(Effect.mapError(failed("fetching", first.number, 0))); - - let parentOld = `origin/${input.base}`; - let parentNew = `origin/${input.base}`; - for (const [index, layer] of input.layers.entries()) { - // The bottom layer's old parent is where it forked from the base; above that it is the - // reviewed head of the layer below, which is exactly the commits this layer must not replay. - const upstream = - index === 0 - ? (yield* git(["merge-base", parentOld, layer.headSha]).pipe( - Effect.mapError(failed("reading the fork point", layer.number, index)), - )).stdout.trim() - : parentOld; - yield* git(["checkout", "--quiet", "--detach", layer.headSha]).pipe( - Effect.mapError(failed("checking out", layer.number, index)), - ); - const rebase = yield* git(["rebase", "--quiet", "--onto", parentNew, upstream], true).pipe( - Effect.mapError(failed("rebasing", layer.number, index)), - ); - if (rebase.exitCode !== 0) { - yield* git(["rebase", "--abort"], true).pipe(Effect.ignore); - return yield* new GitHubStackRebaseConflictError({ number: layer.number, completed: index }); - } - const rebased = (yield* git(["rev-parse", "HEAD"]).pipe( - Effect.mapError(failed("rebasing", layer.number, index)), - )).stdout.trim(); - if (rebased !== layer.headSha) { - yield* git([ - "push", - "--quiet", - `--force-with-lease=refs/heads/${layer.headBranch}:${layer.headSha}`, - "origin", - `${rebased}:refs/heads/${layer.headBranch}`, - ]).pipe(Effect.mapError(failed("pushing", layer.number, index))); - } - parentOld = layer.headSha; - parentNew = rebased; - } - return input.layers.length; -}, Effect.scoped); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ff4c4ef6c2146d6cbbc5cd9ba3ff2391f1163018..a5e23ed65b568a53136adfa059b36bfec33c9be7 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -52,7 +52,6 @@ import * as ProviderHostLive from "./provider/ProviderHostLive.ts"; import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; -import * as GitHubApi from "./sourceControl/GitHubApi.ts"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; @@ -296,7 +295,7 @@ const layerPersistence = Layer.empty.pipe(Layer.provideMerge(SqlitePersistence.l const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)); const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.pipe( - Layer.provide(Layer.mergeAll(GitHubApi.layerWithDependencies, SourceControlBuiltInDrivers.layer)), + Layer.provideMerge(SourceControlBuiltInDrivers.layer), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(layerVcsDriverRegistry), ); @@ -585,9 +584,9 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(RuntimeLayer.layerEventInfrastructure), Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), Layer.provideMerge(layerServerSettings), - // The asset route uses the registry's GitHub credential for private PR media. + // The asset route uses the registry's GitHub credential for private PR media, which the + // built-in drivers' layer provides alongside the registry. Layer.provideMerge(layerSourceControlProviderRegistry), - Layer.provideMerge(GitHubApi.layerWithDependencies), Layer.provideMerge(layerGit), Layer.provideMerge(layerVcs), Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), diff --git a/apps/server/src/sourceControl/GitHubApi.test.ts b/apps/server/src/sourceControl/GitHubApi.test.ts deleted file mode 100644 index cd4c2fb384f230a861c4a953b20965f9df78e059..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/GitHubApi.test.ts +++ /dev/null @@ -1,525 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Redacted from "effect/Redacted"; -import * as PlatformError from "effect/PlatformError"; -import * as TestClock from "effect/testing/TestClock"; -import * as Tracer from "effect/Tracer"; -import { ChildProcessSpawner } from "effect/process"; -import { VcsProcessSpawnError, VcsProcessTimeoutError } from "@t3tools/contracts"; -import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http"; - -import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCredentials from "./GitHubCredentials.ts"; -import * as GitHubQuota from "./githubQuota.ts"; -import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as ServerSettings from "../serverSettings.ts"; - -const NOW = Date.parse("2026-10-05T12:00:00.000Z"); - -function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Response) { - const requests: Array = []; - let tokens = ["first", "second"]; - let invalidations = 0; - const credentials = Layer.succeed( - GitHubCredentials.GitHubCredentials, - GitHubCredentials.GitHubCredentials.of({ - get: (host) => - Effect.sync(() => ({ - host, - token: Redacted.make(tokens[0]!), - source: "gh" as const, - fingerprint: `${host}:${tokens[0]}`, - })), - invalidate: () => - Effect.sync(() => { - invalidations++; - tokens = tokens.slice(1); - }), - }), - ); - const http = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => { - requests.push(request); - return Effect.succeed(HttpClientResponse.fromWeb(request, respond(request))); - }), - ); - const layer = GitHubApi.layer.pipe( - Layer.provide(Layer.mergeAll(credentials, http)), - Layer.provideMerge(GitHubQuota.layer), - Layer.provideMerge(SourceControlRateLimit.layer), - ); - return { layer, requests, invalidations: () => invalidations }; -} - -const json = (body: unknown, init?: ResponseInit) => - new Response(JSON.stringify(body), { - ...init, - headers: { "content-type": "application/json", ...init?.headers }, - }); - -describe("gitHubApiUrls", () => { - it("maps github.com, GHE.com and GitHub Enterprise Server", () => { - expect(GitHubApi.gitHubApiUrls("GitHub.com")).toEqual({ - rest: "https://api.github.com", - graphql: "https://api.github.com/graphql", - }); - expect(GitHubApi.gitHubApiUrls("acme.ghe.com").graphql).toBe( - "https://api.acme.ghe.com/graphql", - ); - expect(GitHubApi.gitHubApiUrls("git.acme.internal")).toEqual({ - rest: "https://git.acme.internal/api/v3", - graphql: "https://git.acme.internal/api/graphql", - }); - }); -}); - -describe("environmentToken", () => { - it("follows gh's precedence per kind of host", () => { - const env = { GH_TOKEN: "gh", GITHUB_TOKEN: "github", GH_ENTERPRISE_TOKEN: "ghe" }; - expect(GitHubCredentials.environmentToken("github.com", env)).toBe("gh"); - expect(GitHubCredentials.environmentToken("acme.ghe.com", { GITHUB_TOKEN: "github" })).toBe( - "github", - ); - // An enterprise token only goes to the host GH_HOST names, never to whatever a remote says. - expect(GitHubCredentials.environmentToken("git.acme.internal", env)).toBeNull(); - expect( - GitHubCredentials.environmentToken("git.acme.internal", { - ...env, - GH_HOST: "git.acme.internal", - }), - ).toBe("ghe"); - expect(GitHubCredentials.environmentToken("git.acme.internal", { GH_TOKEN: "gh" })).toBeNull(); - }); -}); - -describe("GitHubApi", () => { - it.effect("sends GraphQL with the token, and the document as written", () => { - const { layer, requests } = harness(() => json({ data: { viewer: { login: "julius" } } })); - return Effect.gen(function* () { - yield* TestClock.setTime(NOW); - const api = yield* GitHubApi.GitHubApi; - const body = yield* api.graphql({ - host: "github.com", - operation: "viewer", - query: "query { viewer { login } }", - }); - expect(body).toContain('"login":"julius"'); - expect(requests[0]!.url).toBe("https://api.github.com/graphql"); - expect(requests[0]!.headers.authorization).toBe("Bearer first"); - // No `rateLimit` selection is added: the quota is read off the response headers. - const sent = requests[0]!.body; - expect(sent._tag === "Uint8Array" ? new TextDecoder().decode(sent.body) : "").toContain( - '"query":"query { viewer { login } }"', - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect( - "traces the operation, path, query and quota, never the query string or variables", - () => { - const spans: Array = []; - const tracer = Tracer.make({ - span: (options) => { - const span = new Tracer.NativeSpan(options); - spans.push(span); - return span; - }, - }); - const { layer } = harness((request) => - request.url.endsWith("/graphql") - ? json( - { data: { viewer: { login: "julius" } } }, - { headers: { "x-ratelimit-remaining": "4990", "x-ratelimit-resource": "graphql" } }, - ) - : json({ ok: true }, { headers: { "x-ratelimit-remaining": "4800" } }), - ); - return Effect.gen(function* () { - yield* TestClock.setTime(NOW); - const api = yield* GitHubApi.GitHubApi; - yield* api.graphql({ - host: "github.com", - operation: "getPullRequestDetail", - query: "query($body: String!) { viewer { login } }", - variables: { body: "secret user text" }, - }); - yield* api.rest({ - host: "github.com", - operation: "listWorkflowRuns", - path: "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fx", - }); - const byName = (name: string) => spans.filter((span) => span.name === name); - const graphql = Object.fromEntries(byName("GitHubApi.graphql")[0]!.attributes); - expect(graphql).toMatchObject({ - "github.operation": "getPullRequestDetail", - "github.graphql.query": "query($body: String!) { viewer { login } }", - }); - expect(Object.fromEntries(byName("GitHubApi.send")[0]!.attributes)).toMatchObject({ - "github.ratelimit.remaining": 4990, - "github.ratelimit.resource": "graphql", - }); - expect(String(graphql["github.graphql.query_hash"])).toMatch(/^[0-9a-f]{8}$/); - const rest = Object.fromEntries(byName("GitHubApi.send")[1]!.attributes); - expect(rest).toMatchObject({ - "github.operation": "listWorkflowRuns", - "github.kind": "rest", - "url.path": "/repos/acme/web/actions/runs", - "http.response.status_code": 200, - "github.ratelimit.remaining": 4800, - }); - // Nothing recorded carries the query string, the variables, or the token. - const everything = spans - .flatMap((span) => [...span.attributes].map(([key, value]) => `${key}=${String(value)}`)) - .join("\n"); - expect(everything).not.toContain("head_sha"); - expect(everything).not.toContain("secret user text"); - expect(everything).not.toContain("first"); - expect(spans.some((span) => span.attributes.has("url.full"))).toBe(false); - }).pipe(Effect.provide(layer), Effect.withTracer(tracer)); - }, - ); - - it.effect("fails a GraphQL answer that carries errors, naming GitHub's reason", () => { - const { layer } = harness(() => - json({ data: null, errors: [{ type: "FORBIDDEN", message: "Resource not accessible" }] }), - ); - return Effect.gen(function* () { - const api = yield* GitHubApi.GitHubApi; - const error = yield* Effect.flip( - api.graphql({ host: "github.com", operation: "detail", query: "query { viewer { id } }" }), - ); - expect(error._tag).toBe("GitHubApiResponseError"); - expect(error.message).toContain("Resource not accessible"); - }).pipe(Effect.provide(layer)); - }); - - it.effect( - "reads a NOT_FOUND answer with another error as a failure, not a missing resource", - () => { - const { layer } = harness(() => - json({ - data: null, - errors: [ - { type: "NOT_FOUND", message: "Could not resolve" }, - { message: "Something went wrong" }, - ], - }), - ); - return Effect.gen(function* () { - const api = yield* GitHubApi.GitHubApi; - const error = yield* Effect.flip( - api.graphql({ - host: "github.com", - operation: "detail", - query: "query { viewer { id } }", - }), - ); - expect(error._tag).toBe("GitHubApiResponseError"); - }).pipe(Effect.provide(layer)); - }, - ); - - it.effect("pauses the host after a GraphQL RATE_LIMITED answer until the reset", () => { - const reset = Math.floor(NOW / 1000) + 600; - const { layer, requests } = harness(() => - json( - { errors: [{ type: "RATE_LIMITED", message: "API rate limit exceeded" }] }, - { headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) } }, - ), - ); - return Effect.gen(function* () { - yield* TestClock.setTime(NOW); - const api = yield* GitHubApi.GitHubApi; - const first = yield* Effect.flip( - api.graphql({ host: "github.com", operation: "summary", query: "query { viewer { id } }" }), - ); - expect(first).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: reset * 1000 }); - const second = yield* Effect.flip( - api.rest({ host: "github.com", operation: "stack", path: "repos/acme/web/stacks" }), - ); - expect(second).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: reset * 1000, - }); - expect(requests).toHaveLength(1); - }).pipe(Effect.provide(layer)); - }); - - it.effect("keeps the last tenth of a quota for interactive requests until it resets", () => { - const reset = Math.floor(NOW / 1000) + 600; - const quota = (remaining: number) => ({ - "x-ratelimit-resource": "core", - "x-ratelimit-limit": "5000", - "x-ratelimit-remaining": String(remaining), - "x-ratelimit-reset": String(reset), - }); - let remaining = 499; - const { layer, requests } = harness((request) => - request.url.endsWith("/graphql") - ? json({ data: { viewer: { id: "1" } } }) - : json({ ok: true }, { headers: quota(remaining--) }), - ); - return Effect.gen(function* () { - yield* TestClock.setTime(NOW); - const api = yield* GitHubApi.GitHubApi; - const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" }; - // The first answer leaves 499 of 5000, under the last tenth; the next background read waits. - yield* api.rest(read); - const refused = yield* Effect.flip(api.rest(read)); - expect(refused).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: reset * 1000, - }); - // A user's own request may spend the reserve, and GraphQL has a quota of its own. - yield* api.rest(read).pipe(Effect.provideService(GitHubApi.AllowGitHubReserve, true)); - yield* api.graphql({ host: "github.com", operation: "x", query: "query { viewer { id } }" }); - expect(requests).toHaveLength(3); - // The reset gives the background its quota back. - yield* TestClock.setTime(reset * 1000); - yield* api.rest(read); - expect(requests).toHaveLength(4); - }).pipe(Effect.provide(layer)); - }); - - it.effect("carries GitHub's own reason for a refused REST request", () => { - const { layer } = harness(() => - json( - { - message: "Validation Failed", - errors: [ - { - resource: "PullRequest", - code: "custom", - message: "A pull request already exists for acme:feature.", - }, - ], - }, - { status: 422 }, - ), - ); - return Effect.gen(function* () { - const api = yield* GitHubApi.GitHubApi; - const error = yield* Effect.flip( - api.rest({ - host: "github.com", - operation: "createPullRequest", - method: "POST", - path: "repos/acme/web/pulls", - }), - ); - expect(error.message).toBe( - "GitHub returned an error: Validation Failed; A pull request already exists for acme:feature.", - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect("lets an interactive request through a pause a background read recorded", () => { - const reset = Math.floor(NOW / 1000) + 600; - let call = 0; - const { layer, requests } = harness(() => - ++call === 1 - ? json( - { message: "API rate limit exceeded" }, - { - status: 403, - headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) }, - }, - ) - : json({ ok: true }), - ); - return Effect.gen(function* () { - yield* TestClock.setTime(NOW); - const api = yield* GitHubApi.GitHubApi; - const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" }; - yield* Effect.flip(api.rest(read)); - expect((yield* Effect.flip(api.rest(read)))._tag).toBe("SourceControlRateLimitPausedError"); - const merged = yield* api - .rest({ - host: "github.com", - operation: "merge", - method: "PUT", - path: "repos/acme/web/pulls/7/merge", - }) - .pipe(Effect.provideService(GitHubApi.AllowGitHubReserve, true)); - expect(merged.status).toBe(200); - expect(requests).toHaveLength(2); - }).pipe(Effect.provide(layer)); - }); - - it.effect("reads an untyped GraphQL quota error as a rate limit", () => { - const reset = Math.floor(NOW / 1000) + 900; - const { layer } = harness(() => - json( - { errors: [{ message: "API rate limit already exceeded for user ID 1." }] }, - { headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) } }, - ), - ); - return Effect.gen(function* () { - yield* TestClock.setTime(NOW); - const api = yield* GitHubApi.GitHubApi; - const error = yield* Effect.flip( - api.graphql({ host: "github.com", operation: "lookup", query: "query { viewer { id } }" }), - ); - expect(error).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: reset * 1000 }); - }).pipe(Effect.provide(layer)); - }); - - it.effect("maps REST 403 with an exhausted quota to a rate limit, and 304 to an answer", () => { - let call = 0; - const { layer } = harness(() => - ++call === 1 - ? new Response(null, { status: 304 }) - : json( - { message: "API rate limit exceeded" }, - { - status: 403, - headers: { "retry-after": "60" }, - }, - ), - ); - return Effect.gen(function* () { - yield* TestClock.setTime(NOW); - const api = yield* GitHubApi.GitHubApi; - const notModified = yield* api.rest({ - host: "github.com", - operation: "checks", - path: "repos/acme/web/pulls/7", - ifNoneMatch: '"abc"', - }); - expect(notModified.status).toBe(304); - const error = yield* Effect.flip( - api.rest({ host: "github.com", operation: "checks", path: "repos/acme/web/pulls/7" }), - ); - expect(error).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: NOW + 60_000 }); - }).pipe(Effect.provide(layer)); - }); - - it.effect("drops a refused token so the next request asks the source again", () => { - const { layer, requests, invalidations } = harness((request) => - request.headers.authorization === "Bearer first" - ? new Response(null, { status: 401 }) - : json({ ok: true }), - ); - return Effect.gen(function* () { - const api = yield* GitHubApi.GitHubApi; - const refused = yield* Effect.flip( - api.rest({ host: "github.com", operation: "user", path: "user" }), - ); - expect(refused._tag).toBe("GitHubApiAuthenticationError"); - expect(invalidations()).toBe(1); - const answered = yield* api.rest({ host: "github.com", operation: "user", path: "user" }); - expect(answered.status).toBe(200); - expect(requests.map((request) => request.headers.authorization)).toEqual([ - "Bearer first", - "Bearer second", - ]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("refuses to send a pinned credential to another host", () => { - const { layer, requests } = harness(() => json({})); - return Effect.gen(function* () { - const api = yield* GitHubApi.GitHubApi; - const error = yield* Effect.flip( - api.rest({ host: "git.acme.internal", operation: "user", path: "user" }), - ).pipe( - Effect.provideService(GitHubApi.PinnedGitHubCredential, { - host: "github.com", - token: Redacted.make("pinned"), - credentialFingerprint: "github.com:pinned", - }), - ); - expect(error._tag).toBe("GitHubApiAuthenticationError"); - expect(requests).toHaveLength(0); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubCredentials", () => { - const credentialsWith = (run: VcsProcess.VcsProcess["Service"]["run"]) => - GitHubCredentials.layer.pipe( - Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run })), - Layer.provide(NodeServices.layer), - Layer.provide(ServerSettings.layerTest()), - ); - - it.effect("fails with GitHubCliMissingError when gh is not on PATH", () => - Effect.gen(function* () { - const credentials = yield* GitHubCredentials.GitHubCredentials; - const error = yield* Effect.flip(credentials.get("git.acme.internal")); - expect(error._tag).toBe("GitHubCliMissingError"); - }).pipe( - Effect.provide( - credentialsWith(() => - Effect.fail( - new VcsProcessSpawnError({ - operation: "GitHubCredentials.get", - command: "gh", - cwd: "/", - cause: PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - }), - }), - ), - ), - ), - ), - ); - - it.effect("does not cache a gh failure that is not about signing in", () => { - let calls = 0; - return Effect.gen(function* () { - const credentials = yield* GitHubCredentials.GitHubCredentials; - const first = yield* Effect.flip(credentials.get("github.com")); - expect(first._tag).toBe("GitHubCliFailedError"); - expect((yield* credentials.get("github.com")).source).toBe("gh"); - expect(calls).toBe(2); - }).pipe( - Effect.provide( - credentialsWith(() => - ++calls === 1 - ? Effect.fail( - new VcsProcessTimeoutError({ - operation: "GitHubCredentials.get", - command: "gh", - cwd: "/", - timeoutMs: 10_000, - }), - ) - : Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(0), - stdout: "token\n", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }), - ), - ), - ); - }); - - it.effect("fails with GitHubNotSignedInError when gh prints no token", () => - Effect.gen(function* () { - const credentials = yield* GitHubCredentials.GitHubCredentials; - const error = yield* Effect.flip(credentials.get("git.acme.internal")); - expect(error._tag).toBe("GitHubNotSignedInError"); - }).pipe( - Effect.provide( - credentialsWith(() => - Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(0), - stdout: "\n", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }), - ), - ), - ), - ); -}); diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts deleted file mode 100644 index 1cc66a1323c7f42011a64ce924215dffe6f3dc38..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ /dev/null @@ -1,598 +0,0 @@ -import * as Clock from "effect/Clock"; -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Redacted from "effect/Redacted"; -import * as Schema from "effect/Schema"; -import * as Semaphore from "effect/Semaphore"; -import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http"; - -import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; -import * as GitHubCredentials from "./GitHubCredentials.ts"; -import * as GitHubQuota from "./githubQuota.ts"; -import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; - -const DEFAULT_TIMEOUT = Duration.seconds(30); -const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; -/** Polite to GitHub's secondary limits, which punish bursts of concurrent requests. */ -const CONCURRENCY = 8; -const API_VERSION = "2022-11-28"; - -/** - * A credential already verified for one host. Every request made under it must target that - * host, so a page cannot read one account's data with another's token mid-flight. Server-local: - * never put its value in RPC payloads or cache keys. - */ -export const PinnedGitHubCredential = Context.Reference<{ - readonly host: string; - readonly token: Redacted.Redacted; - readonly credentialFingerprint: string; -} | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null }); - -/** - * Set by interactive callers (a user's read or write, not a background sweep). Requests made - * under it may spend the GraphQL reserve and go through a rate-limit pause: a user acting on a - * pull request should not be refused because a background read exhausted the quota. - */ -export const AllowGitHubReserve = Context.Reference( - "t3/sourceControl/AllowGitHubReserve", - { defaultValue: () => false }, -); - -export class GitHubApiRequestError extends Schema.TaggedError()( - "GitHubApiRequestError", - { host: Schema.String, operation: Schema.String, cause: Schema.Defect() }, -) { - override get message(): string { - return `Could not reach GitHub at ${this.host}.`; - } -} - -export class GitHubApiAuthenticationError extends Schema.TaggedError()( - "GitHubApiAuthenticationError", - { host: Schema.String, operation: Schema.String }, -) { - override get message(): string { - return `GitHub refused the credential for ${this.host}. Run \`gh auth login --hostname ${this.host}\` and retry.`; - } -} - -export class GitHubApiRateLimitError extends Schema.TaggedError()( - "GitHubApiRateLimitError", - { - host: Schema.String, - operation: Schema.String, - retryAt: Schema.optionalKey(Schema.Finite), - }, -) { - override get message(): string { - return "GitHub API rate limit exceeded."; - } -} - -export class GitHubApiNotFoundError extends Schema.TaggedError()( - "GitHubApiNotFoundError", - { host: Schema.String, operation: Schema.String }, -) { - override get message(): string { - return "GitHub could not find the requested resource, or the credential cannot see it."; - } -} - -/** - * GitHub answered with a failure that is none of the above. `githubErrors` carries GitHub's own - * error messages, from a GraphQL `errors` list or a REST `message`/`errors` body: they name the - * field and the reason ("A pull request already exists for acme:feature"), never a token. - */ -export class GitHubApiResponseError extends Schema.TaggedError()( - "GitHubApiResponseError", - { - host: Schema.String, - operation: Schema.String, - status: Schema.Int, - githubErrors: Schema.optionalKey(Schema.Array(Schema.String)), - }, -) { - override get message(): string { - return this.githubErrors !== undefined && this.githubErrors.length > 0 - ? `GitHub returned an error: ${this.githubErrors.join("; ")}` - : `GitHub returned HTTP ${this.status}.`; - } -} - -export type GitHubApiError = - | GitHubCredentials.GitHubCredentialUnavailableError - | GitHubApiRequestError - | GitHubApiAuthenticationError - | GitHubApiRateLimitError - | GitHubApiNotFoundError - | GitHubApiResponseError - | SourceControlRateLimit.SourceControlRateLimitPausedError; - -export interface GitHubRestResponse { - readonly status: number; - readonly headers: Readonly>; - readonly body: string; - readonly truncated: boolean; - /** The body was not valid UTF-8, which a raw file read takes to mean binary. */ - readonly invalidUtf8: boolean; -} - -export interface GitHubRestInput { - readonly host: string; - readonly operation: string; - readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - /** Relative to the API root, e.g. `repos/acme/web/pulls/7`; query string included. */ - readonly path: string; - readonly body?: unknown; - /** Defaults to `application/vnd.github+json`. */ - readonly accept?: string; - /** Revalidates a cached answer. A 304 is returned rather than failed, and is free. */ - readonly ifNoneMatch?: string; - readonly maxResponseBytes?: number; - /** Defaults to 30 seconds; a whole pull request's patch may need longer. */ - readonly timeout?: Duration.Input; - /** Overrides `AllowGitHubReserve` for this one request. */ - readonly allowReserve?: boolean; -} - -export interface GitHubGraphQlInput { - readonly host: string; - readonly operation: string; - readonly query: string; - readonly variables?: Readonly>; - readonly allowReserve?: boolean; - readonly maxResponseBytes?: number; -} - -export class GitHubApi extends Context.Service< - GitHubApi, - { - /** The raw JSON body of a successful GraphQL answer, with `rateLimit` recorded. */ - readonly graphql: (input: GitHubGraphQlInput) => Effect.Effect; - readonly rest: (input: GitHubRestInput) => Effect.Effect; - /** The credential a request to `host` would carry right now. */ - readonly credential: ( - host: string, - ) => Effect.Effect< - { readonly token: Redacted.Redacted; readonly fingerprint: string }, - GitHubApiError - >; - } ->()("t3/sourceControl/GitHubApi") {} - -function normalizeHost(host: string): string { - return host.trim().toLowerCase(); -} - -/** - * Where the API for a host lives. github.com and GHE.com data residency serve it from an `api.` - * subdomain; GitHub Enterprise Server serves it under `/api` on the instance itself. - */ -export function gitHubApiUrls(host: string): { readonly rest: string; readonly graphql: string } { - const normalized = normalizeHost(host); - if (normalized === "github.com") { - return { rest: "https://api.github.com", graphql: "https://api.github.com/graphql" }; - } - if (normalized.endsWith(".ghe.com")) { - return { - rest: `https://api.${normalized}`, - graphql: `https://api.${normalized}/graphql`, - }; - } - return { rest: `https://${normalized}/api/v3`, graphql: `https://${normalized}/api/graphql` }; -} - -/** GraphQL documents longer than this are cut in traces; the hash still identifies them. */ -const TRACED_QUERY_MAX_CHARS = 4_000; - -/** A stable short id for a GraphQL document, so traces group by query without its text. */ -function queryHash(query: string): string { - let hash = 0x811c9dc5; - for (let index = 0; index < query.length; index++) { - hash ^= query.charCodeAt(index); - hash = Math.imul(hash, 0x01000193); - } - return (hash >>> 0).toString(16).padStart(8, "0"); -} - -/** The numeric rate-limit headers GitHub sends, as span attributes. */ -function rateLimitAttributes( - headers: Readonly>, -): Record { - const attributes: Record = {}; - for (const [header, name] of [ - ["x-ratelimit-limit", "github.ratelimit.limit"], - ["x-ratelimit-remaining", "github.ratelimit.remaining"], - ["x-ratelimit-used", "github.ratelimit.used"], - ["x-ratelimit-reset", "github.ratelimit.reset"], - ] as const) { - const value = Number(headers[header]); - if (headers[header] !== undefined && Number.isFinite(value)) attributes[name] = value; - } - const resource = headers["x-ratelimit-resource"]; - if (resource !== undefined) attributes["github.ratelimit.resource"] = resource; - return attributes; -} - -/** The pause GitHub asked for, from `retry-after` or the primary limit's reset. */ -function retryAtFrom( - headers: Readonly>, - now: number, -): number | undefined { - const fromRetryAfter = SourceControlRateLimit.retryAtFromHeader(headers["retry-after"], now); - if (fromRetryAfter !== undefined) return fromRetryAfter; - const reset = Number(headers["x-ratelimit-reset"]) * 1_000; - return Number.isFinite(reset) && reset > now ? reset : undefined; -} - -const decodeGraphQlErrors = Schema.decodeUnknownOption( - Schema.fromJsonString( - Schema.Struct({ - errors: Schema.NonEmptyArray( - Schema.Struct({ - type: Schema.optional(Schema.String), - message: Schema.optional(Schema.String), - }), - ), - }), - ), -); - -/** A REST failure body: `{ message, errors: [{ message } | { resource, field, code }] }`. */ -const decodeRestErrors = Schema.decodeUnknownOption( - Schema.fromJsonString( - Schema.Struct({ - message: Schema.optional(Schema.String), - errors: Schema.optional( - Schema.Array( - Schema.Union([ - Schema.String, - Schema.Struct({ - message: Schema.optional(Schema.String), - field: Schema.optional(Schema.String), - code: Schema.optional(Schema.String), - }), - ]), - ), - ), - }), - ), -); - -function restErrorMessages(body: string): ReadonlyArray | undefined { - return Option.match(decodeRestErrors(body), { - onNone: () => undefined, - onSome: (decoded) => { - const details = (decoded.errors ?? []).flatMap((error) => - typeof error === "string" - ? [error] - : error.message !== undefined - ? [error.message] - : error.field !== undefined && error.code !== undefined - ? [`${error.field} ${error.code}`] - : [], - ); - const messages = [...(decoded.message === undefined ? [] : [decoded.message]), ...details]; - return messages.length > 0 ? messages : undefined; - }, - }); -} - -/** What one GitHub answer means, decided once from its status, headers and body. */ -type Answer = Data.TaggedEnum<{ - Ok: {}; - RateLimited: {}; - Unauthorized: {}; - NotFound: {}; - Failed: { readonly messages: ReadonlyArray | undefined }; -}>; -const Answer = Data.taggedEnum(); - -/** - * GitHub reports a failed GraphQL document with HTTP 200 and an `errors` list, so a GraphQL body - * is read for its error types as well as its status. `gh api graphql` failed those too, and - * callers rely on that to fall back to narrower reads. - */ -function classify(input: { - readonly status: number; - readonly headers: Readonly>; - readonly body: string; - readonly graphql: boolean; - readonly acceptNotModified: boolean; -}): Answer { - const { status, headers, body } = input; - const errors = input.graphql - ? Option.getOrUndefined(decodeGraphQlErrors(body))?.errors - : undefined; - const types = errors?.flatMap((error) => (error.type === undefined ? [] : [error.type])) ?? []; - const messages = errors?.flatMap((error) => (error.message === undefined ? [] : [error.message])); - if ( - status === 429 || - types.includes("RATE_LIMITED") || - // An exhausted GraphQL quota can answer HTTP 200 with an untyped "API rate limit already - // exceeded" error; the headers say the same thing. - (input.graphql && errors !== undefined && headers["x-ratelimit-remaining"] === "0") || - (input.graphql && - messages !== undefined && - messages.some((message) => /rate limit (already )?exceeded/i.test(message))) || - (status === 403 && - (headers["x-ratelimit-remaining"] === "0" || - headers["retry-after"] !== undefined || - /rate limit/i.test(body))) - ) { - return Answer.RateLimited(); - } - if (status === 401) return Answer.Unauthorized(); - if ( - status === 404 || - (errors !== undefined && errors.every((error) => error.type === "NOT_FOUND")) - ) { - return Answer.NotFound(); - } - if (errors !== undefined) return Answer.Failed({ messages }); - if ((status >= 200 && status < 300) || (status === 304 && input.acceptNotModified)) { - return Answer.Ok(); - } - return Answer.Failed({ messages: input.graphql ? undefined : restErrorMessages(body) }); -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const credentials = yield* GitHubCredentials.GitHubCredentials; - const quota = yield* GitHubQuota.GitHubQuota; - const limits = yield* SourceControlRateLimit.SourceControlRateLimit; - const gate = yield* Semaphore.make(CONCURRENCY); - - const credential: GitHubApi["Service"]["credential"] = Effect.fn("GitHubApi.credential")( - function* (host) { - const normalized = normalizeHost(host); - const pinned = yield* PinnedGitHubCredential; - if (pinned !== null) { - // A pinned page only ever talks to the host it verified. - if (pinned.host !== normalized) { - return yield* new GitHubApiAuthenticationError({ - host: normalized, - operation: "credential", - }); - } - return { token: pinned.token, fingerprint: pinned.credentialFingerprint }; - } - const held = yield* credentials.get(normalized); - return { token: held.token, fingerprint: held.fingerprint }; - }, - ); - - /** - * Sends one request under the host's rate-limit pause and fails a refusal with the error that - * says why. `onSuccess` sees the answer only once it is known to be one. - */ - const send = Effect.fn("GitHubApi.send")(function* (input: { - readonly host: string; - readonly operation: string; - readonly request: HttpClientRequest.HttpClientRequest; - readonly maxResponseBytes: number; - readonly timeout?: Duration.Input | undefined; - readonly allowReserve: boolean; - readonly acceptNotModified: boolean; - /** Reads the body for GraphQL `errors`, which GitHub sends with HTTP 200. */ - readonly graphql?: boolean; - }) { - const host = normalizeHost(input.host); - // Only the path: a query string can carry a SHA or a branch, and never needs to be in a trace. - yield* Effect.annotateCurrentSpan({ - "github.host": host, - "github.operation": input.operation, - "github.kind": input.graphql === true ? "graphql" : "rest", - "http.request.method": input.request.method, - "url.path": new URL(input.request.url).pathname, - }); - const { token, fingerprint } = yield* credential(host); - const scope = yield* SourceControlRateLimit.CredentialScope; - // REST spends `core` and GraphQL its own quota, each with its own reserve. Every REST path - // this reads today is `core`; a `search/` read would need its own resource here. A refusal - // still pauses the whole host, the key PullRequestService records its own backoff under. - const resource = input.graphql === true ? "graphql" : "core"; - const key = { provider: "github" as const, host }; - const run = Effect.gen(function* () { - const lease = yield* quota - .admit(host, resource, { allowReserve: input.allowReserve }) - .pipe( - Effect.andThen(limits.check(key, input.allowReserve ? { allowPaused: true } : undefined)), - ) - .pipe( - Effect.tapError((paused) => - Effect.annotateCurrentSpan({ - "github.paused": true, - "github.retry_at": paused.retryAt, - }), - ), - ); - // One deadline covers the headers and the body: a host that answers headers and then stalls - // must not hold a slot of the shared gate for undici's own five-minute body timeout. - const { response, collected } = yield* Effect.gen(function* () { - const response: HttpClientResponse.HttpClientResponse = yield* httpClient - .execute( - input.request.pipe( - HttpClientRequest.bearerToken(Redacted.value(token)), - HttpClientRequest.setHeaders({ - "x-github-api-version": API_VERSION, - "user-agent": "t3code", - }), - ), - ) - .pipe( - // `GitHubApi.send` is the request's span. The client's own span would add `url.full` - // and `url.query`, which can carry SHAs and branch names, so it is off for GitHub. - Effect.provideService(HttpClient.TracerDisabledWhen, () => true), - ); - const collected = yield* collectUint8StreamText({ - stream: response.stream, - maxBytes: input.maxResponseBytes, - }).pipe( - // 204, 304 and many refusals carry no body at all, which is an empty answer. - Effect.catchIf( - (error) => error.reason._tag === "EmptyBodyError", - () => Effect.succeed({ text: "", truncated: false, invalidUtf8: false }), - ), - ); - return { response, collected }; - }).pipe( - Effect.timeout(input.timeout ?? DEFAULT_TIMEOUT), - Effect.mapError( - (cause) => new GitHubApiRequestError({ host, operation: input.operation, cause }), - ), - ); - const headers = response.headers; - const status = response.status; - yield* quota.observe(host, headers); - yield* Effect.annotateCurrentSpan({ - "http.response.status_code": status, - ...rateLimitAttributes(headers), - }); - const context = { host, operation: input.operation }; - return yield* Answer.$match( - classify({ - status, - headers, - body: collected.text, - graphql: input.graphql === true, - acceptNotModified: input.acceptNotModified, - }), - { - Ok: () => - limits.recordSuccess({ ...key, lease }).pipe( - Effect.as({ - status, - headers, - body: collected.text, - truncated: collected.truncated, - invalidUtf8: collected.invalidUtf8, - }), - ), - RateLimited: () => - Effect.gen(function* () { - const retryAt = retryAtFrom(headers, yield* Clock.currentTimeMillis); - yield* limits.recordRateLimit({ ...key, lease, retryAt }); - yield* Effect.annotateCurrentSpan({ - "github.rate_limited": true, - ...(retryAt === undefined ? {} : { "github.retry_at": retryAt }), - }); - return yield* new GitHubApiRateLimitError({ - ...context, - ...(retryAt === undefined ? {} : { retryAt }), - }); - }), - // The source may hold a newer token than the one that was refused. - Unauthorized: () => - credentials - .invalidate(host) - .pipe(Effect.andThen(Effect.fail(new GitHubApiAuthenticationError(context)))), - NotFound: () => Effect.fail(new GitHubApiNotFoundError(context)), - Failed: ({ messages }) => - Effect.fail( - new GitHubApiResponseError({ - ...context, - status, - ...(messages === undefined ? {} : { githubErrors: messages }), - }), - ), - }, - ); - }); - // The rate-limit scope follows the credential, so a pause on one account never blocks another. - return yield* gate - .withPermit(run) - .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope || fingerprint)); - }); - - const rest: GitHubApi["Service"]["rest"] = (input) => { - const url = `${gitHubApiUrls(input.host).rest}/${input.path.replace(/^\/+/, "")}`; - const base = HttpClientRequest.make(input.method ?? "GET")(url).pipe( - HttpClientRequest.setHeader("accept", input.accept ?? "application/vnd.github+json"), - ); - const withEtag = - input.ifNoneMatch === undefined - ? base - : base.pipe(HttpClientRequest.setHeader("if-none-match", input.ifNoneMatch)); - const request = - input.body === undefined - ? withEtag - : withEtag.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)); - return AllowGitHubReserve.pipe( - Effect.flatMap((interactive) => - send({ - host: input.host, - operation: input.operation, - request, - maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, - timeout: input.timeout, - allowReserve: input.allowReserve ?? interactive, - acceptNotModified: input.ifNoneMatch !== undefined, - }), - ), - ); - }; - - const graphql: GitHubApi["Service"]["graphql"] = Effect.fn("GitHubApi.graphql")( - function* (input) { - const host = normalizeHost(input.host); - const { fingerprint } = yield* credential(host); - const scope = (yield* SourceControlRateLimit.CredentialScope) || fingerprint; - const allowReserve = input.allowReserve ?? (yield* AllowGitHubReserve); - // The document, never its variables: user text (bodies, search terms) travels as variables. - yield* Effect.annotateCurrentSpan({ - "github.operation": input.operation, - "github.graphql.query_hash": queryHash(input.query), - "github.graphql.query": - input.query.length > TRACED_QUERY_MAX_CHARS - ? `${input.query.slice(0, TRACED_QUERY_MAX_CHARS)}…` - : input.query, - }); - return yield* Effect.gen(function* () { - const response = yield* send({ - host, - operation: input.operation, - request: HttpClientRequest.post(gitHubApiUrls(host).graphql).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bodyJsonUnsafe({ - query: input.query, - variables: input.variables ?? {}, - }), - ), - maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, - allowReserve, - acceptNotModified: false, - graphql: true, - }); - // A body cut at the byte cap hides any `errors` past the cut and cannot be decoded. - if (response.truncated) { - return yield* new GitHubApiResponseError({ - host, - operation: input.operation, - status: response.status, - }); - } - return response.body; - }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope)); - }, - ); - - return GitHubApi.of({ graphql, rest, credential }); -}); - -export const layer = Layer.effect(GitHubApi, make); - -/** - * The transport with the credential, quota reserve and rate-limit pause it reads through. An - * application builds this once so every GitHub reader shares one view of each quota per host. - */ -export const layerWithDependencies = layer.pipe( - Layer.provideMerge(GitHubCredentials.layer), - Layer.provideMerge(GitHubQuota.layer), - Layer.provideMerge(SourceControlRateLimit.layer), -); diff --git a/apps/server/src/sourceControl/GitHubCredentials.test.ts b/apps/server/src/sourceControl/GitHubCredentials.test.ts deleted file mode 100644 index 734a309f002f3e552c45d8e44d59d38615568b5f..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/GitHubCredentials.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Redacted from "effect/Redacted"; -import { ChildProcessSpawner } from "effect/process"; - -import * as ServerSettings from "../serverSettings.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubCredentials from "./GitHubCredentials.ts"; - -const TOKEN_VARIABLES = [ - "GH_TOKEN", - "GITHUB_TOKEN", - "GH_ENTERPRISE_TOKEN", - "GITHUB_ENTERPRISE_TOKEN", -] as const; - -function harness( - hosts: Record = {}, - signedOut: ReadonlyArray = [], - tokens: Record = {}, -) { - const calls: Array> = []; - const process = Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => - Effect.sync(() => { - calls.push(input.args); - const user = input.args[input.args.indexOf("--user") + 1]; - if (input.args.includes("--user") && user !== undefined && signedOut.includes(user)) { - return { - exitCode: ChildProcessSpawner.ExitCode(0), - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }; - } - return { - exitCode: ChildProcessSpawner.ExitCode(0), - stdout: input.args.includes("--user") ? `token-for-${user}\n` : "active-token\n", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }; - }), - }); - const layer = GitHubCredentials.layer.pipe( - Layer.provideMerge( - ServerSettings.ServerSettingsService.layerTest({ github: { hosts, tokens } }), - ), - Layer.provide(process), - Layer.provide(NodeServices.layer), - ); - return { layer, calls }; -} - -describe("GitHubCredentials", () => { - beforeEach(() => { - for (const name of TOKEN_VARIABLES) vi.stubEnv(name, ""); - }); - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it.effect("asks gh for the active login when Settings pin nothing", () => { - const { layer, calls } = harness(); - return Effect.gen(function* () { - const credentials = yield* GitHubCredentials.GitHubCredentials; - const credential = yield* credentials.get("GitHub.com"); - expect(Redacted.value(credential.token)).toBe("active-token"); - expect(calls).toEqual([["auth", "token", "--hostname", "github.com"]]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("passes --user for an account pinned in Settings", () => { - const { layer, calls } = harness({ "github.com": { account: "work" } }); - return Effect.gen(function* () { - const credentials = yield* GitHubCredentials.GitHubCredentials; - const credential = yield* credentials.get("github.com"); - expect(Redacted.value(credential.token)).toBe("token-for-work"); - expect(calls).toEqual([["auth", "token", "--hostname", "github.com", "--user", "work"]]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails a host turned off in Settings without asking gh", () => { - const { layer, calls } = harness({ "github.com": { enabled: false } }); - return Effect.gen(function* () { - const credentials = yield* GitHubCredentials.GitHubCredentials; - const error = yield* Effect.flip(credentials.get("github.com")); - expect(error._tag).toBe("GitHubHostDisabledError"); - expect(error.message).toBe( - "GitHub host github.com is turned off in Settings → Source Control.", - ); - expect(calls).toEqual([]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("lets an environment token win over a pinned account, as gh does", () => { - vi.stubEnv("GH_TOKEN", "from-env"); - const { layer, calls } = harness({ "github.com": { account: "work" } }); - return Effect.gen(function* () { - const credentials = yield* GitHubCredentials.GitHubCredentials; - const credential = yield* credentials.get("github.com"); - expect(Redacted.value(credential.token)).toBe("from-env"); - expect(credential.source).toBe("env"); - expect(calls).toEqual([]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("picks up a changed account on the next request without a restart", () => { - const { layer, calls } = harness(); - return Effect.gen(function* () { - const credentials = yield* GitHubCredentials.GitHubCredentials; - const settings = yield* ServerSettings.ServerSettingsService; - expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); - - yield* settings.updateSettings({ - github: { hosts: { "github.com": { account: "work", enabled: true } } }, - }); - expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("token-for-work"); - - yield* settings.updateSettings({ github: { hosts: { "github.com": { enabled: false } } } }); - expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe( - "GitHubHostDisabledError", - ); - - yield* settings.updateSettings({ github: { hosts: {} } }); - expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); - // The unpinned token stayed cached; only the newly pinned account cost a gh call. - expect(calls).toHaveLength(2); - }).pipe(Effect.provide(layer)); - }); - - it.effect("falls back to the active login when the pinned one is no longer signed in", () => { - const { layer, calls } = harness({ "github.com": { account: "gone" } }, ["gone"]); - return Effect.gen(function* () { - const credentials = yield* GitHubCredentials.GitHubCredentials; - expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); - expect(calls).toEqual([ - ["auth", "token", "--hostname", "github.com", "--user", "gone"], - ["auth", "token", "--hostname", "github.com"], - ]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("uses a token saved in Settings before GH_TOKEN and gh", () => { - vi.stubEnv("GH_TOKEN", "env-token"); - const { layer, calls } = harness({}, [], { "github.com": "saved-token" }); - return Effect.gen(function* () { - const credentials = yield* GitHubCredentials.GitHubCredentials; - const credential = yield* credentials.get("github.com"); - expect(Redacted.value(credential.token)).toBe("saved-token"); - expect(credential.source).toBe("settings"); - expect(calls).toEqual([]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("keeps a host turned off even with a token saved in Settings", () => { - const { layer } = harness({ "github.com": { enabled: false } }, [], { - "github.com": "saved-token", - }); - return Effect.gen(function* () { - const credentials = yield* GitHubCredentials.GitHubCredentials; - expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe( - "GitHubHostDisabledError", - ); - }).pipe(Effect.provide(layer)); - }); -}); diff --git a/apps/server/src/sourceControl/GitHubCredentials.ts b/apps/server/src/sourceControl/GitHubCredentials.ts deleted file mode 100644 index d47d57ea7ffef02b16b2b7ba8204a73cea065f53..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/GitHubCredentials.ts +++ /dev/null @@ -1,262 +0,0 @@ -import * as Cache from "effect/Cache"; -import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Hex from "effect/encoding/Hex"; -import * as Exit from "effect/Exit"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as PlatformError from "effect/PlatformError"; -import * as Redacted from "effect/Redacted"; -import * as Schema from "effect/Schema"; - -import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; - -import * as ServerSettings from "../serverSettings.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; - -/** How long a token is reused before `gh` is asked again, so a `gh auth switch` applies soon. */ -const TOKEN_TTL = Duration.minutes(5); -/** No credential is retried sooner, so a fresh `gh auth login` takes effect on the next read. */ -const MISSING_TTL = Duration.seconds(10); - -export const GitHubCredentialSource = Schema.Literals(["settings", "env", "gh"]); -export type GitHubCredentialSource = typeof GitHubCredentialSource.Type; - -export interface GitHubCredential { - readonly host: string; - readonly token: Redacted.Redacted; - readonly source: GitHubCredentialSource; - /** A digest of host and token: safe for cache keys and rate-limit scopes, never the token. */ - readonly fingerprint: string; -} - -/** Nothing in the environment, and no `gh` on PATH to ask. */ -export class GitHubCliMissingError extends Schema.TaggedError()( - "GitHubCliMissingError", - { host: Schema.String }, -) { - override get message(): string { - return `No GitHub credential for ${this.host}: set GH_TOKEN, or install the GitHub CLI and run \`gh auth login\`.`; - } -} - -/** `gh` is installed but holds no login for the host, or not the account chosen in Settings. */ -export class GitHubNotSignedInError extends Schema.TaggedError()( - "GitHubNotSignedInError", - { host: Schema.String, account: Schema.optional(Schema.String) }, -) { - override get message(): string { - return this.account === undefined - ? `No GitHub credential for ${this.host}: run \`gh auth login --hostname ${this.host}\`.` - : `No GitHub credential for ${this.account} on ${this.host}: run \`gh auth login --hostname ${this.host}\` for that account or pick another in Settings → Source Control.`; - } -} - -/** The user turned the host off in Settings. */ -export class GitHubHostDisabledError extends Schema.TaggedError()( - "GitHubHostDisabledError", - { host: Schema.String }, -) { - override get message(): string { - return `GitHub host ${this.host} is turned off in Settings → Source Control.`; - } -} - -/** `gh auth token` timed out or failed for a reason other than having no login. */ -export class GitHubCliFailedError extends Schema.TaggedError()( - "GitHubCliFailedError", - { host: Schema.String, cause: Schema.Defect() }, -) { - override get message(): string { - return `The GitHub CLI could not hand over a credential for ${this.host}. Check \`gh auth status\` on the server.`; - } -} - -/** There is no token for the host. */ -export type GitHubCredentialUnavailableError = - | GitHubCliMissingError - | GitHubNotSignedInError - | GitHubHostDisabledError - | GitHubCliFailedError; - -/** - * Where GitHub tokens come from. Callers ask per host and never see how the token was found, - * so another source (an in-app OAuth login) slots in here without touching any of them. - */ -export class GitHubCredentials extends Context.Service< - GitHubCredentials, - { - readonly get: ( - host: string, - ) => Effect.Effect; - /** Drops the held token after GitHub refused it, so the next read asks its source again. */ - readonly invalidate: (host: string) => Effect.Effect; - } ->()("t3/sourceControl/GitHubCredentials") {} - -function normalizeHost(host: string): string { - return host.trim().toLowerCase(); -} - -/** Hosts gh treats as GitHub.com-like for `GH_TOKEN`: github.com and GHE.com data residency. */ -function isGitHubDotCom(host: string): boolean { - return host === "github.com" || host.endsWith(".ghe.com"); -} - -/** - * The environment token for a host, in gh's precedence order. gh hands `GH_ENTERPRISE_TOKEN` to - * any non-github.com host; here it only goes to the host `GH_HOST` names, because a remote URL - * picks the host and a hostile one must not receive an enterprise token. - */ -export function environmentToken( - host: string, - env: Readonly>, -): string | null { - const names = isGitHubDotCom(host) - ? ["GH_TOKEN", "GITHUB_TOKEN"] - : env.GH_HOST?.trim().toLowerCase() === host - ? ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"] - : []; - for (const name of names) { - const value = env[name]?.trim(); - if (value) return value; - } - return null; -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const process = yield* VcsProcess.VcsProcess; - const serverSettings = yield* ServerSettings.ServerSettingsService; - const crypto = yield* Crypto.Crypto; - const environment = yield* HostProcessEnvironment; - const workingDirectory = yield* HostProcessWorkingDirectory; - - /** `host:sha256(token)`, safe for cache keys and rate-limit scopes. */ - const fingerprintOf = (host: string, token: string) => - crypto.digest("SHA-256", new TextEncoder().encode(token)).pipe( - Effect.map((digest) => `${host}:${Hex.encode(digest)}`), - // Hashing a string in memory has no platform failure worth a typed error. - Effect.orDie, - ); - - const fromGh = (host: string, account: string | undefined) => - process - .run({ - operation: "GitHubCredentials.get", - command: "gh", - args: [ - "auth", - "token", - "--hostname", - host, - ...(account === undefined ? [] : ["--user", account]), - ], - cwd: workingDirectory, - // Never let gh print the token into a debug log. - env: { GH_DEBUG: "", GH_PROMPT_DISABLED: "1" }, - timeoutMs: 10_000, - }) - .pipe( - Effect.mapError((error) => - error._tag === "VcsProcessSpawnError" && - error.cause instanceof PlatformError.PlatformError && - error.cause.reason._tag === "NotFound" - ? new GitHubCliMissingError({ host }) - : // gh exits non-zero with "no oauth token" when it has no login for the host. - error._tag === "VcsProcessExitError" - ? new GitHubNotSignedInError({ host, ...(account === undefined ? {} : { account }) }) - : new GitHubCliFailedError({ host, cause: error }), - ), - Effect.map((output) => output.stdout.trim()), - Effect.filterOrFail( - (token) => token !== "", - () => new GitHubNotSignedInError({ host, ...(account === undefined ? {} : { account }) }), - ), - ); - - /** The Settings choice for a host; unreadable settings fall back to gh's own choice. */ - const hostChoice = (host: string) => - serverSettings.getSettings.pipe( - Effect.map((settings) => settings.github.hosts[host]), - Effect.orElseSucceed(() => undefined), - ); - - /** A token saved in Settings for the host, read fresh so a saved or removed one applies at once. */ - const savedToken = (host: string) => - serverSettings.getSettings.pipe( - Effect.map((settings) => settings.github.tokens[host]?.trim() || null), - Effect.orElseSucceed(() => null), - ); - - /** Cache key: the host plus its pinned account, so a changed pin misses the cache. */ - const cacheKey = (host: string, account: string | undefined) => - account === undefined ? host : `${host}\u0000${account}`; - - const lookup = Effect.fn("GitHubCredentials.lookup")(function* (key: string) { - const [host = key, choice] = key.split("\u0000"); - // An environment token wins over a pinned account, exactly as it does in gh. - const fromEnv = environmentToken(host, environment); - // A pinned login gh no longer holds (logged out, expired) falls back to the active one, - // which is what discovery reports as the account in use. - const token = - fromEnv ?? - (yield* fromGh(host, choice).pipe( - Effect.catchTags({ - GitHubNotSignedInError: (error) => - choice === undefined ? Effect.fail(error) : fromGh(host, undefined), - }), - )); - return { - host, - token: Redacted.make(token), - source: fromEnv !== null ? "env" : "gh", - fingerprint: yield* fingerprintOf(host, token), - } satisfies GitHubCredential; - }); - - const cache = yield* Cache.makeWith(lookup, { - capacity: 32, - // A transient gh failure (a timeout, a locked keyring) is asked again on the next read. - timeToLive: (exit) => - Exit.isSuccess(exit) - ? TOKEN_TTL - : Exit.findErrorOption(exit).pipe( - Option.exists((error) => error._tag === "GitHubCliFailedError"), - ) - ? Duration.zero - : MISSING_TTL, - }); - - return GitHubCredentials.of({ - get: Effect.fn("GitHubCredentials.get")(function* (rawHost) { - const host = normalizeHost(rawHost); - const choice = yield* hostChoice(host); - if (choice?.enabled === false) { - return yield* new GitHubHostDisabledError({ host }); - } - // A token saved in Settings is the most deliberate choice, so it comes before the - // environment and gh. It is read from the secret store each time, so it needs no cache. - const saved = yield* savedToken(host); - if (saved !== null) { - return { - host, - token: Redacted.make(saved), - source: "settings", - fingerprint: yield* fingerprintOf(host, saved), - } satisfies GitHubCredential; - } - return yield* Cache.get(cache, cacheKey(host, choice?.account)); - }), - invalidate: (rawHost) => { - const host = normalizeHost(rawHost); - return hostChoice(host).pipe( - Effect.flatMap((choice) => Cache.invalidate(cache, cacheKey(host, choice?.account))), - ); - }, - }); -}); - -export const layer = Layer.effect(GitHubCredentials, make); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts deleted file mode 100644 index 602acc9d1e27457af271158a85338df878f99899..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ /dev/null @@ -1,1164 +0,0 @@ -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import { assert, describe, it } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -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 Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { ChildProcessSpawner } from "effect/process"; -import * as TestClock from "effect/testing/TestClock"; - -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCredentials from "./GitHubCredentials.ts"; -import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; -import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; - -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); - -const processResult = ( - stdout: string, - options?: { - readonly stderr?: string; - readonly exitCode?: ChildProcessSpawner.ExitCode; - }, -): VcsProcess.VcsProcessOutput => ({ - exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), - stdout, - stderr: options?.stderr ?? "", - stdoutTruncated: false, - stderrTruncated: false, -}); - -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 }, -}); - -/** - * The provider 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; - readonly resolved?: string; - /** Fails the local branch listing, the first git read a checkout makes. */ - readonly gitFailure?: unknown; -}) { - 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: () => - input.gitFailure === undefined - ? Effect.succeed([...(input.localBranches ?? [])]) - : Effect.fail(input.gitFailure as never), - resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), - }); - const process = Layer.mock(VcsProcess.VcsProcess)({ - run: (args) => - Effect.succeed( - args.args[0] === "remote" - ? processOutput(input.remotes) - : processOutput(input.resolved ?? "", input.resolved ? 0 : 1), - ), - }); - const layer = Layer.mergeAll( - driver, - process, - Layer.mock(GitHubApi.GitHubApi)(input.api), - NodeServices.layer, - ); - return { layer, git }; -} - -/** The provider context for a checkout whose remote is on `host`. */ -/** The provider over a mocked GitHubApi, for reads that never touch the checkout. */ -const makeProvider = (api: Partial) => - GitHubSourceControlProvider.make.pipe(Effect.provide(harness({ remotes: "", api }).layer)); - -const githubContext = (host: string) => ({ - provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` }, - remoteName: "origin", - remoteUrl: `git@${host}:acme/web.git`, -}); - -describe("GitHubSourceControlProvider 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* GitHubSourceControlProvider.make; - 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* GitHubSourceControlProvider.make; - 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", context: githubContext("github") }); - assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("names a missing repository rather than a pull request", () => { - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/gone.git"]), - api: { - rest: (input) => - Effect.fail( - new GitHubApi.GitHubApiNotFoundError({ host: input.host, operation: input.operation }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubSourceControlProvider.make; - const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); - assert.include(error.detail, "Repository not found"); - }).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* GitHubSourceControlProvider.make; - const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); - assert.strictEqual(error._tag, "SourceControlProviderError"); - assert.include(error.detail, "No GitHub repository"); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubSourceControlProvider change request lookups by head", () => { - 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* GitHubSourceControlProvider.make; - const lookup = (headSelector: string) => - gh - .listChangeRequests({ - cwd: "/repo", - headSelector, - state: "all", - limit: 100, - context: githubContext("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* GitHubSourceControlProvider.make; - const lookups = yield* Effect.all( - Array.from({ length: 26 }, (_, index) => - gh.listChangeRequests({ - cwd: "/repo", - headSelector: `feature/${index}`, - state: "all", - limit: 100, - context: githubContext("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* GitHubSourceControlProvider.make; - const open = yield* gh - .listChangeRequests({ cwd: "/repo", headSelector: "me:main", state: "open", 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* GitHubSourceControlProvider.make; - const read = (headSelector: string) => - gh - .listChangeRequests({ cwd: "/repo", headSelector, state: "open", limit: 1 }) - .pipe(Effect.flip, Effect.forkChild); - const missing = yield* read("missing"); - yield* TestClock.adjust("500 millis"); - assert.include((yield* Fiber.join(missing)).detail, "No GitHub credential on the server"); - const limited = yield* read("limited"); - yield* TestClock.adjust("500 millis"); - const error = yield* Fiber.join(limited); - assert.include(error.detail, "rate limit exceeded"); - assert.propertyVal(error.cause, "retryAt", 123); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubSourceControlProvider.getChangeRequest", () => { - 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* GitHubSourceControlProvider.make; - assert.strictEqual( - (yield* gh.getChangeRequest({ cwd: "/repo", reference: "#42" })).number, - 42, - ); - yield* gh.getChangeRequest({ - 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* GitHubSourceControlProvider.make; - const error = yield* gh.getChangeRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); - assert.include(error.detail, "Pull request not found"); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubSourceControlProvider 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* GitHubSourceControlProvider.make; - yield* gh.createChangeRequest({ - cwd: "/repo", - baseRefName: "main", - headSelector: "me:feature", - title: "Title", - bodyFile, - }); - assert.strictEqual(requests[0]!.method, "POST"); - assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); - // A user's own write, so the background's reserve is not held against it. - assert.strictEqual(requests[0]!.allowReserve, true); - 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* GitHubSourceControlProvider.make; - 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("GitHubSourceControlProvider.checkoutChangeRequest", () => { - 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* GitHubSourceControlProvider.make; - yield* gh.checkoutChangeRequest({ 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* GitHubSourceControlProvider.make; - yield* Effect.flip(gh.checkoutChangeRequest({ 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* GitHubSourceControlProvider.make; - yield* gh.checkoutChangeRequest({ 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.effect("keeps git's own output out of what a failed checkout reports", () => { - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - gitFailure: new Error("fatal: /home/me/secret-path: permission denied"), - api: { - graphql: () => - Effect.succeed( - encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubSourceControlProvider.make; - const error = yield* gh - .checkoutChangeRequest({ cwd: "/repo", reference: "5" }) - .pipe(Effect.flip); - assert.strictEqual(error.detail, "The pull request could not be checked out with git."); - assert.notInclude(error.message, "secret-path"); - }).pipe(Effect.provide(layer)); - }); - - it("names the local branch the way gh pr checkout does", () => { - const name = (headRefName: string, isCrossRepository: boolean) => - GitHubSourceControlProvider.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"); - }); -}); - -it("accepts active authenticated GitHub accounts when another account fails", () => { - const auth = GitHubSourceControlProvider.discovery.parseAuth( - processResult( - JSON.stringify({ - hosts: { - "github.com": [ - { - state: "success", - active: true, - host: "github.com", - login: "active-user", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - { - state: "error", - active: false, - host: "github.com", - login: "stale-user", - tokenSource: "keyring", - gitProtocol: "ssh", - error: "The token in keyring is invalid.", - }, - ], - }, - }), - ), - ); - - assert.deepStrictEqual( - { - status: auth.status, - account: auth.account, - host: auth.host, - }, - { - status: "authenticated", - account: Option.some("active-user"), - host: Option.some("github.com"), - }, - ); -}); - -it("parses GitHub auth JSON from stdout when stderr has warnings", () => { - const auth = GitHubSourceControlProvider.discovery.parseAuth( - processResult( - JSON.stringify({ - hosts: { - "github.com": [ - { - state: "success", - active: true, - host: "github.com", - login: "active-user", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - ], - }, - }), - { stderr: "warning: ignored diagnostic from gh\n" }, - ), - ); - - assert.deepStrictEqual( - { - status: auth.status, - account: auth.account, - host: auth.host, - }, - { - status: "authenticated", - account: Option.some("active-user"), - host: Option.some("github.com"), - }, - ); -}); - -it("parses GitHub auth status accounts by host and active state", () => { - assert.deepStrictEqual( - parseGitHubAuthStatus( - JSON.stringify({ - hosts: { - "github.com": [ - { - state: "success", - active: true, - host: "github.com", - login: "active-user", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - { - state: "error", - active: false, - host: "github.com", - login: "stale-user", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - ], - "github.example.test": [ - { - state: "success", - active: false, - host: "github.example.test", - login: "enterprise-user", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - ], - }, - }), - ).accounts, - [ - { - host: "github.com", - account: "active-user", - authenticated: true, - active: true, - error: null, - environmentVariable: null, - }, - { - host: "github.com", - account: "stale-user", - authenticated: false, - active: false, - error: null, - environmentVariable: null, - }, - { - host: "github.example.test", - account: "enterprise-user", - authenticated: true, - active: false, - error: null, - environmentVariable: null, - }, - ], - ); -}); - -it("reports unauthenticated when GitHub JSON has accounts but none are valid", () => { - const auth = GitHubSourceControlProvider.discovery.parseAuth( - processResult( - JSON.stringify({ - hosts: { - "github.com": [ - { - state: "error", - active: true, - host: "github.com", - login: "stale-user", - tokenSource: "keyring", - gitProtocol: "ssh", - error: "The token in keyring is invalid.", - }, - ], - }, - }), - ), - ); - - assert.deepStrictEqual( - { - status: auth.status, - host: auth.host, - detail: auth.detail, - }, - { - status: "unauthenticated", - host: Option.some("github.com"), - detail: Option.some("The token in keyring is invalid."), - }, - ); -}); - -it("reports an update hint instead of unauthenticated when gh predates --json", () => { - const auth = GitHubSourceControlProvider.discovery.parseAuth( - processResult("", { - stderr: "unknown flag: --json\n\nUsage: gh auth status [flags]\n", - exitCode: ChildProcessSpawner.ExitCode(1), - }), - ); - - assert.strictEqual(auth.status, "unknown"); - assert.match( - Option.getOrElse(auth.detail, () => ""), - /2\.81\.0/, - ); -}); - -it.effect.each(["pull", "issues"])( - "resolves %s subjects on the linked host without using the checkout", - (kind) => - Effect.gen(function* () { - const provider = yield* makeProvider({ - rest: (input) => { - assert.strictEqual(input.host, "github.com"); - assert.strictEqual(input.path, "repos/owner/repo/issues/42"); - return Effect.succeed( - restResponse({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }), - ); - }, - }); - const lookup = provider.resolveLink?.({ - cwd: "/unrelated", - url: new URL(`https://github.com/owner/repo/${kind}/42`), - }); - assert.ok(lookup); - assert.deepStrictEqual(yield* lookup, { - title: "Pairing expiry", - body: "Preserve remote access", - }); - assert.strictEqual( - provider.resolveLink?.({ - cwd: "/unrelated", - url: new URL("https://github.com/owner/repo"), - }), - undefined, - ); - }), -); - -it.effect.each(["read", "decode"] as const)( - "retains the %s failure without exposing its raw contents", - (stage) => - Effect.gen(function* () { - const cause = new GitHubApi.GitHubApiResponseError({ - host: "github.com", - operation: "resolveLink", - status: 500, - }); - const provider = yield* makeProvider({ - rest: () => - stage === "read" - ? Effect.fail(cause) - : Effect.succeed({ ...restResponse(undefined), body: "private response text" }), - }); - const lookup = provider.resolveLink?.({ - cwd: "/repo", - url: new URL("https://github.com/owner/repo/issues/42"), - }); - assert.ok(lookup); - const error = yield* Effect.flip(lookup); - assert.strictEqual(error.operation, stage === "read" ? "resolveLink" : "resolveLink.decode"); - assert.strictEqual(error.detail, "The linked subject could not be read."); - assert.notInclude(error.message, "private response text"); - if (stage === "read") assert.strictEqual(error.cause, cause); - else assert.propertyVal(error.cause, "_tag", "SchemaError"); - }), -); - -const multiAccountStatus = (extra: ReadonlyArray> = []) => - processResult( - JSON.stringify({ - hosts: { - "github.com": [ - { state: "success", active: true, host: "github.com", login: "personal" }, - { state: "success", active: false, host: "github.com", login: "work" }, - ...extra, - ], - "ghe.acme.test": [ - { state: "error", active: true, host: "ghe.acme.test", login: "jm", error: "expired" }, - ], - }, - }), - ); - -it("reports every gh login and leads with the account Settings pin", () => { - const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), { - hosts: { "github.com": { account: "work", enabled: true } }, - tokens: {}, - }); - assert.deepStrictEqual(auth.account, Option.some("work")); - assert.deepStrictEqual(auth.accounts, [ - { host: "github.com", account: "personal", active: true, authenticated: true }, - { host: "github.com", account: "work", active: false, authenticated: true }, - { host: "ghe.acme.test", account: "jm", active: true, authenticated: false, error: "expired" }, - ]); -}); - -it("falls back to gh's active login when the pinned account is gone", () => { - const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), { - hosts: { "github.com": { account: "former-job", enabled: true } }, - tokens: {}, - }); - assert.deepStrictEqual(auth.account, Option.some("personal")); -}); - -it("reports unauthenticated when Settings turn off every signed-in host", () => { - const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), { - hosts: { "github.com": { enabled: false } }, - tokens: {}, - }); - assert.strictEqual(auth.status, "unauthenticated"); - assert.deepStrictEqual( - auth.detail, - Option.some("Every GitHub host gh is signed in to is turned off in Settings → Source Control."), - ); -}); - -it("names the environment token that overrides the Settings choice", () => { - const auth = GitHubSourceControlProvider.parseGitHubAuth( - multiAccountStatus([ - { - state: "success", - active: false, - host: "github.com", - login: "bot", - tokenSource: "GH_TOKEN", - }, - ]), - { hosts: { "github.com": { account: "work", enabled: true } }, tokens: {} }, - ); - assert.deepStrictEqual(auth.account, Option.some("bot")); - assert.deepStrictEqual( - auth.detail, - Option.some( - "Using GH_TOKEN from the server environment; it overrides the account chosen in Settings.", - ), - ); - assert.strictEqual(auth.accounts?.[2]?.environmentVariable, "GH_TOKEN"); -}); - -it.live.each([ - "git@code.example.test:team/project.git", - "ssh://git@code.example.test/team/project.git", - "https://code.example.test/team/project.git", -])("keeps Enterprise operations on the selected remote: %s", (remoteUrl) => { - const graphql: GitHubApi.GitHubGraphQlInput[] = []; - const rest: GitHubApi.GitHubRestInput[] = []; - const api = { - graphql: (input: GitHubApi.GitHubGraphQlInput) => - Effect.sync(() => { - graphql.push(input); - const pr = { - ...node(42, "feature", "team"), - url: "https://code.example.test/team/project/pull/42", - isCrossRepository: false, - headRepository: { name: "project", nameWithOwner: "team/project" }, - }; - return encodeJson({ - data: { - repository: - input.operation === "getPullRequest" ? { pullRequest: pr } : { h0: { nodes: [pr] } }, - }, - }); - }), - rest: (input: GitHubApi.GitHubRestInput) => - Effect.sync(() => { - rest.push(input); - return restResponse({ - full_name: "team/project", - html_url: `https://${input.host}/team/project`, - ssh_url: `git@${input.host}:team/project.git`, - default_branch: "main", - }); - }), - }; - const { layer, git } = harness({ - remotes: remotesOutput( - ["origin", "git@github.com:team/project.git"], - ["enterprise", remoteUrl], - ), - api, - }); - return Effect.gen(function* () { - const provider = yield* GitHubSourceControlProvider.make; - const fs = yield* FileSystem.FileSystem; - const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); - yield* fs.writeFileString(bodyFile, "Enterprise PR"); - const input = { - cwd: "/repo", - context: { - provider: { - kind: "github" as const, - name: "GitHub Self-Hosted", - baseUrl: "https://code.example.test", - }, - remoteName: "enterprise", - remoteUrl, - }, - }; - for (const state of ["open", "closed"] as const) { - const prs = yield* provider.listChangeRequests({ - ...input, - state, - headSelector: "feature", - }); - assert.strictEqual(prs[0]?.url, "https://code.example.test/team/project/pull/42"); - } - yield* provider.getChangeRequest({ ...input, reference: "42" }); - yield* provider.getChangeRequest({ ...input, reference: "feature" }); - yield* provider.createChangeRequest({ - ...input, - baseRefName: "main", - headSelector: "feature", - title: "PR", - bodyFile, - }); - assert.strictEqual(yield* provider.getDefaultBranch(input), "main"); - yield* provider.checkoutChangeRequest({ ...input, reference: "42" }); - yield* provider.getRepositoryCloneUrls({ ...input, repository: "team/other" }); - yield* provider.getRepositoryCloneUrls({ ...input, repository: "github.com/team/explicit" }); - assert.strictEqual(graphql.length, 5); - for (const request of graphql) { - assert.strictEqual(request.host, "code.example.test"); - assert.strictEqual(request.variables?.owner, "team"); - assert.strictEqual(request.variables?.name, "project"); - } - assert.deepStrictEqual( - rest.map(({ host, path }) => [host, path]), - [ - ["code.example.test", "repos/team/project/pulls"], - ["code.example.test", "repos/team/project"], - ["code.example.test", "repos/team/other"], - ["github.com", "repos/team/explicit"], - ], - ); - assert.deepStrictEqual(git.find(([name]) => name === "fetchRemoteTrackingBranch")?.[1], { - cwd: "/repo", - remoteName: "enterprise", - remoteBranch: "feature", - }); - }).pipe(Effect.provide(layer), Effect.provideService(HostProcessEnvironment, {}), Effect.scoped); -}); - -it.effect.each([ - { host: "github.com", resolved: "", envRepository: "", expected: "acme/web" }, - { host: "code.example.test", resolved: "", envRepository: "", expected: "acme/web" }, - { - host: "github.com", - resolved: "remote.origin.gh-resolved base", - envRepository: "", - expected: "me/web", - }, - { - host: "code.example.test", - resolved: "remote.origin.gh-resolved base", - envRepository: "", - expected: "me/web", - }, - { - host: "github.com", - resolved: "remote.upstream.gh-resolved acme/other", - envRepository: "", - expected: "acme/other", - }, - { - host: "code.example.test", - resolved: "remote.upstream.gh-resolved acme/other", - envRepository: "", - expected: "acme/other", - }, - { host: "github.com", resolved: "", envRepository: "chosen/repo", expected: "chosen/repo" }, - { - host: "code.example.test", - resolved: "", - envRepository: "chosen/repo", - expected: "chosen/repo", - }, -])( - "preserves repository precedence on $host: $resolved $envRepository", - ({ host, resolved, envRepository, expected }) => { - const targets: string[] = []; - const { layer } = harness({ - remotes: remotesOutput( - ["origin", `git@${host}:me/web.git`], - ["upstream", `git@${host}:acme/web.git`], - ), - resolved, - api: { - rest: (input) => - Effect.sync(() => { - targets.push(`${input.host}/${input.path}`); - return restResponse({ - full_name: expected, - html_url: `https://${host}/${expected}`, - ssh_url: `git@${host}:${expected}.git`, - default_branch: "main", - }); - }), - }, - }); - return Effect.gen(function* () { - const provider = yield* GitHubSourceControlProvider.make; - const fs = yield* FileSystem.FileSystem; - const bodyFile = yield* fs.makeTempFileScoped(); - yield* fs.writeFileString(bodyFile, "Body"); - const input = { - cwd: "/repo", - context: { - provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` }, - remoteName: "origin", - remoteUrl: `git@${host}:me/web.git`, - }, - }; - yield* provider.getDefaultBranch(input); - yield* provider.createChangeRequest({ - ...input, - baseRefName: "main", - headSelector: "me:feature", - title: "PR", - bodyFile, - }); - assert.deepStrictEqual(targets, [ - `${host}/repos/${expected}`, - `${host}/repos/${expected}/pulls`, - ]); - }).pipe( - Effect.provide(layer), - Effect.provideService(HostProcessEnvironment, { GH_REPO: envRepository }), - Effect.scoped, - ); - }, -); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts deleted file mode 100644 index 35876794048a0eb0751faff02fc62457b1d90876..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ /dev/null @@ -1,1076 +0,0 @@ -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; -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 { - DEFAULT_SERVER_SETTINGS, - SourceControlProviderError, - TrimmedNonEmptyString, - type ChangeRequest, - type GitHubSettings, - type SourceControlProviderDiscoveryItem, - type SourceControlRepositoryCloneUrls, -} from "@t3tools/contracts"; -import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; - -import * as ServerSettings from "../serverSettings.ts"; -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as GitHubApi from "./GitHubApi.ts"; -import { - decodeGitHubPullRequestEntries, - type NormalizedGitHubPullRequestRecord, -} from "./gitHubPullRequests.ts"; -import { - gitHubApiHostForRemote, - parseFetchRemotes, - parseGitHubRepositorySelector, - parsePullRequestReference, - resolveGitHubRepository, - type GitHubRepositoryLocator, -} from "./gitHubRepositoryResolution.ts"; -import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; -import { - effectiveGitHubAccount, - findAuthenticatedGitHubAccount, - parseGitHubAuthStatus, - type GitHubAuthStatusAccount, -} from "./gitHubAuthStatus.ts"; -import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; -import { - combinedAuthOutput, - firstSafeAuthLine, - probeSourceControlProvider, - providerAuth, - type SourceControlAuthProbeInput, - type SourceControlCliDiscoverySpec, - type SourceControlManagedCliDiscoverySpec, -} from "@t3tools/source-control-core/server/discovery"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; - -const decodeLinkSubject = Schema.decodeUnknownEffect( - Schema.fromJsonString( - Schema.Struct({ title: Schema.String, body: Schema.optional(Schema.NullOr(Schema.String)) }), - ), -); - -function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest { - return { provider: "github", ...record }; -} - -function authAccounts(accounts: ReadonlyArray) { - return accounts.map((entry) => ({ - host: entry.host, - account: entry.account, - active: entry.active, - authenticated: entry.authenticated, - ...(entry.error === null ? {} : { error: entry.error }), - ...(entry.environmentVariable === null - ? {} - : { environmentVariable: entry.environmentVariable }), - })); -} - -/** - * Reads `gh auth status --json hosts`. The headline account is the one GitHub requests will - * use: Settings can pin a login per host or turn a host off, and an environment token beats both. - */ -export function parseGitHubAuth( - input: SourceControlAuthProbeInput, - settings: GitHubSettings = DEFAULT_SERVER_SETTINGS.github, -) { - const output = combinedAuthOutput(input); - const authStatus = parseGitHubAuthStatus(input.stdout); - const hosts = [...new Set(authStatus.accounts.map((entry) => entry.host))]; - const fallback = findAuthenticatedGitHubAccount(authStatus.accounts); - // Lead with the host gh would pick, unless Settings turned it off. - const orderedHosts = fallback - ? [fallback.host, ...hosts.filter((host) => host !== fallback.host)] - : hosts; - const chosen = orderedHosts - .map((host) => effectiveGitHubAccount(host, authStatus.accounts, settings)) - .find((entry) => entry !== undefined); - const accounts = authStatus.parsed ? { accounts: authAccounts(authStatus.accounts) } : {}; - - if (chosen) { - return { - ...providerAuth({ - status: "authenticated", - account: chosen.account, - host: chosen.host, - detail: - chosen.environmentVariable === null - ? undefined - : `Using ${chosen.environmentVariable} from the server environment; it overrides the account chosen in Settings.`, - }), - ...accounts, - }; - } - - if (fallback) { - return { - ...providerAuth({ - status: "unauthenticated", - host: fallback.host, - detail: "Every GitHub host gh is signed in to is turned off in Settings → Source Control.", - }), - ...accounts, - }; - } - - const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0]; - if (authStatus.parsed) { - return { - ...providerAuth({ - status: "unauthenticated", - host: failedAccount?.host, - detail: - failedAccount?.error ?? - "Run `gh auth login` to authenticate GitHub CLI with an active account.", - }), - ...accounts, - }; - } - - // gh gained `auth status --json` in 2.81.0. Older versions reject the flag and exit - // non-zero, which reads exactly like a signed-out CLI. Name the real problem instead. - if (input.exitCode !== 0 && output.includes("unknown flag: --json")) { - return providerAuth({ - status: "unknown", - detail: - "GitHub CLI is too old to report sign-in status. Update `gh` to 2.81.0 or newer (for example `brew upgrade gh`) and rescan.", - }); - } - - if (input.exitCode !== 0) { - return providerAuth({ - status: "unauthenticated", - detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", - }); - } - - return providerAuth({ - status: "unknown", - detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", - }); -} - -export const discovery = { - type: "cli", - kind: "github", - label: "GitHub", - executable: "gh", - versionArgs: ["--version"], - authArgs: ["auth", "status", "--json", "hosts"], - parseAuth: parseGitHubAuth, - installHint: - "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", -} satisfies SourceControlCliDiscoverySpec; - -const decodeViewer = Schema.decodeUnknownOption( - Schema.fromJsonString(Schema.Struct({ login: Schema.String })), -); - -/** The environment variable gh would take a github.com token from, if one is set. */ -function environmentTokenVariable(environment: NodeJS.ProcessEnv): string | null { - return ["GH_TOKEN", "GITHUB_TOKEN"].find((name) => environment[name]?.trim()) ?? null; -} - -/** - * GitHub is usable with a token saved in Settings, one from the environment, or `gh` to hand one - * over. Reads the - * GitHub settings on every probe, so a saved account choice shows on rescan. An environment - * token is checked against the API, since `gh auth status` may not know it. - */ -export const makeDiscovery = Effect.gen(function* () { - const api = yield* GitHubApi.GitHubApi; - const process = yield* VcsProcess.VcsProcess; - const environment = yield* HostProcessEnvironment; - const serverSettings = yield* ServerSettings.ServerSettingsService; - - return { - type: "managed-cli", - kind: discovery.kind, - label: discovery.label, - installHint: discovery.installHint, - probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) { - const settings = yield* serverSettings.getSettings.pipe( - Effect.map((current) => current.github), - Effect.orElseSucceed(() => DEFAULT_SERVER_SETTINGS.github), - ); - const cli = yield* probeSourceControlProvider({ - cwd, - process, - spec: { ...discovery, parseAuth: (input) => parseGitHubAuth(input, settings) }, - }); - // A token saved in Settings wins over the environment, which wins over gh. - const savedToken = (settings.tokens["github.com"] ?? "").trim() !== ""; - const variable = environmentTokenVariable(environment); - const tokenSource = savedToken ? "the token saved in Settings" : variable; - // A host turned off in Settings stays off even with a token. - if (tokenSource === null || settings.hosts["github.com"]?.enabled === false) return cli; - const viewer = yield* api - .rest({ host: "github.com", operation: "discovery", path: "user" }) - .pipe(Effect.result); - const login = Result.isSuccess(viewer) - ? Option.getOrUndefined(decodeViewer(viewer.success.body))?.login - : undefined; - // The per-host logins stay, so the account picker still lists every host gh knows. - const accounts = cli.auth.accounts === undefined ? {} : { accounts: cli.auth.accounts }; - return { - ...cli, - status: "available" as const, - auth: { - ...(login !== undefined - ? providerAuth({ - status: "authenticated", - account: login, - host: "github.com", - detail: savedToken - ? "Using the token saved in Settings; it overrides GH_TOKEN and the gh login." - : `Using ${variable} from the server environment; it overrides the account chosen in Settings.`, - }) - : Result.isFailure(viewer) && viewer.failure._tag !== "GitHubApiAuthenticationError" - ? // Only a refusal says the token is bad; a network error or a pause says nothing. - providerAuth({ - status: "unknown", - host: "github.com", - detail: `Could not check ${savedToken ? tokenSource : `the token in ${tokenSource}`}: ${viewer.failure.message}`, - }) - : providerAuth({ - status: "unauthenticated", - host: "github.com", - detail: savedToken - ? "GitHub refused the token saved in Settings. Replace or remove it in Settings → Source Control." - : `GitHub refused the token in ${tokenSource}. Replace it, or unset it to use \`gh auth login\`.`, - })), - ...accounts, - }, - } satisfies SourceControlProviderDiscoveryItem; - }), - /** - * Claims a custom host GitHub holds a credential for, from whichever source supplies it - * (Settings, the environment or gh). A host turned off in Settings is claimed too, so its - * error says so instead of "unsupported host". - */ - refineUnknownRemote: ({ context }) => { - // Identity resolution also probes providers before a web base URL is known. - const url = URL.parse(context.provider.baseUrl); - if (!url?.host) return Effect.succeed(null); - return api.credential(url.host).pipe( - Effect.as(true), - Effect.catchTags({ GitHubHostDisabledError: () => Effect.succeed(true) }), - Effect.orElseSucceed(() => false), - Effect.map((known) => - known - ? ({ - kind: "github", - name: "GitHub Self-Hosted", - baseUrl: context.provider.baseUrl, - } as const) - : null, - ), - ); - }, - } satisfies SourceControlManagedCliDiscoverySpec; -}); - -/** - * Why a GitHub read or write failed, in words a user can act on. The provider methods attach the - * operation and request context; `cause` keeps the raw failure for logs, never for transport. - */ -class GitHubFailure extends Data.TaggedError("GitHubFailure")<{ - readonly detail: string; - readonly cause: unknown; -}> {} - -const failure = (detail: string, cause?: unknown) => new GitHubFailure({ detail, cause }); - -const PULL_REQUEST_NOT_FOUND = "Pull request not found. Check the PR number or URL and try again."; -const REPOSITORY_NOT_FOUND = "Repository not found. Check the owner and name and try again."; - -/** `notFound` names what was missing, which only the operation knows. */ -function fromGitHubApiError( - error: GitHubApi.GitHubApiError, - notFound = PULL_REQUEST_NOT_FOUND, -): GitHubFailure { - switch (error._tag) { - case "GitHubCliMissingError": - return failure( - "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", - error, - ); - case "GitHubNotSignedInError": - case "GitHubHostDisabledError": - // A missing or turned-off credential already says what to do about it. - return failure(error.message, error); - case "GitHubApiAuthenticationError": - return failure( - "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.", - error, - ); - case "GitHubApiRateLimitError": - case "SourceControlRateLimitPausedError": - return failure( - "GitHub API rate limit exceeded. Requests resume when the limit resets.", - error, - ); - case "GitHubApiNotFoundError": - return failure(notFound, error); - case "GitHubCliFailedError": - case "GitHubApiResponseError": - case "GitHubApiRequestError": - // GitHub's own reason ("A pull request already exists…") when it gave one. - return failure(error.message.trim() || "GitHub request failed.", error); - } -} - -const notFound = (cause: string) => failure(PULL_REQUEST_NOT_FOUND, new Error(cause)); - -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, -): SourceControlRepositoryCloneUrls { - 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 host: string; - readonly owner: string; - readonly name: string; - readonly headRefName: string; - readonly state: PullRequestListState; - readonly limit: number; - readonly allowReserve: boolean; - }, - ReadonlyArray, - GitHubFailure -> {} - -/** 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) })), - }), - }), -); - -/** 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; -} - -/** The checkout's GitHub API host, from the remote the caller resolved the provider from. */ -const contextHost = (context: SourceControlProvider.SourceControlProviderContext | undefined) => - context === undefined ? undefined : new URL(context.provider.baseUrl).host; - -export const make = Effect.gen(function* () { - const api = yield* GitHubApi.GitHubApi; - const process = yield* VcsProcess.VcsProcess; - const environment = yield* HostProcessEnvironment; - const git = yield* GitVcsDriver.GitVcsDriver; - const fileSystem = yield* FileSystem.FileSystem; - - const gitRead = (cwd: string, args: ReadonlyArray) => - process.run({ - operation: "GitHubSourceControlProvider.resolveRepository", - command: "git", - args, - cwd, - allowNonZeroExit: true, - timeoutMs: 5_000, - }); - - /** - * Uses GH_REPO, then gh's remote selection on the detected host. The provider context is - * a fallback when the checkout's remotes cannot resolve that repository. - */ - const resolveRepository = Effect.fn("GitHubSourceControlProvider.resolveRepository")( - function* (input: { - readonly cwd: string; - readonly host?: string | undefined; - readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; - }) { - const envRepository = environment.GH_REPO?.trim(); - const hostHint = input.host ?? contextHost(input.context); - const defaultHost = (hostHint ?? 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 { host, locator } = resolveGitHubRepository({ - remotes: remotes?.exitCode === 0 ? remotes.stdout : "", - resolved: resolved?.exitCode === 0 ? resolved.stdout : "", - hostHint, - defaultHost, - }); - if (locator !== null) return locator; - if (input.context) { - const remote = normalizeGitRemoteUrl(input.context.remoteUrl); - const fallback = parseGitHubRepositorySelector( - `${host}/${remote.slice(remote.indexOf("/") + 1)}`, - host, - ); - if (fallback !== null) return fallback; - } - return yield* failure( - `No GitHub repository on ${host} was found among this checkout's git remotes.`, - ); - }, - ); - - const graphqlJson = ( - input: GitHubApi.GitHubGraphQlInput, - decode: (raw: string) => Result.Result, - decodeDetail: string, - ) => - api.graphql(input).pipe( - Effect.mapError(fromGitHubApiError), - Effect.flatMap((raw) => { - const decoded = decode(raw); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail(failure(decodeDetail, decoded.failure)); - }), - ); - - const rest = (input: GitHubApi.GitHubRestInput, notFoundDetail?: string) => - api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(error, notFoundDetail))); - - 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 { host, owner, name, allowReserve } = first.request; - const query = buildPullRequestsByHeadQuery(entries.map((entry) => entry.request)); - return graphqlJson( - { - host, - operation: "listPullRequestsByHead", - query: query.document, - variables: { owner, name, ...query.variables }, - 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, - "GitHub returned an invalid change request list.", - ).pipe( - Effect.flatMap((decoded) => - decoded.data.repository === null - ? Effect.fail(notFound("The repository could not be read.")) - : Effect.succeed(decoded.data.repository), - ), - 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), - ); - - /** - * 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. - */ - const listByHead = Effect.fn("GitHubSourceControlProvider.listByHead")(function* (input: { - readonly cwd: string; - readonly headSelector: string; - readonly state: PullRequestListState; - readonly limit: number; - readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; - readonly host?: string | undefined; - readonly allowReserve: boolean; - }) { - const locator = yield* resolveRepository(input); - 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({ - ...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 readPullRequest = Effect.fn("GitHubSourceControlProvider.readPullRequest")( - function* (input: { - readonly cwd: string; - readonly reference: string; - readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; - readonly host?: 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 lookup = (state: PullRequestListState) => - listByHead({ - cwd: input.cwd, - headSelector: parsed.headSelector, - state, - limit: 1, - host: input.host, - context: input.context, - allowReserve: true, - }); - const [open] = yield* lookup("open"); - const found = open ?? (yield* lookup("all"))[0]; - if (found === undefined) return yield* notFound("No pull request has this head branch."); - return found; - } - const locator = parsed.kind === "url" ? parsed.locator : yield* resolveRepository(input); - const decodeDetail = "GitHub returned an invalid pull request."; - const decoded = yield* graphqlJson( - { - host: locator.host, - operation: "getPullRequest", - query: PULL_REQUEST_BY_NUMBER_QUERY, - variables: { owner: locator.owner, name: locator.name, number: parsed.number }, - allowReserve: true, - }, - decodePullRequestByNumber, - decodeDetail, - ); - const node = decoded.data.repository?.pullRequest; - if (node == null) return yield* notFound("The pull request does not exist."); - const [record] = decodeGitHubPullRequestEntries([node]); - if (record === undefined) - return yield* failure(decodeDetail, new Error("Malformed pull request.")); - return record; - }, - ); - - const readRepository = Effect.fn("GitHubSourceControlProvider.readRepository")(function* ( - locator: GitHubRepositoryLocator, - ) { - const response = yield* rest( - { - host: locator.host, - operation: "getRepository", - path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, - allowReserve: true, - }, - REPOSITORY_NOT_FOUND, - ); - const decoded = decodeRawRepository(response.body); - if (Result.isFailure(decoded)) { - return yield* failure("GitHub returned an invalid repository.", decoded.failure); - } - return decoded.success; - }); - - const readViewerLogin = Effect.fn("GitHubSourceControlProvider.readViewerLogin")(function* ( - host: string, - ) { - const response = yield* rest({ - host, - operation: "getViewer", - path: "user", - allowReserve: true, - }); - const decoded = decodeViewerLogin(response.body); - if (Result.isFailure(decoded)) { - return yield* failure("GitHub request failed.", decoded.failure); - } - return decoded.success.login; - }); - - // Git and filesystem errors can carry arguments, paths and stderr, so the detail a client - // sees is fixed and the raw error stays in `cause`. - const gitFailure = (cause: unknown) => - failure("The pull request could not be checked out with git.", cause); - - const runGit = (cwd: string, operation: string, args: ReadonlyArray) => - git.execute({ - operation: `GitHubSourceControlProvider.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 = Effect.fn("GitHubSourceControlProvider.checkoutPullRequest")( - function* (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - readonly context?: SourceControlProvider.SourceControlProviderContext; - }) { - const reference = parsePullRequestReference(input.reference); - const pullRequest = yield* readPullRequest(input); - const base = reference.kind === "url" ? reference.locator : yield* resolveRepository(input); - 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), - ); - const remoteFor = (nameWithOwner: string) => - parseFetchRemotes(remotes).find( - (remote) => - (gitHubApiHostForRemote(remote.url) ?? - normalizeGitRemoteUrl(remote.url).split("/")[0]) === base.host && - normalizeGitRemoteUrl(remote.url).split("/").slice(1).join("/") === - nameWithOwner.toLowerCase(), - )?.name ?? 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(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* failure("The fork is gone."); - const known = remoteFor(headNameWithOwner); - if (known !== null) return known; - const [owner, name] = headNameWithOwner.split("/"); - const fork = yield* readRepository({ 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))).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), - ); - - 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)); - }, - ); - - /** Reports a failure against the operation and request that made it, with transport-safe context. */ - const providerError = - ( - operation: string, - cwd: string, - context?: { readonly reference?: string; readonly repository?: string }, - ) => - (error: GitHubFailure) => - new SourceControlProviderError({ - provider: "github", - operation, - cwd, - ...(context?.reference === undefined - ? {} - : { - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - context.reference, - ), - }), - ...(context?.repository === undefined - ? {} - : { - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - context.repository, - ), - }), - detail: error.detail, - cause: error.cause, - }); - - const readLinkSubject = Effect.fn("GitHubSourceControlProvider.readLinkSubject")(function* ( - input: { readonly cwd: string; readonly url: URL }, - endpoint: string, - ) { - const result = yield* api - .rest({ - host: input.url.host, - operation: "resolveLink", - path: endpoint, - maxResponseBytes: 1_000_000, - }) - .pipe( - Effect.timeout("3 seconds"), - Effect.mapError( - (cause) => - new SourceControlProviderError({ - provider: "github", - operation: "resolveLink", - cwd: input.cwd, - detail: "The linked subject could not be read.", - cause, - }), - ), - ); - const subject = yield* decodeLinkSubject(result.body).pipe( - Effect.mapError( - (cause) => - new SourceControlProviderError({ - provider: "github", - operation: "resolveLink.decode", - cwd: input.cwd, - detail: "The linked subject could not be read.", - cause, - }), - ), - ); - return { title: subject.title, body: subject.body ?? null }; - }); - - return SourceControlProvider.SourceControlProvider.of({ - kind: "github", - resolveLink: (input) => { - // Automatic enrichment must not send ambient CLI credentials to a host from message text. - if (input.url.host !== "github.com") return undefined; - const match = /^\/([\w.-]+)\/([\w.-]+)\/(?:pull|issues)\/([1-9]\d*)(?:\/.*)?$/.exec( - input.url.pathname, - ); - if (!match) return undefined; - return readLinkSubject(input, `repos/${match[1]}/${match[2]}/issues/${match[3]}`); - }, - listChangeRequests: (input) => - // An open lookup is a user waiting on a status; the rest may be a background sweep. - (input.state === "open" ? Effect.succeed(true) : GitHubApi.AllowGitHubReserve).pipe( - Effect.flatMap((allowReserve) => - listByHead({ - cwd: input.cwd, - headSelector: input.headSelector, - state: input.state, - limit: input.limit ?? (input.state === "open" ? 1 : 20), - host: contextHost(input.context), - context: input.context, - allowReserve, - }), - ), - Effect.map((records) => records.map(toChangeRequest)), - Effect.mapError( - providerError("listChangeRequests", input.cwd, { reference: input.headSelector }), - ), - ), - getChangeRequest: (input) => - readPullRequest({ ...input, host: contextHost(input.context) }).pipe( - Effect.map(toChangeRequest), - Effect.mapError( - providerError("getChangeRequest", input.cwd, { reference: input.reference }), - ), - ), - createChangeRequest: (input) => - Effect.gen(function* () { - const locator = yield* resolveRepository(input); - const body = yield* fileSystem - .readFileString(input.bodyFile) - .pipe( - Effect.mapError((cause) => - failure("The pull request description could not be read.", cause), - ), - ); - yield* rest({ - host: locator.host, - operation: "createPullRequest", - method: "POST", - // A user's own write may spend the reserve the background leaves for it. - allowReserve: true, - 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.baseRefName, - head: input.headSelector, - title: input.title, - body, - maintainer_can_modify: true, - }, - }); - }).pipe( - Effect.mapError( - providerError("createChangeRequest", input.cwd, { reference: input.headSelector }), - ), - ), - getRepositoryCloneUrls: (input) => - Effect.gen(function* () { - const fallbackHost = (yield* resolveRepository(input).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* failure("Repositories are named owner/name."); - return repositoryCloneUrls(yield* readRepository(locator)); - }).pipe( - Effect.mapError( - providerError("getRepositoryCloneUrls", input.cwd, { repository: input.repository }), - ), - ), - createRepository: (input) => - Effect.gen(function* () { - const locator = parseGitHubRepositorySelector( - input.repository, - (environment.GH_HOST ?? "github.com").toLowerCase(), - ); - const viewer = locator === null ? null : yield* readViewerLogin(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( - { - host, - operation: "createRepository", - method: "POST", - allowReserve: true, - path: - isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, - body: { name, private: input.visibility === "private" }, - }, - // GitHub answers 404 for an organization the account cannot create repositories in. - `No organization named ${owner ?? "that"} that this account can create repositories in.`, - ); - const decoded = decodeRawRepository(response.body); - if (Result.isFailure(decoded)) { - return yield* failure("GitHub returned an invalid repository.", decoded.failure); - } - return repositoryCloneUrls(decoded.success); - }).pipe( - Effect.mapError( - providerError("createRepository", input.cwd, { repository: input.repository }), - ), - ), - getDefaultBranch: (input) => - Effect.gen(function* () { - const locator = yield* resolveRepository({ - cwd: input.cwd, - host: contextHost(input.context), - context: input.context, - }); - const repository = yield* readRepository(locator); - const branch = repository.default_branch?.trim() ?? ""; - return branch.length > 0 ? branch : null; - }).pipe(Effect.mapError(providerError("getDefaultBranch", input.cwd))), - checkoutChangeRequest: (input) => - checkoutPullRequest(input).pipe( - Effect.mapError( - providerError("checkoutChangeRequest", input.cwd, { reference: input.reference }), - ), - ), - }); -}); diff --git a/apps/server/src/sourceControl/ServerSourceControlHost.ts b/apps/server/src/sourceControl/ServerSourceControlHost.ts index a21e7d5fe089ff4a567f83cf9bc1d0dc8e8ceb0a..c5c55fd85ab85abf10016ed5f75b4b6c050961af 100644 --- a/apps/server/src/sourceControl/ServerSourceControlHost.ts +++ b/apps/server/src/sourceControl/ServerSourceControlHost.ts @@ -24,6 +24,8 @@ export const layer = Layer.effect( settings: { get: serverSettings.getSettings }, process: { run: process.run }, git: { + execute: git.execute, + resolveCommit: git.resolveCommit, remotes: (cwd) => vcsRegistry.resolve({ cwd }).pipe(Effect.map((handle) => handle.driver.listRemotes(cwd))), readConfigValue: git.readConfigValue, diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 3cda7d31d9fb14a6a78385c95304b8bb1b178f5c..b4503fb7535feeef3d4c28f4b7c312ed7a67811e 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -20,7 +20,8 @@ import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/Azu import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; -import * as GitHubApi from "./GitHubApi.ts"; +import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; +import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; @@ -46,6 +47,7 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), ServerSettings.ServerSettingsService.layerTest(), + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}), Layer.mock(GitHubApi.GitHubApi)({}), Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(GitLabCli.GitLabCli)({}), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index b16ccfa44e732cfb0bfb261812a1aeaa592b005d..bfed745ca2955f839a8c92716b72e9ccf78742ed 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -18,8 +18,9 @@ import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCredentials from "./GitHubCredentials.ts"; +import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; +import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; +import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; @@ -115,6 +116,7 @@ function makeRegistry(input: { Layer.mock(BitbucketApi.BitbucketApi)({}), Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), ServerSettings.ServerSettingsService.layerTest(), + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}), Layer.mock(GitHubApi.GitHubApi)({ // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed. credential: (host) => Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 607e1b957b9bbfcce50fd90c3f51bdb2b01a4496..153e8c21b1133f3984ea1c8b3e335eb701a89c78 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -11,7 +11,6 @@ import { import type { SourceControlProviderKind } from "@t3tools/contracts"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; import * as BuiltInDrivers from "./builtInDrivers.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import { @@ -302,8 +301,6 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit ); export const make = Effect.gen(function* () { - const github = yield* GitHubSourceControlProvider.make; - const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery; const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => driver.make.pipe( Effect.map((instance): SourceControlProviderRegistration => ({ @@ -313,19 +310,7 @@ export const make = Effect.gen(function* () { })), ), ); - // Discovery lists hosts in this order, so it is kept while hosts move into packages. - const order = ["github", "gitlab", "azure-devops", "bitbucket", "forgejo"]; - const registrations: ReadonlyArray = [ - { - kind: "github", - provider: github, - discovery: githubDiscovery, - }, - ...drivers, - ]; - return yield* makeWithProviders( - registrations.toSorted((left, right) => order.indexOf(left.kind) - order.indexOf(right.kind)), - ); + return yield* makeWithProviders(drivers); }); export const layer = Layer.effect(SourceControlProviderRegistry, make); diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts index 9a972e9e5f00e0043da471c4cfd5ca457ed55d53..2e830dec803e317cb2c354333aad3d1fd293e20c 100644 --- a/apps/server/src/sourceControl/builtInDrivers.ts +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -15,6 +15,9 @@ import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/serv import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; +import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; +import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; +import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; @@ -24,6 +27,7 @@ import * as Layer from "effect/Layer"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; const drivers = [ + GitHubDriver.driver, GitLabDriver.driver, AzureDevOpsDriver.driver, BitbucketDriver.driver, @@ -34,12 +38,16 @@ const drivers = [ export type BuiltInSourceControlDriversEnv = (typeof drivers)[number] extends SourceControlDriver ? R : never; +/** Ordered as the hosts appear in discovery. */ export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray< SourceControlDriver > = drivers; /** The services the built-in drivers' packages own, plus the host port they all run against. */ export const layer = Layer.mergeAll( + // `GitHubApi.layerWithDependencies` carries the quota reserve and rate-limit pause every GitHub + // reader shares, so the server builds it once, here. + GitHubPullRequestApi.layer.pipe(Layer.provideMerge(GitHubApi.layerWithDependencies)), AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), BitbucketPullRequestApi.layer.pipe(Layer.provideMerge(BitbucketApi.layer)), ForgejoCli.layer, diff --git a/apps/server/src/sourceControl/gitHubAuthStatus.ts b/apps/server/src/sourceControl/gitHubAuthStatus.ts deleted file mode 100644 index 7321ead2d4ec1f83bba46fb207ad3d75f68cf4af..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/gitHubAuthStatus.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { GitHubSettings } from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; - -const GitHubAuthStatusAccountSchema = Schema.Struct({ - state: Schema.String, - error: Schema.optional(Schema.String), - active: Schema.Boolean, - host: Schema.String, - login: Schema.String, - tokenSource: Schema.optional(Schema.String), -}); - -const GitHubAuthStatusSchema = Schema.Struct({ - hosts: Schema.Record(Schema.String, Schema.Array(GitHubAuthStatusAccountSchema)), -}); - -const decodeGitHubAuthStatusJson = Schema.decodeUnknownOption( - Schema.fromJsonString(GitHubAuthStatusSchema), -); - -export interface GitHubAuthStatusAccount { - readonly host: string; - readonly account: string; - readonly authenticated: boolean; - readonly active: boolean; - readonly error: string | null; - /** The variable (GH_TOKEN and kin) the login came from; it overrides every stored login. */ - readonly environmentVariable: string | null; -} - -export interface GitHubAuthStatus { - readonly parsed: boolean; - readonly accounts: ReadonlyArray; -} - -const ENVIRONMENT_TOKEN_SOURCE = /^(?:GH|GITHUB)_(?:ENTERPRISE_)?TOKEN$/u; - -function nonEmptyString(value: string): string | null { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -export function parseGitHubAuthStatus(text: string): GitHubAuthStatus { - return Option.match(decodeGitHubAuthStatusJson(text), { - onNone: () => ({ parsed: false, accounts: [] }), - onSome: (status) => - ({ - parsed: true, - accounts: Object.values(status.hosts).flatMap((accounts) => - accounts.flatMap((account) => { - const host = nonEmptyString(account.host); - const login = nonEmptyString(account.login); - if (host === null || login === null) return []; - - return [ - { - host: host.toLowerCase(), - account: login, - authenticated: account.state === "success", - active: account.active, - error: account.error?.trim() || null, - environmentVariable: ENVIRONMENT_TOKEN_SOURCE.test(account.tokenSource ?? "") - ? (account.tokenSource ?? null) - : null, - }, - ]; - }), - ), - }) satisfies GitHubAuthStatus, - }); -} - -export function findAuthenticatedGitHubAccount( - accounts: ReadonlyArray, -): GitHubAuthStatusAccount | undefined { - return ( - accounts.find((account) => account.authenticated && account.active) ?? - accounts.find((account) => account.authenticated) - ); -} - -/** - * The login GitHub requests for a host will use, honoring Settings: an environment token - * first (gh's own precedence), then the pinned account, then gh's active login. - * Returns undefined when the host is turned off or has no usable login. - */ -export function effectiveGitHubAccount( - host: string, - accounts: ReadonlyArray, - settings: GitHubSettings, -): GitHubAuthStatusAccount | undefined { - const choice = settings.hosts[host]; - if (choice?.enabled === false) return undefined; - const usable = accounts.filter((account) => account.host === host && account.authenticated); - return ( - usable.find((account) => account.environmentVariable !== null) ?? - (choice?.account === undefined - ? undefined - : usable.find((account) => account.account === choice.account)) ?? - findAuthenticatedGitHubAccount(usable) - ); -} diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts deleted file mode 100644 index 6c89bee35fa28d9a209be59b2355432c8e1046ca..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ /dev/null @@ -1,145 +0,0 @@ -import * as Cause from "effect/Cause"; -import * as DateTime from "effect/DateTime"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; -import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; - -export interface NormalizedGitHubPullRequestRecord { - readonly number: number; - readonly title: string; - readonly url: string; - readonly baseRefName: string; - readonly headRefName: string; - /** The head commit, when the read asked for `headRefOid`. */ - readonly headSha?: string; - readonly state: "open" | "closed" | "merged"; - readonly isDraft?: boolean; - readonly closedAt?: string | null; - readonly mergedAt?: string | null; - readonly updatedAt: Option.Option; - readonly isCrossRepository?: boolean; - readonly headRepositoryNameWithOwner?: string | null; - readonly headRepositoryOwnerLogin?: string | null; -} - -const GitHubPullRequestSchema = Schema.Struct({ - number: PositiveInt, - title: TrimmedNonEmptyString, - url: TrimmedNonEmptyString, - baseRefName: TrimmedNonEmptyString, - headRefName: TrimmedNonEmptyString, - headRefOid: Schema.optional(Schema.NullOr(Schema.String)), - state: Schema.optional(Schema.NullOr(Schema.String)), - isDraft: Schema.optional(Schema.Boolean), - closedAt: Schema.optional(Schema.NullOr(Schema.String)), - mergedAt: Schema.optional(Schema.NullOr(Schema.String)), - updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), - isCrossRepository: Schema.optional(Schema.Boolean), - // gh < 2.47 exports headRepository as {id, name} only; nameWithOwner was - // added later. Both fields stay optional so a version-drifted gh CLI can - // never fail the decode and silently drop the PR from the list. - headRepository: Schema.optional( - Schema.NullOr( - Schema.Struct({ - nameWithOwner: Schema.optional(Schema.NullOr(Schema.String)), - name: Schema.optional(Schema.NullOr(Schema.String)), - }), - ), - ), - headRepositoryOwner: Schema.optional( - Schema.NullOr( - Schema.Struct({ - login: Schema.optional(Schema.NullOr(Schema.String)), - }), - ), - ), -}); - -function trimOptionalString(value: string | null | undefined): string | null { - const trimmed = value?.trim() ?? ""; - return trimmed.length > 0 ? trimmed : null; -} - -function normalizeGitHubPullRequestState(input: { - state?: string | null | undefined; - mergedAt?: string | null | undefined; -}): "open" | "closed" | "merged" { - const normalizedState = input.state?.trim().toUpperCase(); - if ( - (typeof input.mergedAt === "string" && input.mergedAt.trim().length > 0) || - normalizedState === "MERGED" - ) { - return "merged"; - } - if (normalizedState === "CLOSED") { - return "closed"; - } - return "open"; -} - -function normalizeGitHubPullRequestRecord( - raw: Schema.Schema.Type, -): NormalizedGitHubPullRequestRecord { - const explicitNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); - const headRepositoryName = trimOptionalString(raw.headRepository?.name); - const headRepositoryOwnerLogin = - trimOptionalString(raw.headRepositoryOwner?.login) ?? - (explicitNameWithOwner?.includes("/") ? (explicitNameWithOwner.split("/")[0] ?? null) : null); - const headRepositoryNameWithOwner = - explicitNameWithOwner ?? - (headRepositoryOwnerLogin && headRepositoryName - ? `${headRepositoryOwnerLogin}/${headRepositoryName}` - : null); - const headSha = trimOptionalString(raw.headRefOid); - - return { - number: raw.number, - title: raw.title, - url: raw.url, - baseRefName: raw.baseRefName, - headRefName: raw.headRefName, - ...(headSha ? { headSha } : {}), - state: normalizeGitHubPullRequestState(raw), - ...(raw.isDraft === true ? { isDraft: true } : {}), - closedAt: raw.closedAt ?? null, - mergedAt: raw.mergedAt ?? null, - updatedAt: raw.updatedAt ?? Option.none(), - ...(typeof raw.isCrossRepository === "boolean" - ? { isCrossRepository: raw.isCrossRepository } - : {}), - ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), - ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), - }; -} - -const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); -const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); - -/** - * Pull request rows in `gh --json` or GraphQL node shape. A row that does not decode is - * skipped, so one malformed pull request cannot hide the rest. - */ -export function decodeGitHubPullRequestEntries( - entries: ReadonlyArray, -): ReadonlyArray { - const pullRequests: NormalizedGitHubPullRequestRecord[] = []; - for (const entry of entries) { - const decodedEntry = decodeGitHubPullRequestEntry(entry); - if (Exit.isSuccess(decodedEntry)) { - pullRequests.push(normalizeGitHubPullRequestRecord(decodedEntry.value)); - } - } - return pullRequests; -} - -export function decodeGitHubPullRequestListJson( - raw: string, -): Result.Result< - ReadonlyArray, - Cause.Cause -> { - return Result.map(decodeGitHubPullRequestList(raw), decodeGitHubPullRequestEntries); -} diff --git a/apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts b/apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts deleted file mode 100644 index 3b33f0c38e40636ec2b1df0d30883f97207df971..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; - -import { - gitHubApiHostForRemote, - selectGitHubBaseRepository, -} from "./gitHubRepositoryResolution.ts"; - -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 }) => - 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("gitHubApiHostForRemote", () => { - it("reads an SSH alias through github.com and a real host as itself", () => { - assert.strictEqual(gitHubApiHostForRemote("git@github:acme/web.git"), "github.com"); - assert.strictEqual( - gitHubApiHostForRemote("git@github.example.com:a/b.git"), - "github.example.com", - ); - assert.strictEqual(gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null); - }); -}); diff --git a/apps/server/src/sourceControl/gitHubRepositoryResolution.ts b/apps/server/src/sourceControl/gitHubRepositoryResolution.ts deleted file mode 100644 index 21f68b4cdb20fd845d998ae10e0fb3f4d8f07860..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/gitHubRepositoryResolution.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; -import { - detectSourceControlProviderFromRemoteUrl, - isSshRemoteUrl, -} from "@t3tools/shared/sourceControl"; - -/** 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; -} - -/** The fetch remotes in `git remote -v` output, by name. */ -export function parseFetchRemotes( - remotes: string, -): ReadonlyArray<{ readonly name: string; readonly url: string }> { - return remotes - .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]! })); -} - -/** - * 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 remote of parseFetchRemotes(input.remotes)) { - const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/"); - if (remoteHost !== host || !owner || !name || rest.length > 0) return null; - repositories.set(remote.name, { 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; -} - -/** - * The repository `gh` would act on in a checkout, from its remotes: the remote `gh` would pick, - * else the best-ranked remote on the host. `host` is the API host it settled on, which a caller - * names when no repository was found there. GH_REPO is the caller's to check first. - */ -export function resolveGitHubRepository(input: { - readonly remotes: string; - readonly resolved: string; - /** The checkout's GitHub API host, when the caller already knows it. */ - readonly hostHint: string | undefined; - readonly defaultHost: string; -}): { readonly host: string; readonly locator: GitHubRepositoryLocator | null } { - const fetchRemotes = parseFetchRemotes(input.remotes).map((remote) => ({ - ...remote, - host: gitHubApiHostForRemote(remote.url), - })); - const host = - (input.hostHint === undefined ? undefined : apiHostForHint(input.hostHint)) ?? - fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? - fetchRemotes.find((remote) => remote.host !== null)?.host ?? - input.defaultHost; - const selected = selectGitHubBaseRepository({ - remotes: input.remotes, - resolved: input.resolved, - host, - }); - if (selected !== null) return { host, locator: { 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 || normalizeGitRemoteUrl(remote.url).split("/")[0] === 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, locator: { host, owner, name } }; - } - return { host, locator: null }; -} - -/** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ -export 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. - */ -export 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; -} diff --git a/apps/server/src/sourceControl/githubGraphQl.test.ts b/apps/server/src/sourceControl/githubGraphQl.test.ts deleted file mode 100644 index 9ab8f93544933c9a86c147d7069b8a10cd8b7409..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/githubGraphQl.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { assert, describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; - -import { aliasedGraphQlDocument, readGraphQlPages } from "./githubGraphQl.ts"; - -describe("aliasedGraphQlDocument", () => { - it("keeps every value out of the document, each under its alias's own variable", () => { - const document = aliasedGraphQlDocument({ - operation: "query", - name: "Lookups", - alias: "s", - items: ['acme") { x } #', "web"], - shared: { owner: ["String!", "acme"] }, - variables: (repo) => ({ name: ["String!", repo] }), - field: ({ name }) => `repository(owner: $owner, name: ${name}) { id }`, - }); - assert.isNotNull(document); - expect(document!.query).toBe( - "query Lookups($owner: String!, $s0_name: String!, $s1_name: String!) {\n" + - " s0: repository(owner: $owner, name: $s0_name) { id }\n" + - " s1: repository(owner: $owner, name: $s1_name) { id }\n}", - ); - expect(document!.variables).toEqual({ - owner: "acme", - s0_name: 'acme") { x } #', - s1_name: "web", - }); - }); - - it("aliases by key and nests inside a shared parent when asked", () => { - const document = aliasedGraphQlDocument({ - operation: "query", - alias: "pr", - key: (number) => number, - items: [2, 3], - variables: (number) => ({ number: ["Int!", number] }), - field: ({ number }) => `pullRequest(number: ${number}) { id }`, - within: (fields) => `repository(owner: "a", name: "b") {\n${fields}\n}`, - }); - expect(document?.query).toContain(" pr2: pullRequest(number: $pr2_number) { id }"); - expect(document?.query).toContain(" pr3: pullRequest(number: $pr3_number) { id }"); - expect(document?.variables).toEqual({ pr2_number: 2, pr3_number: 3 }); - }); - - it("asks for nothing when there is nothing to ask", () => { - expect( - aliasedGraphQlDocument({ - operation: "mutation", - alias: "f", - items: [], - variables: () => ({}), - field: () => "x", - }), - ).toBeNull(); - }); -}); - -describe("readGraphQlPages", () => { - const pages = (cursors: ReadonlyArray) => { - const asked: Array = []; - const read = (after: string | null) => - Effect.sync(() => { - asked.push(after); - return { next: cursors[asked.length - 1] ?? null }; - }); - return { asked, read }; - }; - - it.effect("follows the cursor until GitHub has no more", () => - Effect.gen(function* () { - const { asked, read } = pages(["a", "b", null]); - const result = yield* readGraphQlPages(read, { nextCursor: (page) => page.next }); - expect(asked).toEqual([null, "a", "b"]); - expect(result.truncated).toBe(false); - }), - ); - - it.effect("stops as truncated at the page limit, at `until`, or on a repeated cursor", () => - Effect.gen(function* () { - const limited = pages(["a", "b", "c"]); - const atLimit = yield* readGraphQlPages(limited.read, { - nextCursor: (page) => page.next, - maxPages: 2, - }); - expect(limited.asked).toEqual([null, "a"]); - expect(atLimit.truncated).toBe(true); - - const enough = pages(["a", "b", "c"]); - const satisfied = yield* readGraphQlPages(enough.read, { - nextCursor: (page) => page.next, - until: (read) => read.length === 1, - }); - expect(enough.asked).toEqual([null]); - expect(satisfied.truncated).toBe(true); - - const stuck = pages(["a", "a", "a"]); - const repeated = yield* readGraphQlPages(stuck.read, { - nextCursor: (page) => page.next, - from: "start", - }); - expect(stuck.asked).toEqual(["start", "a"]); - expect(repeated.truncated).toBe(true); - }), - ); -}); diff --git a/apps/server/src/sourceControl/githubGraphQl.ts b/apps/server/src/sourceControl/githubGraphQl.ts deleted file mode 100644 index 0b1e3ca65844c5c8656a153f0c07e71e583cdc95..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/githubGraphQl.ts +++ /dev/null @@ -1,100 +0,0 @@ -import * as Effect from "effect/Effect"; - -/** GraphQL variables by name, each with the type it is declared as and the value it carries. */ -export type GraphQlVariables = Readonly>; - -export interface GraphQlDocument { - readonly query: string; - readonly variables: Readonly>; -} - -/** - * One document asking the same field for many items, each under its own alias: the prefix and - * the item's position (`s0`, `s1`, …), or its `key` when the answer is easier read by one. GitHub - * has no bulk form of most lookups, so aliases are how a batch travels in one request. - * - * Every value travels as a variable renamed per alias (`$s0_owner`), and `field` is handed those - * placeholders rather than the values: nothing an item holds is written into the document. - * `shared` variables are declared once and referenced by their own name; `within` nests the aliased - * fields inside one parent selection, such as a single repository. - * - * Null for no items, since a document with no selection is not a document. - */ -export function aliasedGraphQlDocument(input: { - readonly operation: "query" | "mutation"; - /** The operation's name, which tells one batch from another in logs and recorded traffic. */ - readonly name?: string; - readonly alias: string; - readonly key?: (item: Item, index: number) => number; - readonly items: ReadonlyArray; - readonly variables: (item: Item) => Variables; - readonly field: ( - placeholders: { readonly [Name in keyof Variables]: string }, - item: Item, - ) => string; - readonly shared?: GraphQlVariables; - readonly within?: (fields: string) => string; -}): GraphQlDocument | null { - if (input.items.length === 0) return null; - const declarations: string[] = []; - const variables: Record = {}; - for (const [name, [type, value]] of Object.entries(input.shared ?? {})) { - declarations.push(`$${name}: ${type}`); - variables[name] = value; - } - const fields = input.items.map((item, index) => { - const alias = `${input.alias}${input.key?.(item, index) ?? index}`; - const placeholders: Record = {}; - for (const [name, [type, value]] of Object.entries(input.variables(item))) { - const variable = `${alias}_${name}`; - declarations.push(`$${variable}: ${type}`); - variables[variable] = value; - placeholders[name] = `$${variable}`; - } - return ` ${alias}: ${input.field(placeholders as { readonly [Name in keyof Variables]: string }, item)}`; - }); - const selection = fields.join("\n"); - const parameters = declarations.length === 0 ? "" : `(${declarations.join(", ")})`; - return { - query: `${input.operation}${input.name === undefined ? "" : ` ${input.name}`}${parameters} {\n${input.within?.(selection) ?? selection}\n}`, - variables, - }; -} - -/** - * The pages of a GraphQL connection, read one after another from `from` (the first page by - * default) until GitHub has no more, `until` is satisfied, or `maxPages` have been read. `read` is - * handed the pages so far, for reads that size or check the next page by what has already arrived. - * - * `truncated` says pages remained when reading stopped. A cursor GitHub hands back a second time - * would page forever, so it stops the read as truncated too. - */ -export const readGraphQlPages = ( - read: (after: string | null, pages: ReadonlyArray) => Effect.Effect, - options: { - readonly nextCursor: (page: NoInfer) => string | null; - readonly from?: string | null; - readonly maxPages?: number; - readonly until?: (pages: ReadonlyArray>) => boolean; - }, -): Effect.Effect<{ readonly pages: ReadonlyArray; readonly truncated: boolean }, E, R> => - Effect.gen(function* () { - const pages: Page[] = []; - const seen = new Set(); - let after = options.from ?? null; - while (true) { - const page: Page = yield* read(after, pages); - pages.push(page); - const next = options.nextCursor(page); - if (next === null) return { pages, truncated: false }; - if ( - seen.has(next) || - pages.length >= (options.maxPages ?? Infinity) || - options.until?.(pages) === true - ) { - return { pages, truncated: true }; - } - seen.add(next); - after = next; - } - }); diff --git a/apps/server/src/sourceControl/githubQuota.test.ts b/apps/server/src/sourceControl/githubQuota.test.ts deleted file mode 100644 index e9c1fac93eb04be46ceee99bcdb53dc45c477ae9..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/githubQuota.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as TestClock from "effect/testing/TestClock"; - -import * as GitHubQuota from "./githubQuota.ts"; -import { CredentialScope } from "@t3tools/source-control-core/server/SourceControlRateLimit"; - -const RESET = Date.parse("2099-08-13T14:00:00Z"); -const headers = (remaining: number, resource = "graphql", reset = RESET) => ({ - "x-ratelimit-resource": resource, - "x-ratelimit-limit": "5000", - "x-ratelimit-remaining": String(remaining), - "x-ratelimit-reset": String(reset / 1000), -}); - -describe("GitHubQuota", () => { - it.effect("refuses a background request below the reserve, per quota and account", () => - Effect.gen(function* () { - const quota = yield* GitHubQuota.GitHubQuota; - yield* quota.observe("github.com", headers(499)); - const refused = yield* Effect.flip(quota.admit("github.com", "graphql")); - assert.strictEqual(refused.retryAt, RESET); - // Another quota, another host and another account each have their own balance. - yield* quota.admit("github.com", "core"); - yield* quota.admit("ghe.example", "graphql"); - yield* quota.admit("github.com", "graphql").pipe(Effect.provideService(CredentialScope, "b")); - yield* quota.admit("github.com", "graphql", { allowReserve: true }); - }).pipe(Effect.provide(GitHubQuota.layer)), - ); - - it.effect("takes the latest balance in a window, and ignores an older window", () => - Effect.gen(function* () { - const quota = yield* GitHubQuota.GitHubQuota; - yield* quota.observe("github.com", headers(400)); - yield* Effect.flip(quota.admit("github.com", "graphql")); - // GitHub can report more left later in the same window, from another region. - yield* quota.observe("github.com", headers(900)); - yield* quota.admit("github.com", "graphql"); - yield* quota.observe("github.com", headers(400)); - yield* Effect.flip(quota.admit("github.com", "graphql")); - // An answer from an older window says nothing about this one. - yield* quota.observe("github.com", headers(4000, "graphql", RESET - 3_600_000)); - yield* Effect.flip(quota.admit("github.com", "graphql")); - // A later window replaces it. - yield* quota.observe("github.com", headers(4000, "graphql", RESET + 3_600_000)); - yield* quota.admit("github.com", "graphql"); - }).pipe(Effect.provide(GitHubQuota.layer)), - ); - - it.effect("lets interactive requests spend the reserve but not an empty quota", () => - Effect.gen(function* () { - const quota = yield* GitHubQuota.GitHubQuota; - yield* quota.observe("github.com", headers(1)); - yield* quota.admit("github.com", "graphql", { allowReserve: true }); - yield* quota.observe("github.com", headers(0)); - const refused = yield* Effect.flip( - quota.admit("github.com", "graphql", { allowReserve: true }), - ); - assert.strictEqual(refused.retryAt, RESET); - }).pipe(Effect.provide(GitHubQuota.layer)), - ); - - it.effect("spends nothing locally, so a free 304 cannot run the balance down", () => - Effect.gen(function* () { - const quota = yield* GitHubQuota.GitHubQuota; - yield* quota.observe("github.com", headers(501, "core")); - // Conditional reads answer 304 with the balance unchanged, however many are sent. - for (let index = 0; index < 5; index++) { - yield* quota.admit("github.com", "core"); - yield* quota.observe("github.com", headers(501, "core")); - } - yield* quota.admit("github.com", "core"); - }).pipe(Effect.provide(GitHubQuota.layer)), - ); - - it.effect("forgets a balance once its window resets, and ignores answers naming no quota", () => - Effect.gen(function* () { - const quota = yield* GitHubQuota.GitHubQuota; - yield* quota.observe("github.com", headers(0)); - yield* TestClock.setTime(RESET); - yield* quota.admit("github.com", "graphql"); - yield* quota.observe("other.example", { "x-ratelimit-remaining": "0" }); - yield* quota.admit("other.example", "graphql"); - }).pipe(Effect.provide(GitHubQuota.layer)), - ); -}); diff --git a/apps/server/src/sourceControl/githubQuota.ts b/apps/server/src/sourceControl/githubQuota.ts deleted file mode 100644 index 642c48fddc9a107f869c25ca8b3183a3487530c8..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/githubQuota.ts +++ /dev/null @@ -1,122 +0,0 @@ -import * as Clock from "effect/Clock"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; - -import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; - -/** The share of each quota a background read leaves for a user's next click. */ -const RESERVE_RATIO = 0.1; - -/** - * GitHub's quotas: `core` for REST, `graphql` for GraphQL, and a few smaller ones (`search`, …). - * Each is spent and reset on its own, so each is tracked on its own. - */ -type GitHubQuotaResource = string; - -interface QuotaSnapshot { - readonly limit: number; - readonly remaining: number; - readonly resetAtMs: number; -} - -/** - * What GitHub says is left of one quota, from the `x-ratelimit-*` headers every API answer - * carries. Null when the answer named no quota, which an Enterprise host with limits off does. - */ -function quotaFromHeaders( - headers: Readonly>, -): (QuotaSnapshot & { readonly resource: GitHubQuotaResource }) | null { - const resource = headers["x-ratelimit-resource"]?.trim(); - const limit = Number(headers["x-ratelimit-limit"]); - const remaining = Number(headers["x-ratelimit-remaining"]); - const reset = Number(headers["x-ratelimit-reset"]); - if ( - !resource || - !Number.isFinite(limit) || - limit <= 0 || - !Number.isFinite(remaining) || - remaining < 0 || - !Number.isFinite(reset) - ) { - return null; - } - return { resource, limit, remaining, resetAtMs: reset * 1_000 }; -} - -/** - * Keeps the last tenth of each GitHub quota for interactive requests. A background sweep that - * would dip below it is refused until the quota resets, so a user's next click still has budget. - * - * The balance is GitHub's own count from the latest answer's headers, so it already includes - * what anything else spent with the same token, such as an agent's `gh` commands. Requests in - * flight together all see that one balance; the reserve absorbs what they spend before their - * answers land. Nothing is debited locally, because a 304 or a request that never reached GitHub - * spends nothing, and a local count would drift below GitHub's for the rest of the window. - * - * Interactive requests may spend the reserve, but not a quota GitHub already reported empty. - */ -export class GitHubQuota extends Context.Service< - GitHubQuota, - { - readonly admit: ( - host: string, - resource: GitHubQuotaResource, - options?: { readonly allowReserve: boolean }, - ) => Effect.Effect; - readonly observe: ( - host: string, - headers: Readonly>, - ) => Effect.Effect; - } ->()("t3/sourceControl/githubQuota") {} - -const make = Effect.gen(function* () { - const snapshots = yield* Ref.make>(new Map()); - const keyOf = (host: string, resource: string, scope: string) => - `${host.trim().toLowerCase()}\0${resource}\0${scope}`; - - const admit: GitHubQuota["Service"]["admit"] = Effect.fn("GitHubQuota.admit")( - function* (host, resource, options) { - const now = yield* Clock.currentTimeMillis; - const key = keyOf(host, resource, yield* SourceControlRateLimit.CredentialScope); - const snapshot = (yield* Ref.get(snapshots)).get(key); - if (snapshot === undefined || snapshot.resetAtMs <= now) return; - const floor = options?.allowReserve === true ? 1 : snapshot.limit * RESERVE_RATIO; - if (snapshot.remaining >= floor) return; - return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({ - provider: "github", - host: host.trim().toLowerCase(), - retryAt: snapshot.resetAtMs, - }); - }, - ); - - const observe: GitHubQuota["Service"]["observe"] = Effect.fn("GitHubQuota.observe")( - function* (host, headers) { - const quota = quotaFromHeaders(headers); - if (quota === null) return; - const key = keyOf(host, quota.resource, yield* SourceControlRateLimit.CredentialScope); - yield* Ref.update(snapshots, (current) => { - const previous = current.get(key); - // The latest answer wins. GitHub serves requests from several regions, so a later answer - // can report more left in the same window, and keeping the lower one would hold - // background work at the reserve until the reset. An answer from an older window says - // nothing of this one. - if (previous !== undefined && quota.resetAtMs < previous.resetAtMs) return current; - const next = new Map(current); - next.set(key, { - limit: quota.limit, - remaining: quota.remaining, - resetAtMs: quota.resetAtMs, - }); - return next; - }); - }, - ); - - return GitHubQuota.of({ admit, observe }); -}); - -export const layer = Layer.effect(GitHubQuota, make); diff --git a/apps/server/src/sourceControl/githubStackRebase.test.ts b/apps/server/src/sourceControl/githubStackRebase.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..613c0016c65fdad8706b314d8ae9530d9d0bd2de --- /dev/null +++ b/apps/server/src/sourceControl/githubStackRebase.test.ts @@ -0,0 +1,173 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; + +import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; +import { cascadeRebaseStack } from "@t3tools/source-control-github/server/githubStackRebase"; + +import * as ServerSettings from "../serverSettings.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; + +const layer = Layer.mergeAll( + Layer.mock(GitHubApi.GitHubApi)({ + credential: () => Effect.succeed({ token: Redacted.make("token"), fingerprint: "fp" }), + }), + // The cascade runs real git through the server's host; the git driver itself is never asked. + ServerSourceControlHost.layer.pipe( + Layer.provide(ServerSettings.layerTest()), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({})), + Layer.provideMerge(VcsProcess.layer), + ), +).pipe(Layer.provideMerge(NodeServices.layer)); + +/** + * A bare "GitHub" holding the live-run stack: `main` moved ahead after the stack was cut, and a + * second layer sits on the first. Returns each branch's head. + */ +const setup = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const process = yield* VcsProcess.VcsProcess; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cascade-test-" }); + const remote = `${root}/remote.git`; + const work = `${root}/work`; + const git = (cwd: string, ...args: string[]) => + process + .run({ + operation: "test", + command: "git", + args, + cwd, + env: { + GIT_AUTHOR_NAME: "t", + GIT_AUTHOR_EMAIL: "t@t", + GIT_COMMITTER_NAME: "t", + GIT_COMMITTER_EMAIL: "t@t", + }, + }) + .pipe(Effect.map((out) => out.stdout.trim())); + const write = (path: string, text: string) => fs.writeFileString(`${work}/${path}`, text); + yield* git(root, "init", "--quiet", "--bare", "-b", "main", remote); + yield* git(root, "clone", "--quiet", remote, work); + yield* write("index.ts", "export const answer = 41;\n"); + yield* git(work, "add", "-A"); + yield* git(work, "commit", "--quiet", "-m", "Initial"); + yield* git(work, "switch", "--quiet", "-c", "feat/answer-42"); + yield* write("index.ts", "export const answer = 42;\n"); + yield* git(work, "commit", "--quiet", "-am", "A: set the answer to 42"); + yield* git(work, "switch", "--quiet", "-c", "feat/answer-doc"); + yield* write("index.ts", "/** The answer. */\nexport const answer = 42;\n"); + yield* git(work, "commit", "--quiet", "-am", "B: document the answer"); + yield* git(work, "switch", "--quiet", "main"); + yield* write("README.md", "main moved ahead\n"); + yield* git(work, "add", "-A"); + yield* git(work, "commit", "--quiet", "-m", "Docs"); + yield* git(work, "push", "--quiet", "origin", "main", "feat/answer-42", "feat/answer-doc"); + const head = (branch: string) => git(remote, "rev-parse", `refs/heads/${branch}`); + return { + remote, + head, + git: (...args: string[]) => git(remote, ...args), + layers: [ + { number: 1, headBranch: "feat/answer-42", headSha: yield* head("feat/answer-42") }, + { number: 2, headBranch: "feat/answer-doc", headSha: yield* head("feat/answer-doc") }, + ], + }; +}); + +it.layer(layer)("cascadeRebaseStack", (it) => { + it.effect("moves each layer's own commits onto the rebased layer below it", () => + Effect.gen(function* () { + const repo = yield* setup; + const completed = yield* cascadeRebaseStack({ + host: "github.com", + repository: "acme/web", + base: "main", + layers: repo.layers, + remote: repo.remote, + }); + assert.strictEqual(completed, 2); + + const main = yield* repo.head("main"); + const bottom = yield* repo.head("feat/answer-42"); + const top = yield* repo.head("feat/answer-doc"); + // Bottom sits on the new main; top sits on the new bottom with only its own commit. + expect(yield* repo.git("rev-parse", `${bottom}~1`)).toBe(main); + expect(yield* repo.git("rev-parse", `${top}~1`)).toBe(bottom); + expect(yield* repo.git("log", "--format=%s", `${bottom}..${top}`)).toBe( + "B: document the answer", + ); + }).pipe(Effect.scoped), + ); + + it.effect("refuses to overwrite a layer pushed after it was reviewed", () => + Effect.gen(function* () { + const repo = yield* setup; + const stale = [ + repo.layers[0]!, + // Reviewed at the bottom layer's head, but the branch is really at its own commit. + { ...repo.layers[1]!, headSha: repo.layers[0]!.headSha }, + ]; + const error = yield* Effect.flip( + cascadeRebaseStack({ + host: "github.com", + repository: "acme/web", + base: "main", + layers: stale, + remote: repo.remote, + }), + ); + expect(error).toMatchObject({ _tag: "GitHubStackRebaseGitError", number: 2, completed: 1 }); + expect(yield* repo.head("feat/answer-doc")).toBe(repo.layers[1]!.headSha); + }).pipe(Effect.scoped), + ); + + it.effect("stops at a conflicting layer and leaves it untouched", () => + Effect.gen(function* () { + const repo = yield* setup; + // Make main conflict with the bottom layer's change to the same line. + const fs = yield* FileSystem.FileSystem; + const process = yield* VcsProcess.VcsProcess; + const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cascade-conflict-" }); + const run = (...args: string[]) => + process.run({ + operation: "test", + command: "git", + args, + cwd: scratch, + env: { + GIT_AUTHOR_NAME: "t", + GIT_AUTHOR_EMAIL: "t@t", + GIT_COMMITTER_NAME: "t", + GIT_COMMITTER_EMAIL: "t@t", + }, + }); + yield* run("clone", "--quiet", repo.remote, "."); + yield* fs.writeFileString(`${scratch}/index.ts`, "export const answer = 43;\n"); + yield* run("commit", "--quiet", "-am", "Conflict"); + yield* run("push", "--quiet", "origin", "main"); + + const error = yield* Effect.flip( + cascadeRebaseStack({ + host: "github.com", + repository: "acme/web", + base: "main", + layers: repo.layers, + remote: repo.remote, + }), + ); + expect(error).toMatchObject({ + _tag: "GitHubStackRebaseConflictError", + number: 1, + completed: 0, + }); + expect(yield* repo.head("feat/answer-42")).toBe(repo.layers[0]!.headSha); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a3b239ffaa6230b22b9f12bb346aaaa93265abda..8e7a1210a507c0be96f8ad57b655bb2615435eeb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -229,7 +229,6 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; -import * as GitHubApi from "./sourceControl/GitHubApi.ts"; import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -3199,12 +3198,7 @@ export const layer = Layer.unwrap( SourceControlDiscovery.layer.pipe( Layer.provide( SourceControlProviderRegistry.layer.pipe( - Layer.provide( - Layer.mergeAll( - GitHubApi.layerWithDependencies, - SourceControlBuiltInDrivers.layer, - ), - ), + Layer.provide(SourceControlBuiltInDrivers.layer), Layer.provideMerge(GitVcsDriver.layer), Layer.provide( VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)), diff --git a/package.json b/package.json index 764f45f27c975d9992af63f15e587d6001c88243..ba161940d2f7a3117b808150405ce45666318044 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/source-control-core/src/server/SourceControlHost.ts b/packages/source-control-core/src/server/SourceControlHost.ts index 564b318d85b4283c8125bb830b33dc980d8e85ce..a46db0fccf277030e2b226810963554d00406307 100644 --- a/packages/source-control-core/src/server/SourceControlHost.ts +++ b/packages/source-control-core/src/server/SourceControlHost.ts @@ -54,6 +54,11 @@ export interface SourceControlProcessOutput { readonly stderrInvalidUtf8?: boolean; } +export type SourceControlGitOutput = Omit< + SourceControlProcessOutput, + "stdoutInvalidUtf8" | "stderrInvalidUtf8" +>; + export interface SourceControlGitRemoteBranch { readonly cwd: string; readonly remoteName: string; @@ -73,6 +78,16 @@ export class SourceControlHost extends Context.Service< ) => Effect.Effect; }; readonly git: { + /** Runs `git -C cwd …args` through the server's git driver, with its limits and metrics. */ + readonly execute: (input: { + readonly operation: string; + readonly cwd: string; + readonly args: ReadonlyArray; + }) => Effect.Effect; + readonly resolveCommit: (input: { + readonly cwd: string; + readonly revision: string; + }) => Effect.Effect<{ readonly commitSha: string }, GitCommandError>; /** * Lists the remotes of the repository at `cwd`. The outer effect resolves the repository * and fails when the server cannot drive one there; the inner one lists its remotes. diff --git a/packages/source-control-github/package.json b/packages/source-control-github/package.json new file mode 100644 index 0000000000000000000000000000000000000000..739d6e8b5f3f4d1d0d33bd66b72b7abcfaa42302 --- /dev/null +++ b/packages/source-control-github/package.json @@ -0,0 +1,85 @@ +{ + "name": "@t3tools/source-control-github", + "private": true, + "type": "module", + "exports": { + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" + }, + "./server/GitHubApi": { + "types": "./src/server/GitHubApi.ts", + "import": "./src/server/GitHubApi.ts" + }, + "./server/gitHubAuthStatus": { + "types": "./src/server/gitHubAuthStatus.ts", + "import": "./src/server/gitHubAuthStatus.ts" + }, + "./server/gitHubConditionalChecks": { + "types": "./src/server/gitHubConditionalChecks.ts", + "import": "./src/server/gitHubConditionalChecks.ts" + }, + "./server/GitHubCredentials": { + "types": "./src/server/GitHubCredentials.ts", + "import": "./src/server/GitHubCredentials.ts" + }, + "./server/githubGraphQl": { + "types": "./src/server/githubGraphQl.ts", + "import": "./src/server/githubGraphQl.ts" + }, + "./server/GitHubPullRequestApi": { + "types": "./src/server/GitHubPullRequestApi.ts", + "import": "./src/server/GitHubPullRequestApi.ts" + }, + "./server/gitHubPullRequestJson": { + "types": "./src/server/gitHubPullRequestJson.ts", + "import": "./src/server/gitHubPullRequestJson.ts" + }, + "./server/GitHubPullRequestProvider": { + "types": "./src/server/GitHubPullRequestProvider.ts", + "import": "./src/server/GitHubPullRequestProvider.ts" + }, + "./server/gitHubPullRequests": { + "types": "./src/server/gitHubPullRequests.ts", + "import": "./src/server/gitHubPullRequests.ts" + }, + "./server/GitHubQuota": { + "types": "./src/server/GitHubQuota.ts", + "import": "./src/server/GitHubQuota.ts" + }, + "./server/gitHubRepositoryResolution": { + "types": "./src/server/gitHubRepositoryResolution.ts", + "import": "./src/server/gitHubRepositoryResolution.ts" + }, + "./server/GitHubSourceControlProvider": { + "types": "./src/server/GitHubSourceControlProvider.ts", + "import": "./src/server/GitHubSourceControlProvider.ts" + }, + "./server/githubStackActions": { + "types": "./src/server/githubStackActions.ts", + "import": "./src/server/githubStackActions.ts" + }, + "./server/githubStackRebase": { + "types": "./src/server/githubStackRebase.ts", + "import": "./src/server/githubStackRebase.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "@t3tools/source-control-core": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@t3tools/source-control-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/source-control-github/src/server/GitHubApi.test.ts b/packages/source-control-github/src/server/GitHubApi.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3287ebb8e1a16219a245d0944f90df88050bb490 --- /dev/null +++ b/packages/source-control-github/src/server/GitHubApi.test.ts @@ -0,0 +1,525 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as PlatformError from "effect/PlatformError"; +import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; +import { ChildProcessSpawner } from "effect/process"; +import { VcsProcessSpawnError, VcsProcessTimeoutError } from "@t3tools/contracts"; +import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http"; + +import * as GitHubApi from "./GitHubApi.ts"; +import * as GitHubCredentials from "./GitHubCredentials.ts"; +import * as GitHubQuota from "./GitHubQuota.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +const NOW = Date.parse("2026-10-05T12:00:00.000Z"); + +function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Response) { + const requests: Array = []; + let tokens = ["first", "second"]; + let invalidations = 0; + const credentials = Layer.succeed( + GitHubCredentials.GitHubCredentials, + GitHubCredentials.GitHubCredentials.of({ + get: (host) => + Effect.sync(() => ({ + host, + token: Redacted.make(tokens[0]!), + source: "gh" as const, + fingerprint: `${host}:${tokens[0]}`, + })), + invalidate: () => + Effect.sync(() => { + invalidations++; + tokens = tokens.slice(1); + }), + }), + ); + const http = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push(request); + return Effect.succeed(HttpClientResponse.fromWeb(request, respond(request))); + }), + ); + const layer = GitHubApi.layer.pipe( + Layer.provide(Layer.mergeAll(credentials, http)), + Layer.provideMerge(GitHubQuota.layer), + Layer.provideMerge(SourceControlRateLimit.layer), + ); + return { layer, requests, invalidations: () => invalidations }; +} + +const json = (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "content-type": "application/json", ...init?.headers }, + }); + +describe("gitHubApiUrls", () => { + it("maps github.com, GHE.com and GitHub Enterprise Server", () => { + expect(GitHubApi.gitHubApiUrls("GitHub.com")).toEqual({ + rest: "https://api.github.com", + graphql: "https://api.github.com/graphql", + }); + expect(GitHubApi.gitHubApiUrls("acme.ghe.com").graphql).toBe( + "https://api.acme.ghe.com/graphql", + ); + expect(GitHubApi.gitHubApiUrls("git.acme.internal")).toEqual({ + rest: "https://git.acme.internal/api/v3", + graphql: "https://git.acme.internal/api/graphql", + }); + }); +}); + +describe("environmentToken", () => { + it("follows gh's precedence per kind of host", () => { + const env = { GH_TOKEN: "gh", GITHUB_TOKEN: "github", GH_ENTERPRISE_TOKEN: "ghe" }; + expect(GitHubCredentials.environmentToken("github.com", env)).toBe("gh"); + expect(GitHubCredentials.environmentToken("acme.ghe.com", { GITHUB_TOKEN: "github" })).toBe( + "github", + ); + // An enterprise token only goes to the host GH_HOST names, never to whatever a remote says. + expect(GitHubCredentials.environmentToken("git.acme.internal", env)).toBeNull(); + expect( + GitHubCredentials.environmentToken("git.acme.internal", { + ...env, + GH_HOST: "git.acme.internal", + }), + ).toBe("ghe"); + expect(GitHubCredentials.environmentToken("git.acme.internal", { GH_TOKEN: "gh" })).toBeNull(); + }); +}); + +describe("GitHubApi", () => { + it.effect("sends GraphQL with the token, and the document as written", () => { + const { layer, requests } = harness(() => json({ data: { viewer: { login: "julius" } } })); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const body = yield* api.graphql({ + host: "github.com", + operation: "viewer", + query: "query { viewer { login } }", + }); + expect(body).toContain('"login":"julius"'); + expect(requests[0]!.url).toBe("https://api.github.com/graphql"); + expect(requests[0]!.headers.authorization).toBe("Bearer first"); + // No `rateLimit` selection is added: the quota is read off the response headers. + const sent = requests[0]!.body; + expect(sent._tag === "Uint8Array" ? new TextDecoder().decode(sent.body) : "").toContain( + '"query":"query { viewer { login } }"', + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "traces the operation, path, query and quota, never the query string or variables", + () => { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const { layer } = harness((request) => + request.url.endsWith("/graphql") + ? json( + { data: { viewer: { login: "julius" } } }, + { headers: { "x-ratelimit-remaining": "4990", "x-ratelimit-resource": "graphql" } }, + ) + : json({ ok: true }, { headers: { "x-ratelimit-remaining": "4800" } }), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + yield* api.graphql({ + host: "github.com", + operation: "getPullRequestDetail", + query: "query($body: String!) { viewer { login } }", + variables: { body: "secret user text" }, + }); + yield* api.rest({ + host: "github.com", + operation: "listWorkflowRuns", + path: "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fx", + }); + const byName = (name: string) => spans.filter((span) => span.name === name); + const graphql = Object.fromEntries(byName("GitHubApi.graphql")[0]!.attributes); + expect(graphql).toMatchObject({ + "github.operation": "getPullRequestDetail", + "github.graphql.query": "query($body: String!) { viewer { login } }", + }); + expect(Object.fromEntries(byName("GitHubApi.send")[0]!.attributes)).toMatchObject({ + "github.ratelimit.remaining": 4990, + "github.ratelimit.resource": "graphql", + }); + expect(String(graphql["github.graphql.query_hash"])).toMatch(/^[0-9a-f]{8}$/); + const rest = Object.fromEntries(byName("GitHubApi.send")[1]!.attributes); + expect(rest).toMatchObject({ + "github.operation": "listWorkflowRuns", + "github.kind": "rest", + "url.path": "/repos/acme/web/actions/runs", + "http.response.status_code": 200, + "github.ratelimit.remaining": 4800, + }); + // Nothing recorded carries the query string, the variables, or the token. + const everything = spans + .flatMap((span) => [...span.attributes].map(([key, value]) => `${key}=${String(value)}`)) + .join("\n"); + expect(everything).not.toContain("head_sha"); + expect(everything).not.toContain("secret user text"); + expect(everything).not.toContain("first"); + expect(spans.some((span) => span.attributes.has("url.full"))).toBe(false); + }).pipe(Effect.provide(layer), Effect.withTracer(tracer)); + }, + ); + + it.effect("fails a GraphQL answer that carries errors, naming GitHub's reason", () => { + const { layer } = harness(() => + json({ data: null, errors: [{ type: "FORBIDDEN", message: "Resource not accessible" }] }), + ); + return Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const error = yield* Effect.flip( + api.graphql({ host: "github.com", operation: "detail", query: "query { viewer { id } }" }), + ); + expect(error._tag).toBe("GitHubApiResponseError"); + expect(error.message).toContain("Resource not accessible"); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "reads a NOT_FOUND answer with another error as a failure, not a missing resource", + () => { + const { layer } = harness(() => + json({ + data: null, + errors: [ + { type: "NOT_FOUND", message: "Could not resolve" }, + { message: "Something went wrong" }, + ], + }), + ); + return Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const error = yield* Effect.flip( + api.graphql({ + host: "github.com", + operation: "detail", + query: "query { viewer { id } }", + }), + ); + expect(error._tag).toBe("GitHubApiResponseError"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("pauses the host after a GraphQL RATE_LIMITED answer until the reset", () => { + const reset = Math.floor(NOW / 1000) + 600; + const { layer, requests } = harness(() => + json( + { errors: [{ type: "RATE_LIMITED", message: "API rate limit exceeded" }] }, + { headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) } }, + ), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const first = yield* Effect.flip( + api.graphql({ host: "github.com", operation: "summary", query: "query { viewer { id } }" }), + ); + expect(first).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: reset * 1000 }); + const second = yield* Effect.flip( + api.rest({ host: "github.com", operation: "stack", path: "repos/acme/web/stacks" }), + ); + expect(second).toMatchObject({ + _tag: "SourceControlRateLimitPausedError", + retryAt: reset * 1000, + }); + expect(requests).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.effect("keeps the last tenth of a quota for interactive requests until it resets", () => { + const reset = Math.floor(NOW / 1000) + 600; + const quota = (remaining: number) => ({ + "x-ratelimit-resource": "core", + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": String(remaining), + "x-ratelimit-reset": String(reset), + }); + let remaining = 499; + const { layer, requests } = harness((request) => + request.url.endsWith("/graphql") + ? json({ data: { viewer: { id: "1" } } }) + : json({ ok: true }, { headers: quota(remaining--) }), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" }; + // The first answer leaves 499 of 5000, under the last tenth; the next background read waits. + yield* api.rest(read); + const refused = yield* Effect.flip(api.rest(read)); + expect(refused).toMatchObject({ + _tag: "SourceControlRateLimitPausedError", + retryAt: reset * 1000, + }); + // A user's own request may spend the reserve, and GraphQL has a quota of its own. + yield* api.rest(read).pipe(Effect.provideService(GitHubApi.AllowGitHubReserve, true)); + yield* api.graphql({ host: "github.com", operation: "x", query: "query { viewer { id } }" }); + expect(requests).toHaveLength(3); + // The reset gives the background its quota back. + yield* TestClock.setTime(reset * 1000); + yield* api.rest(read); + expect(requests).toHaveLength(4); + }).pipe(Effect.provide(layer)); + }); + + it.effect("carries GitHub's own reason for a refused REST request", () => { + const { layer } = harness(() => + json( + { + message: "Validation Failed", + errors: [ + { + resource: "PullRequest", + code: "custom", + message: "A pull request already exists for acme:feature.", + }, + ], + }, + { status: 422 }, + ), + ); + return Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const error = yield* Effect.flip( + api.rest({ + host: "github.com", + operation: "createPullRequest", + method: "POST", + path: "repos/acme/web/pulls", + }), + ); + expect(error.message).toBe( + "GitHub returned an error: Validation Failed; A pull request already exists for acme:feature.", + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lets an interactive request through a pause a background read recorded", () => { + const reset = Math.floor(NOW / 1000) + 600; + let call = 0; + const { layer, requests } = harness(() => + ++call === 1 + ? json( + { message: "API rate limit exceeded" }, + { + status: 403, + headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) }, + }, + ) + : json({ ok: true }), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" }; + yield* Effect.flip(api.rest(read)); + expect((yield* Effect.flip(api.rest(read)))._tag).toBe("SourceControlRateLimitPausedError"); + const merged = yield* api + .rest({ + host: "github.com", + operation: "merge", + method: "PUT", + path: "repos/acme/web/pulls/7/merge", + }) + .pipe(Effect.provideService(GitHubApi.AllowGitHubReserve, true)); + expect(merged.status).toBe(200); + expect(requests).toHaveLength(2); + }).pipe(Effect.provide(layer)); + }); + + it.effect("reads an untyped GraphQL quota error as a rate limit", () => { + const reset = Math.floor(NOW / 1000) + 900; + const { layer } = harness(() => + json( + { errors: [{ message: "API rate limit already exceeded for user ID 1." }] }, + { headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) } }, + ), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const error = yield* Effect.flip( + api.graphql({ host: "github.com", operation: "lookup", query: "query { viewer { id } }" }), + ); + expect(error).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: reset * 1000 }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("maps REST 403 with an exhausted quota to a rate limit, and 304 to an answer", () => { + let call = 0; + const { layer } = harness(() => + ++call === 1 + ? new Response(null, { status: 304 }) + : json( + { message: "API rate limit exceeded" }, + { + status: 403, + headers: { "retry-after": "60" }, + }, + ), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const notModified = yield* api.rest({ + host: "github.com", + operation: "checks", + path: "repos/acme/web/pulls/7", + ifNoneMatch: '"abc"', + }); + expect(notModified.status).toBe(304); + const error = yield* Effect.flip( + api.rest({ host: "github.com", operation: "checks", path: "repos/acme/web/pulls/7" }), + ); + expect(error).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: NOW + 60_000 }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("drops a refused token so the next request asks the source again", () => { + const { layer, requests, invalidations } = harness((request) => + request.headers.authorization === "Bearer first" + ? new Response(null, { status: 401 }) + : json({ ok: true }), + ); + return Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const refused = yield* Effect.flip( + api.rest({ host: "github.com", operation: "user", path: "user" }), + ); + expect(refused._tag).toBe("GitHubApiAuthenticationError"); + expect(invalidations()).toBe(1); + const answered = yield* api.rest({ host: "github.com", operation: "user", path: "user" }); + expect(answered.status).toBe(200); + expect(requests.map((request) => request.headers.authorization)).toEqual([ + "Bearer first", + "Bearer second", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("refuses to send a pinned credential to another host", () => { + const { layer, requests } = harness(() => json({})); + return Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const error = yield* Effect.flip( + api.rest({ host: "git.acme.internal", operation: "user", path: "user" }), + ).pipe( + Effect.provideService(GitHubApi.PinnedGitHubCredential, { + host: "github.com", + token: Redacted.make("pinned"), + credentialFingerprint: "github.com:pinned", + }), + ); + expect(error._tag).toBe("GitHubApiAuthenticationError"); + expect(requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubCredentials", () => { + const credentialsWith = (run: SourceControlHost.SourceControlHost["Service"]["process"]["run"]) => + GitHubCredentials.layer.pipe( + Layer.provide(TestSourceControlHost.layer({ process: { run } })), + Layer.provide(NodeCrypto.layer), + ); + + it.effect("fails with GitHubCliMissingError when gh is not on PATH", () => + Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const error = yield* Effect.flip(credentials.get("git.acme.internal")); + expect(error._tag).toBe("GitHubCliMissingError"); + }).pipe( + Effect.provide( + credentialsWith(() => + Effect.fail( + new VcsProcessSpawnError({ + operation: "GitHubCredentials.get", + command: "gh", + cwd: "/", + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + }), + ), + ), + ), + ), + ); + + it.effect("does not cache a gh failure that is not about signing in", () => { + let calls = 0; + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const first = yield* Effect.flip(credentials.get("github.com")); + expect(first._tag).toBe("GitHubCliFailedError"); + expect((yield* credentials.get("github.com")).source).toBe("gh"); + expect(calls).toBe(2); + }).pipe( + Effect.provide( + credentialsWith(() => + ++calls === 1 + ? Effect.fail( + new VcsProcessTimeoutError({ + operation: "GitHubCredentials.get", + command: "gh", + cwd: "/", + timeoutMs: 10_000, + }), + ) + : Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "token\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + ), + ), + ); + }); + + it.effect("fails with GitHubNotSignedInError when gh prints no token", () => + Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const error = yield* Effect.flip(credentials.get("git.acme.internal")); + expect(error._tag).toBe("GitHubNotSignedInError"); + }).pipe( + Effect.provide( + credentialsWith(() => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + ), + ), + ), + ); +}); diff --git a/packages/source-control-github/src/server/GitHubApi.ts b/packages/source-control-github/src/server/GitHubApi.ts new file mode 100644 index 0000000000000000000000000000000000000000..e734e5ff07a51a69adc3e253fb656c277ac2246f --- /dev/null +++ b/packages/source-control-github/src/server/GitHubApi.ts @@ -0,0 +1,600 @@ +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http"; + +import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; +import * as GitHubCredentials from "./GitHubCredentials.ts"; +import * as GitHubQuota from "./GitHubQuota.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; + +const DEFAULT_TIMEOUT = Duration.seconds(30); +const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +/** Polite to GitHub's secondary limits, which punish bursts of concurrent requests. */ +const CONCURRENCY = 8; +const API_VERSION = "2022-11-28"; + +/** + * A credential already verified for one host. Every request made under it must target that + * host, so a page cannot read one account's data with another's token mid-flight. Server-local: + * never put its value in RPC payloads or cache keys. + */ +export const PinnedGitHubCredential = Context.Reference<{ + readonly host: string; + readonly token: Redacted.Redacted; + readonly credentialFingerprint: string; +} | null>("@t3tools/source-control-github/server/GitHubApi/PinnedGitHubCredential", { + defaultValue: () => null, +}); + +/** + * Set by interactive callers (a user's read or write, not a background sweep). Requests made + * under it may spend the GraphQL reserve and go through a rate-limit pause: a user acting on a + * pull request should not be refused because a background read exhausted the quota. + */ +export const AllowGitHubReserve = Context.Reference( + "@t3tools/source-control-github/server/GitHubApi/AllowGitHubReserve", + { defaultValue: () => false }, +); + +export class GitHubApiRequestError extends Schema.TaggedError()( + "GitHubApiRequestError", + { host: Schema.String, operation: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not reach GitHub at ${this.host}.`; + } +} + +export class GitHubApiAuthenticationError extends Schema.TaggedError()( + "GitHubApiAuthenticationError", + { host: Schema.String, operation: Schema.String }, +) { + override get message(): string { + return `GitHub refused the credential for ${this.host}. Run \`gh auth login --hostname ${this.host}\` and retry.`; + } +} + +export class GitHubApiRateLimitError extends Schema.TaggedError()( + "GitHubApiRateLimitError", + { + host: Schema.String, + operation: Schema.String, + retryAt: Schema.optionalKey(Schema.Finite), + }, +) { + override get message(): string { + return "GitHub API rate limit exceeded."; + } +} + +export class GitHubApiNotFoundError extends Schema.TaggedError()( + "GitHubApiNotFoundError", + { host: Schema.String, operation: Schema.String }, +) { + override get message(): string { + return "GitHub could not find the requested resource, or the credential cannot see it."; + } +} + +/** + * GitHub answered with a failure that is none of the above. `githubErrors` carries GitHub's own + * error messages, from a GraphQL `errors` list or a REST `message`/`errors` body: they name the + * field and the reason ("A pull request already exists for acme:feature"), never a token. + */ +export class GitHubApiResponseError extends Schema.TaggedError()( + "GitHubApiResponseError", + { + host: Schema.String, + operation: Schema.String, + status: Schema.Int, + githubErrors: Schema.optionalKey(Schema.Array(Schema.String)), + }, +) { + override get message(): string { + return this.githubErrors !== undefined && this.githubErrors.length > 0 + ? `GitHub returned an error: ${this.githubErrors.join("; ")}` + : `GitHub returned HTTP ${this.status}.`; + } +} + +export type GitHubApiError = + | GitHubCredentials.GitHubCredentialUnavailableError + | GitHubApiRequestError + | GitHubApiAuthenticationError + | GitHubApiRateLimitError + | GitHubApiNotFoundError + | GitHubApiResponseError + | SourceControlRateLimit.SourceControlRateLimitPausedError; + +export interface GitHubRestResponse { + readonly status: number; + readonly headers: Readonly>; + readonly body: string; + readonly truncated: boolean; + /** The body was not valid UTF-8, which a raw file read takes to mean binary. */ + readonly invalidUtf8: boolean; +} + +export interface GitHubRestInput { + readonly host: string; + readonly operation: string; + readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** Relative to the API root, e.g. `repos/acme/web/pulls/7`; query string included. */ + readonly path: string; + readonly body?: unknown; + /** Defaults to `application/vnd.github+json`. */ + readonly accept?: string; + /** Revalidates a cached answer. A 304 is returned rather than failed, and is free. */ + readonly ifNoneMatch?: string; + readonly maxResponseBytes?: number; + /** Defaults to 30 seconds; a whole pull request's patch may need longer. */ + readonly timeout?: Duration.Input; + /** Overrides `AllowGitHubReserve` for this one request. */ + readonly allowReserve?: boolean; +} + +export interface GitHubGraphQlInput { + readonly host: string; + readonly operation: string; + readonly query: string; + readonly variables?: Readonly>; + readonly allowReserve?: boolean; + readonly maxResponseBytes?: number; +} + +export class GitHubApi extends Context.Service< + GitHubApi, + { + /** The raw JSON body of a successful GraphQL answer, with `rateLimit` recorded. */ + readonly graphql: (input: GitHubGraphQlInput) => Effect.Effect; + readonly rest: (input: GitHubRestInput) => Effect.Effect; + /** The credential a request to `host` would carry right now. */ + readonly credential: ( + host: string, + ) => Effect.Effect< + { readonly token: Redacted.Redacted; readonly fingerprint: string }, + GitHubApiError + >; + } +>()("@t3tools/source-control-github/server/GitHubApi") {} + +function normalizeHost(host: string): string { + return host.trim().toLowerCase(); +} + +/** + * Where the API for a host lives. github.com and GHE.com data residency serve it from an `api.` + * subdomain; GitHub Enterprise Server serves it under `/api` on the instance itself. + */ +export function gitHubApiUrls(host: string): { readonly rest: string; readonly graphql: string } { + const normalized = normalizeHost(host); + if (normalized === "github.com") { + return { rest: "https://api.github.com", graphql: "https://api.github.com/graphql" }; + } + if (normalized.endsWith(".ghe.com")) { + return { + rest: `https://api.${normalized}`, + graphql: `https://api.${normalized}/graphql`, + }; + } + return { rest: `https://${normalized}/api/v3`, graphql: `https://${normalized}/api/graphql` }; +} + +/** GraphQL documents longer than this are cut in traces; the hash still identifies them. */ +const TRACED_QUERY_MAX_CHARS = 4_000; + +/** A stable short id for a GraphQL document, so traces group by query without its text. */ +function queryHash(query: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < query.length; index++) { + hash ^= query.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +/** The numeric rate-limit headers GitHub sends, as span attributes. */ +function rateLimitAttributes( + headers: Readonly>, +): Record { + const attributes: Record = {}; + for (const [header, name] of [ + ["x-ratelimit-limit", "github.ratelimit.limit"], + ["x-ratelimit-remaining", "github.ratelimit.remaining"], + ["x-ratelimit-used", "github.ratelimit.used"], + ["x-ratelimit-reset", "github.ratelimit.reset"], + ] as const) { + const value = Number(headers[header]); + if (headers[header] !== undefined && Number.isFinite(value)) attributes[name] = value; + } + const resource = headers["x-ratelimit-resource"]; + if (resource !== undefined) attributes["github.ratelimit.resource"] = resource; + return attributes; +} + +/** The pause GitHub asked for, from `retry-after` or the primary limit's reset. */ +function retryAtFrom( + headers: Readonly>, + now: number, +): number | undefined { + const fromRetryAfter = SourceControlRateLimit.retryAtFromHeader(headers["retry-after"], now); + if (fromRetryAfter !== undefined) return fromRetryAfter; + const reset = Number(headers["x-ratelimit-reset"]) * 1_000; + return Number.isFinite(reset) && reset > now ? reset : undefined; +} + +const decodeGraphQlErrors = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + errors: Schema.NonEmptyArray( + Schema.Struct({ + type: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + }), + ), + }), + ), +); + +/** A REST failure body: `{ message, errors: [{ message } | { resource, field, code }] }`. */ +const decodeRestErrors = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + message: Schema.optional(Schema.String), + errors: Schema.optional( + Schema.Array( + Schema.Union([ + Schema.String, + Schema.Struct({ + message: Schema.optional(Schema.String), + field: Schema.optional(Schema.String), + code: Schema.optional(Schema.String), + }), + ]), + ), + ), + }), + ), +); + +function restErrorMessages(body: string): ReadonlyArray | undefined { + return Option.match(decodeRestErrors(body), { + onNone: () => undefined, + onSome: (decoded) => { + const details = (decoded.errors ?? []).flatMap((error) => + typeof error === "string" + ? [error] + : error.message !== undefined + ? [error.message] + : error.field !== undefined && error.code !== undefined + ? [`${error.field} ${error.code}`] + : [], + ); + const messages = [...(decoded.message === undefined ? [] : [decoded.message]), ...details]; + return messages.length > 0 ? messages : undefined; + }, + }); +} + +/** What one GitHub answer means, decided once from its status, headers and body. */ +type Answer = Data.TaggedEnum<{ + Ok: {}; + RateLimited: {}; + Unauthorized: {}; + NotFound: {}; + Failed: { readonly messages: ReadonlyArray | undefined }; +}>; +const Answer = Data.taggedEnum(); + +/** + * GitHub reports a failed GraphQL document with HTTP 200 and an `errors` list, so a GraphQL body + * is read for its error types as well as its status. `gh api graphql` failed those too, and + * callers rely on that to fall back to narrower reads. + */ +function classify(input: { + readonly status: number; + readonly headers: Readonly>; + readonly body: string; + readonly graphql: boolean; + readonly acceptNotModified: boolean; +}): Answer { + const { status, headers, body } = input; + const errors = input.graphql + ? Option.getOrUndefined(decodeGraphQlErrors(body))?.errors + : undefined; + const types = errors?.flatMap((error) => (error.type === undefined ? [] : [error.type])) ?? []; + const messages = errors?.flatMap((error) => (error.message === undefined ? [] : [error.message])); + if ( + status === 429 || + types.includes("RATE_LIMITED") || + // An exhausted GraphQL quota can answer HTTP 200 with an untyped "API rate limit already + // exceeded" error; the headers say the same thing. + (input.graphql && errors !== undefined && headers["x-ratelimit-remaining"] === "0") || + (input.graphql && + messages !== undefined && + messages.some((message) => /rate limit (already )?exceeded/i.test(message))) || + (status === 403 && + (headers["x-ratelimit-remaining"] === "0" || + headers["retry-after"] !== undefined || + /rate limit/i.test(body))) + ) { + return Answer.RateLimited(); + } + if (status === 401) return Answer.Unauthorized(); + if ( + status === 404 || + (errors !== undefined && errors.every((error) => error.type === "NOT_FOUND")) + ) { + return Answer.NotFound(); + } + if (errors !== undefined) return Answer.Failed({ messages }); + if ((status >= 200 && status < 300) || (status === 304 && input.acceptNotModified)) { + return Answer.Ok(); + } + return Answer.Failed({ messages: input.graphql ? undefined : restErrorMessages(body) }); +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const credentials = yield* GitHubCredentials.GitHubCredentials; + const quota = yield* GitHubQuota.GitHubQuota; + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const gate = yield* Semaphore.make(CONCURRENCY); + + const credential: GitHubApi["Service"]["credential"] = Effect.fn("GitHubApi.credential")( + function* (host) { + const normalized = normalizeHost(host); + const pinned = yield* PinnedGitHubCredential; + if (pinned !== null) { + // A pinned page only ever talks to the host it verified. + if (pinned.host !== normalized) { + return yield* new GitHubApiAuthenticationError({ + host: normalized, + operation: "credential", + }); + } + return { token: pinned.token, fingerprint: pinned.credentialFingerprint }; + } + const held = yield* credentials.get(normalized); + return { token: held.token, fingerprint: held.fingerprint }; + }, + ); + + /** + * Sends one request under the host's rate-limit pause and fails a refusal with the error that + * says why. `onSuccess` sees the answer only once it is known to be one. + */ + const send = Effect.fn("GitHubApi.send")(function* (input: { + readonly host: string; + readonly operation: string; + readonly request: HttpClientRequest.HttpClientRequest; + readonly maxResponseBytes: number; + readonly timeout?: Duration.Input | undefined; + readonly allowReserve: boolean; + readonly acceptNotModified: boolean; + /** Reads the body for GraphQL `errors`, which GitHub sends with HTTP 200. */ + readonly graphql?: boolean; + }) { + const host = normalizeHost(input.host); + // Only the path: a query string can carry a SHA or a branch, and never needs to be in a trace. + yield* Effect.annotateCurrentSpan({ + "github.host": host, + "github.operation": input.operation, + "github.kind": input.graphql === true ? "graphql" : "rest", + "http.request.method": input.request.method, + "url.path": new URL(input.request.url).pathname, + }); + const { token, fingerprint } = yield* credential(host); + const scope = yield* SourceControlRateLimit.CredentialScope; + // REST spends `core` and GraphQL its own quota, each with its own reserve. Every REST path + // this reads today is `core`; a `search/` read would need its own resource here. A refusal + // still pauses the whole host, the key PullRequestService records its own backoff under. + const resource = input.graphql === true ? "graphql" : "core"; + const key = { provider: "github" as const, host }; + const run = Effect.gen(function* () { + const lease = yield* quota + .admit(host, resource, { allowReserve: input.allowReserve }) + .pipe( + Effect.andThen(limits.check(key, input.allowReserve ? { allowPaused: true } : undefined)), + ) + .pipe( + Effect.tapError((paused) => + Effect.annotateCurrentSpan({ + "github.paused": true, + "github.retry_at": paused.retryAt, + }), + ), + ); + // One deadline covers the headers and the body: a host that answers headers and then stalls + // must not hold a slot of the shared gate for undici's own five-minute body timeout. + const { response, collected } = yield* Effect.gen(function* () { + const response: HttpClientResponse.HttpClientResponse = yield* httpClient + .execute( + input.request.pipe( + HttpClientRequest.bearerToken(Redacted.value(token)), + HttpClientRequest.setHeaders({ + "x-github-api-version": API_VERSION, + "user-agent": "t3code", + }), + ), + ) + .pipe( + // `GitHubApi.send` is the request's span. The client's own span would add `url.full` + // and `url.query`, which can carry SHAs and branch names, so it is off for GitHub. + Effect.provideService(HttpClient.TracerDisabledWhen, () => true), + ); + const collected = yield* collectUint8StreamText({ + stream: response.stream, + maxBytes: input.maxResponseBytes, + }).pipe( + // 204, 304 and many refusals carry no body at all, which is an empty answer. + Effect.catchIf( + (error) => error.reason._tag === "EmptyBodyError", + () => Effect.succeed({ text: "", truncated: false, invalidUtf8: false }), + ), + ); + return { response, collected }; + }).pipe( + Effect.timeout(input.timeout ?? DEFAULT_TIMEOUT), + Effect.mapError( + (cause) => new GitHubApiRequestError({ host, operation: input.operation, cause }), + ), + ); + const headers = response.headers; + const status = response.status; + yield* quota.observe(host, headers); + yield* Effect.annotateCurrentSpan({ + "http.response.status_code": status, + ...rateLimitAttributes(headers), + }); + const context = { host, operation: input.operation }; + return yield* Answer.$match( + classify({ + status, + headers, + body: collected.text, + graphql: input.graphql === true, + acceptNotModified: input.acceptNotModified, + }), + { + Ok: () => + limits.recordSuccess({ ...key, lease }).pipe( + Effect.as({ + status, + headers, + body: collected.text, + truncated: collected.truncated, + invalidUtf8: collected.invalidUtf8, + }), + ), + RateLimited: () => + Effect.gen(function* () { + const retryAt = retryAtFrom(headers, yield* Clock.currentTimeMillis); + yield* limits.recordRateLimit({ ...key, lease, retryAt }); + yield* Effect.annotateCurrentSpan({ + "github.rate_limited": true, + ...(retryAt === undefined ? {} : { "github.retry_at": retryAt }), + }); + return yield* new GitHubApiRateLimitError({ + ...context, + ...(retryAt === undefined ? {} : { retryAt }), + }); + }), + // The source may hold a newer token than the one that was refused. + Unauthorized: () => + credentials + .invalidate(host) + .pipe(Effect.andThen(Effect.fail(new GitHubApiAuthenticationError(context)))), + NotFound: () => Effect.fail(new GitHubApiNotFoundError(context)), + Failed: ({ messages }) => + Effect.fail( + new GitHubApiResponseError({ + ...context, + status, + ...(messages === undefined ? {} : { githubErrors: messages }), + }), + ), + }, + ); + }); + // The rate-limit scope follows the credential, so a pause on one account never blocks another. + return yield* gate + .withPermit(run) + .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope || fingerprint)); + }); + + const rest: GitHubApi["Service"]["rest"] = (input) => { + const url = `${gitHubApiUrls(input.host).rest}/${input.path.replace(/^\/+/, "")}`; + const base = HttpClientRequest.make(input.method ?? "GET")(url).pipe( + HttpClientRequest.setHeader("accept", input.accept ?? "application/vnd.github+json"), + ); + const withEtag = + input.ifNoneMatch === undefined + ? base + : base.pipe(HttpClientRequest.setHeader("if-none-match", input.ifNoneMatch)); + const request = + input.body === undefined + ? withEtag + : withEtag.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)); + return AllowGitHubReserve.pipe( + Effect.flatMap((interactive) => + send({ + host: input.host, + operation: input.operation, + request, + maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, + timeout: input.timeout, + allowReserve: input.allowReserve ?? interactive, + acceptNotModified: input.ifNoneMatch !== undefined, + }), + ), + ); + }; + + const graphql: GitHubApi["Service"]["graphql"] = Effect.fn("GitHubApi.graphql")( + function* (input) { + const host = normalizeHost(input.host); + const { fingerprint } = yield* credential(host); + const scope = (yield* SourceControlRateLimit.CredentialScope) || fingerprint; + const allowReserve = input.allowReserve ?? (yield* AllowGitHubReserve); + // The document, never its variables: user text (bodies, search terms) travels as variables. + yield* Effect.annotateCurrentSpan({ + "github.operation": input.operation, + "github.graphql.query_hash": queryHash(input.query), + "github.graphql.query": + input.query.length > TRACED_QUERY_MAX_CHARS + ? `${input.query.slice(0, TRACED_QUERY_MAX_CHARS)}…` + : input.query, + }); + return yield* Effect.gen(function* () { + const response = yield* send({ + host, + operation: input.operation, + request: HttpClientRequest.post(gitHubApiUrls(host).graphql).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bodyJsonUnsafe({ + query: input.query, + variables: input.variables ?? {}, + }), + ), + maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, + allowReserve, + acceptNotModified: false, + graphql: true, + }); + // A body cut at the byte cap hides any `errors` past the cut and cannot be decoded. + if (response.truncated) { + return yield* new GitHubApiResponseError({ + host, + operation: input.operation, + status: response.status, + }); + } + return response.body; + }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope)); + }, + ); + + return GitHubApi.of({ graphql, rest, credential }); +}); + +export const layer = Layer.effect(GitHubApi, make); + +/** + * The transport with the credential, quota reserve and rate-limit pause it reads through. An + * application builds this once so every GitHub reader shares one view of each quota per host. + */ +export const layerWithDependencies = layer.pipe( + Layer.provideMerge(GitHubCredentials.layer), + Layer.provideMerge(GitHubQuota.layer), + Layer.provideMerge(SourceControlRateLimit.layer), +); diff --git a/packages/source-control-github/src/server/GitHubCredentials.test.ts b/packages/source-control-github/src/server/GitHubCredentials.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..15ac6bf648a7b481adede99eb90ce6b770902897 --- /dev/null +++ b/packages/source-control-github/src/server/GitHubCredentials.test.ts @@ -0,0 +1,182 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import { ChildProcessSpawner } from "effect/process"; + +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; +import * as GitHubCredentials from "./GitHubCredentials.ts"; + +const TOKEN_VARIABLES = [ + "GH_TOKEN", + "GITHUB_TOKEN", + "GH_ENTERPRISE_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", +] as const; + +/** Settings keep `enabled` explicit; a host the test leaves it off is enabled. */ +const hostSettings = ( + hosts: Record, +) => + Object.fromEntries( + Object.entries(hosts).map(([host, choice]) => [host, { enabled: true, ...choice }]), + ); + +function harness( + hosts: Record = {}, + signedOut: ReadonlyArray = [], + tokens: Record = {}, +) { + const calls: Array> = []; + const run: SourceControlHost.SourceControlHost["Service"]["process"]["run"] = (input) => + Effect.sync(() => { + calls.push(input.args); + const user = input.args[input.args.indexOf("--user") + 1]; + if (input.args.includes("--user") && user !== undefined && signedOut.includes(user)) { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + } + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: input.args.includes("--user") ? `token-for-${user}\n` : "active-token\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }); + const layer = GitHubCredentials.layer.pipe( + Layer.provideMerge( + TestSourceControlHost.layer({ + settings: applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + github: { hosts: hostSettings(hosts), tokens }, + }), + process: { run }, + }), + ), + Layer.provide(NodeCrypto.layer), + ); + return { layer, calls }; +} + +describe("GitHubCredentials", () => { + beforeEach(() => { + for (const name of TOKEN_VARIABLES) vi.stubEnv(name, ""); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.effect("asks gh for the active login when Settings pin nothing", () => { + const { layer, calls } = harness(); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const credential = yield* credentials.get("GitHub.com"); + expect(Redacted.value(credential.token)).toBe("active-token"); + expect(calls).toEqual([["auth", "token", "--hostname", "github.com"]]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("passes --user for an account pinned in Settings", () => { + const { layer, calls } = harness({ "github.com": { account: "work" } }); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const credential = yield* credentials.get("github.com"); + expect(Redacted.value(credential.token)).toBe("token-for-work"); + expect(calls).toEqual([["auth", "token", "--hostname", "github.com", "--user", "work"]]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails a host turned off in Settings without asking gh", () => { + const { layer, calls } = harness({ "github.com": { enabled: false } }); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const error = yield* Effect.flip(credentials.get("github.com")); + expect(error._tag).toBe("GitHubHostDisabledError"); + expect(error.message).toBe( + "GitHub host github.com is turned off in Settings → Source Control.", + ); + expect(calls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lets an environment token win over a pinned account, as gh does", () => { + vi.stubEnv("GH_TOKEN", "from-env"); + const { layer, calls } = harness({ "github.com": { account: "work" } }); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const credential = yield* credentials.get("github.com"); + expect(Redacted.value(credential.token)).toBe("from-env"); + expect(credential.source).toBe("env"); + expect(calls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("picks up a changed account on the next request without a restart", () => { + const { layer, calls } = harness(); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const settings = yield* TestSourceControlHost.TestSourceControlHostSettings; + expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); + + yield* settings.update({ + github: { hosts: { "github.com": { account: "work", enabled: true } } }, + }); + expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("token-for-work"); + + yield* settings.update({ github: { hosts: { "github.com": { enabled: false } } } }); + expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe( + "GitHubHostDisabledError", + ); + + yield* settings.update({ github: { hosts: {} } }); + expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); + // The unpinned token stayed cached; only the newly pinned account cost a gh call. + expect(calls).toHaveLength(2); + }).pipe(Effect.provide(layer)); + }); + + it.effect("falls back to the active login when the pinned one is no longer signed in", () => { + const { layer, calls } = harness({ "github.com": { account: "gone" } }, ["gone"]); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); + expect(calls).toEqual([ + ["auth", "token", "--hostname", "github.com", "--user", "gone"], + ["auth", "token", "--hostname", "github.com"], + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("uses a token saved in Settings before GH_TOKEN and gh", () => { + vi.stubEnv("GH_TOKEN", "env-token"); + const { layer, calls } = harness({}, [], { "github.com": "saved-token" }); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const credential = yield* credentials.get("github.com"); + expect(Redacted.value(credential.token)).toBe("saved-token"); + expect(credential.source).toBe("settings"); + expect(calls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("keeps a host turned off even with a token saved in Settings", () => { + const { layer } = harness({ "github.com": { enabled: false } }, [], { + "github.com": "saved-token", + }); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe( + "GitHubHostDisabledError", + ); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/packages/source-control-github/src/server/GitHubCredentials.ts b/packages/source-control-github/src/server/GitHubCredentials.ts new file mode 100644 index 0000000000000000000000000000000000000000..7483d7e02a52699667a446d604ddb4a8373c0bf7 --- /dev/null +++ b/packages/source-control-github/src/server/GitHubCredentials.ts @@ -0,0 +1,261 @@ +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Hex from "effect/encoding/Hex"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; + +import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; + +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; + +/** How long a token is reused before `gh` is asked again, so a `gh auth switch` applies soon. */ +const TOKEN_TTL = Duration.minutes(5); +/** No credential is retried sooner, so a fresh `gh auth login` takes effect on the next read. */ +const MISSING_TTL = Duration.seconds(10); + +export const GitHubCredentialSource = Schema.Literals(["settings", "env", "gh"]); +export type GitHubCredentialSource = typeof GitHubCredentialSource.Type; + +export interface GitHubCredential { + readonly host: string; + readonly token: Redacted.Redacted; + readonly source: GitHubCredentialSource; + /** A digest of host and token: safe for cache keys and rate-limit scopes, never the token. */ + readonly fingerprint: string; +} + +/** Nothing in the environment, and no `gh` on PATH to ask. */ +export class GitHubCliMissingError extends Schema.TaggedError()( + "GitHubCliMissingError", + { host: Schema.String }, +) { + override get message(): string { + return `No GitHub credential for ${this.host}: set GH_TOKEN, or install the GitHub CLI and run \`gh auth login\`.`; + } +} + +/** `gh` is installed but holds no login for the host, or not the account chosen in Settings. */ +export class GitHubNotSignedInError extends Schema.TaggedError()( + "GitHubNotSignedInError", + { host: Schema.String, account: Schema.optional(Schema.String) }, +) { + override get message(): string { + return this.account === undefined + ? `No GitHub credential for ${this.host}: run \`gh auth login --hostname ${this.host}\`.` + : `No GitHub credential for ${this.account} on ${this.host}: run \`gh auth login --hostname ${this.host}\` for that account or pick another in Settings → Source Control.`; + } +} + +/** The user turned the host off in Settings. */ +export class GitHubHostDisabledError extends Schema.TaggedError()( + "GitHubHostDisabledError", + { host: Schema.String }, +) { + override get message(): string { + return `GitHub host ${this.host} is turned off in Settings → Source Control.`; + } +} + +/** `gh auth token` timed out or failed for a reason other than having no login. */ +export class GitHubCliFailedError extends Schema.TaggedError()( + "GitHubCliFailedError", + { host: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `The GitHub CLI could not hand over a credential for ${this.host}. Check \`gh auth status\` on the server.`; + } +} + +/** There is no token for the host. */ +export type GitHubCredentialUnavailableError = + | GitHubCliMissingError + | GitHubNotSignedInError + | GitHubHostDisabledError + | GitHubCliFailedError; + +/** + * Where GitHub tokens come from. Callers ask per host and never see how the token was found, + * so another source (an in-app OAuth login) slots in here without touching any of them. + */ +export class GitHubCredentials extends Context.Service< + GitHubCredentials, + { + readonly get: ( + host: string, + ) => Effect.Effect; + /** Drops the held token after GitHub refused it, so the next read asks its source again. */ + readonly invalidate: (host: string) => Effect.Effect; + } +>()("@t3tools/source-control-github/server/GitHubCredentials") {} + +function normalizeHost(host: string): string { + return host.trim().toLowerCase(); +} + +/** Hosts gh treats as GitHub.com-like for `GH_TOKEN`: github.com and GHE.com data residency. */ +function isGitHubDotCom(host: string): boolean { + return host === "github.com" || host.endsWith(".ghe.com"); +} + +/** + * The environment token for a host, in gh's precedence order. gh hands `GH_ENTERPRISE_TOKEN` to + * any non-github.com host; here it only goes to the host `GH_HOST` names, because a remote URL + * picks the host and a hostile one must not receive an enterprise token. + */ +export function environmentToken( + host: string, + env: Readonly>, +): string | null { + const names = isGitHubDotCom(host) + ? ["GH_TOKEN", "GITHUB_TOKEN"] + : env.GH_HOST?.trim().toLowerCase() === host + ? ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"] + : []; + for (const name of names) { + const value = env[name]?.trim(); + if (value) return value; + } + return null; +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const sourceControlHost = yield* SourceControlHost.SourceControlHost; + const process = sourceControlHost.process; + const crypto = yield* Crypto.Crypto; + const environment = yield* HostProcessEnvironment; + const workingDirectory = yield* HostProcessWorkingDirectory; + + /** `host:sha256(token)`, safe for cache keys and rate-limit scopes. */ + const fingerprintOf = (host: string, token: string) => + crypto.digest("SHA-256", new TextEncoder().encode(token)).pipe( + Effect.map((digest) => `${host}:${Hex.encode(digest)}`), + // Hashing a string in memory has no platform failure worth a typed error. + Effect.orDie, + ); + + const fromGh = (host: string, account: string | undefined) => + process + .run({ + operation: "GitHubCredentials.get", + command: "gh", + args: [ + "auth", + "token", + "--hostname", + host, + ...(account === undefined ? [] : ["--user", account]), + ], + cwd: workingDirectory, + // Never let gh print the token into a debug log. + env: { GH_DEBUG: "", GH_PROMPT_DISABLED: "1" }, + timeoutMs: 10_000, + }) + .pipe( + Effect.mapError((error) => + error._tag === "VcsProcessSpawnError" && + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" + ? new GitHubCliMissingError({ host }) + : // gh exits non-zero with "no oauth token" when it has no login for the host. + error._tag === "VcsProcessExitError" + ? new GitHubNotSignedInError({ host, ...(account === undefined ? {} : { account }) }) + : new GitHubCliFailedError({ host, cause: error }), + ), + Effect.map((output) => output.stdout.trim()), + Effect.filterOrFail( + (token) => token !== "", + () => new GitHubNotSignedInError({ host, ...(account === undefined ? {} : { account }) }), + ), + ); + + /** The Settings choice for a host; unreadable settings fall back to gh's own choice. */ + const hostChoice = (host: string) => + sourceControlHost.settings.get.pipe( + Effect.map((settings) => settings.github.hosts[host]), + Effect.orElseSucceed(() => undefined), + ); + + /** A token saved in Settings for the host, read fresh so a saved or removed one applies at once. */ + const savedToken = (host: string) => + sourceControlHost.settings.get.pipe( + Effect.map((settings) => settings.github.tokens[host]?.trim() || null), + Effect.orElseSucceed(() => null), + ); + + /** Cache key: the host plus its pinned account, so a changed pin misses the cache. */ + const cacheKey = (host: string, account: string | undefined) => + account === undefined ? host : `${host}\u0000${account}`; + + const lookup = Effect.fn("GitHubCredentials.lookup")(function* (key: string) { + const [host = key, choice] = key.split("\u0000"); + // An environment token wins over a pinned account, exactly as it does in gh. + const fromEnv = environmentToken(host, environment); + // A pinned login gh no longer holds (logged out, expired) falls back to the active one, + // which is what discovery reports as the account in use. + const token = + fromEnv ?? + (yield* fromGh(host, choice).pipe( + Effect.catchTags({ + GitHubNotSignedInError: (error) => + choice === undefined ? Effect.fail(error) : fromGh(host, undefined), + }), + )); + return { + host, + token: Redacted.make(token), + source: fromEnv !== null ? "env" : "gh", + fingerprint: yield* fingerprintOf(host, token), + } satisfies GitHubCredential; + }); + + const cache = yield* Cache.makeWith(lookup, { + capacity: 32, + // A transient gh failure (a timeout, a locked keyring) is asked again on the next read. + timeToLive: (exit) => + Exit.isSuccess(exit) + ? TOKEN_TTL + : Exit.findErrorOption(exit).pipe( + Option.exists((error) => error._tag === "GitHubCliFailedError"), + ) + ? Duration.zero + : MISSING_TTL, + }); + + return GitHubCredentials.of({ + get: Effect.fn("GitHubCredentials.get")(function* (rawHost) { + const host = normalizeHost(rawHost); + const choice = yield* hostChoice(host); + if (choice?.enabled === false) { + return yield* new GitHubHostDisabledError({ host }); + } + // A token saved in Settings is the most deliberate choice, so it comes before the + // environment and gh. It is read from the secret store each time, so it needs no cache. + const saved = yield* savedToken(host); + if (saved !== null) { + return { + host, + token: Redacted.make(saved), + source: "settings", + fingerprint: yield* fingerprintOf(host, saved), + } satisfies GitHubCredential; + } + return yield* Cache.get(cache, cacheKey(host, choice?.account)); + }), + invalidate: (rawHost) => { + const host = normalizeHost(rawHost); + return hostChoice(host).pipe( + Effect.flatMap((choice) => Cache.invalidate(cache, cacheKey(host, choice?.account))), + ); + }, + }); +}); + +export const layer = Layer.effect(GitHubCredentials, make); diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..badb7967758560f95781e50f05e3ac04343a3bb6 --- /dev/null +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -0,0 +1,4589 @@ +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 "./GitHubApi.ts"; +import * as GitHubApi from "./GitHubApi.ts"; +import * as GitHubCredentials from "./GitHubCredentials.ts"; +import * as GitHubQuota from "./GitHubQuota.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; +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 quota from each + * answer's headers, the way GitHubApi does, so the reserve behaviour is still the module's to prove. + */ +const mockApi = Layer.effect( + GitHubApi.GitHubApi, + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + return GitHubApi.GitHubApi.of({ + graphql: (input) => + GitHubApi.AllowGitHubReserve.pipe( + Effect.flatMap((interactive) => + quota.admit(input.host, "graphql", { + allowReserve: input.allowReserve ?? interactive, + }), + ), + Effect.andThen(() => + (input.query.includes("query PullRequestStackMemberships") + ? mockedStackMemberships + : mockedExecute)({ kind: "graphql", ...input }), + ), + Effect.tap((answer) => quota.observe(input.host, answer.headers)), + Effect.map((answer) => answer.body), + ), + rest: (input) => mockedExecute({ kind: "rest", ...input }), + credential: (host) => mockedCredential(host), + }); + }), +); + +const layer = it.layer( + GitHubPullRequestApi.layer.pipe( + Layer.provideMerge(mockApi), + Layer.provideMerge(GitHubQuota.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide(TestSourceControlHost.layer()), + Layer.provide(NodeServices.layer), + ), +); + +/** A successful answer; GraphQL reads take its body. */ +function output( + body: string, + truncated = false, + invalidUtf8 = false, + headers: Readonly> = {}, +): GitHubApi.GitHubRestResponse { + return { status: 200, headers, body, truncated, invalidUtf8 }; +} + +/** GitHub's GraphQL quota headers, leaving `remaining` of 5,000 until `resetAt`. */ +function graphqlQuota(remaining: number, resetAt = "2099-08-13T14:00:00Z") { + return { + "x-ratelimit-resource": "graphql", + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": String(remaining), + "x-ratelimit-reset": String(Date.parse(resetAt) / 1000), + }; +} + +/** + * 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(GitHubQuota.layer), + Layer.provide(SourceControlRateLimit.layer), + Layer.merge(TestSourceControlHost.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("holds background previews once an answer reports the reserve, until it resets", () => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const quota = yield* GitHubQuota.GitHubQuota; + const resetAt = "2099-08-13T14:00:00Z"; + yield* quota.observe("preview-budget.example", graphqlQuota(501, resetAt)); + // GitHub's count, not ours: the answer below reports the reserve has been reached. + 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, + }, + }, + }, + }), + false, + false, + graphqlQuota(499, resetAt), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const input = { + cwd: "/w", + repository: "acme/web", + host: "preview-budget.example", + number: 7, + }; + yield* cli.getPullRequestPreview(input); + const held = yield* Effect.flip(cli.getPullRequestPreview({ ...input, number: 8 })); + expect(held._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(); + expect(queryAt(0)).toContain( + "s0: repository(owner: $s0_owner, name: $s0_name) { pullRequest(number: $s0_number)", + ); + expect(varsAt(0)).toMatchObject({ + s0_owner: "acme", + s0_name: "web", + s0_number: 7, + s1_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(); + expect(queryAt(0)).toContain( + "w1: repository(owner: $w1_owner, name: $w1_name) { pullRequest(number: $w1_number)", + ); + expect(varsAt(0)).toMatchObject({ w1_owner: "acme", w1_name: "web", w1_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("reports truncation when GitHub repeats a cursor before the page is full", () => + Effect.gen(function* () { + // Two pages of 100 that hand back the same cursor, for a page of 250. + const repeating = output( + encodeJson({ + data: { + search: { + pageInfo: { hasNextPage: true, endCursor: "same" }, + nodes: rows(100, 1), + }, + }, + }), + ); + mockedExecute.mockReturnValue(Effect.succeed(repeating)); + 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: 250, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 2); + 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 numbers = Object.entries( + membership?.kind === "graphql" ? (membership.variables ?? {}) : {}, + ) + .filter(([name]) => name.endsWith("_number")) + .map(([, number]) => number); + expect(numbers).toEqual([4, 5]); + }), + ); + + 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" && Object.values(input.variables ?? {}).includes(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 }, + ]); + expect(queryAt(0)).toContain("s0: repository(owner: $s0_owner, name: $s0_name)"); + expect(varsAt(0)).toMatchObject({ s0_owner: "acme", s0_name: "web", s24_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 the head list stops before its end", () => + Effect.gen(function* () { + // GitHub hands the same cursor back, so the second page is never read past. #7 looks + // unique on what was read, and an unread page could still hold another head like it. + const repeating = heads([7]); + repeating.data.repository.pullRequests.pageInfo = { + hasNextPage: true, + endCursor: "same", + } as never; + workflowApprovalRoutes(() => crossRepositoryDetail(), repeating, 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-list-truncated", + }); + }), + ); + + 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("looks up avatars by node id in one GraphQL read", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }], + }, + }), + ), + ), + ); + 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(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), false, false, graphqlQuota(499))), + ); + 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 }, + }, + }, + }), + false, + false, + graphqlQuota(499), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const input = { + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + } as const; + + yield* cli.getViewerAccess(input); + + 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 }, + }, + }, + }), + false, + false, + graphqlQuota(499), + ), + ), + ) + .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, + }); + + // The same cursor twice is a page GitHub already gave, so reading stops there. + assert.strictEqual(mockedExecute.mock.calls.length, 2); + assert.isTrue(viewed.truncated); + }), + ); + + 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", + f0_path: "src/a.ts", + f1_path: "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/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts new file mode 100644 index 0000000000000000000000000000000000000000..ceff9c6c0b5c341278414e74b86a82d50d106ccf --- /dev/null +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -0,0 +1,2857 @@ +import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage"; +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 * as GitHubApi from "./GitHubApi.ts"; +import { readGraphQlPages } from "./githubGraphQl.ts"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +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, + 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 "@t3tools/source-control-core/server/PullRequestProvider"; + +/** + * 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; + } +>()("@t3tools/source-control-github/server/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 sourceControlHost = yield* SourceControlHost.SourceControlHost; + 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. + */ + type CheckContextsPage = Result.Result.Success< + ReturnType + >; + const readAllCheckContexts = ( + input: Parameters[0], + allowReserve: boolean, + ) => + Effect.gen(function* () { + const { owner, name } = parseRepositorySelector(input.repository); + const { pages, truncated } = yield* readGraphQlPages( + (after, previous: ReadonlyArray) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestDetail", + allowReserve, + variables: { owner, name, number: input.number, after }, + query: pullRequestCheckContextsGraphQlQuery(input.host), + decode: decodePullRequestCheckContextsJson, + }).pipe( + // Each page names the head it read, so a push mid-walk fails rather than mixing + // two revisions' checks. + Effect.filterOrFail( + (page) => previous.length === 0 || page.headSha === previous[0]!.headSha, + () => + readError( + input.cwd, + "getPullRequestDetail", + new Error("Pull request head changed while reading checks."), + ), + ), + ), + { maxPages: CHECK_CONTEXT_PAGES, nextCursor: (page) => page.nextCursor }, + ); + return { + headSha: pages[0]?.headSha ?? null, + contexts: pages.flatMap((page) => page.contexts), + truncated, + }; + }); + + const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return GitHubApi.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. + * `truncated` says some were never read, which is never enough to call a head unique. + */ + 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 countHeads = (pages: ReadonlyArray<{ readonly heads: ReadonlyArray }>) => + pages.reduce((total, page) => total + page.heads.length, 0); + const { pages, truncated } = yield* readGraphQlPages( + (after) => + 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, + }), + { + nextCursor: (page) => page.nextCursor, + until: (pages) => countHeads(pages) > workflowApprovalLimit, + }, + ); + return { heads: pages.flatMap((page) => page.heads), truncated }; + }); + + /** + * 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, + truncated, + }): Effect.Effect< + GitHubPullRequestHead, + GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError + > => { + const exactHeads = heads.filter( + (pullRequest) => + pullRequest.headSha === input.headSha && + pullRequest.isCrossRepository === true && + pullRequest.headRepositoryOwner?.toLowerCase() === + input.headRepositoryOwner.toLowerCase(), + ); + if (truncated || 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 document = buildPullRequestSummariesGraphQlQuery( + batchable.map((entry) => entry.request), + first.request.host === "github.com", + ); + const batched = + document === null + ? Effect.succeed(new Map()) + : graphqlRead({ + cwd: first.request.cwd, + host: first.request.host, + operation: "getPullRequestSummary", + ...document, + 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 document = buildPullRequestWatchFingerprintsGraphQlQuery( + batchable.map((entry) => entry.request), + ); + const read = + document === null + ? Effect.succeed(new Map()) + : graphqlRead({ + cwd: first.request.cwd, + host: first.request.host, + operation: "getPullRequestWatchFingerprint", + ...document, + 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 countRows = (pages: ReadonlyArray) => + pages.reduce((total, read) => total + read.rawCount, 0); + const { pages, truncated } = yield* readGraphQlPages( + (after, pages: ReadonlyArray) => + page(after, rows - countRows(pages)), + { + nextCursor: (read) => read.endCursor, + until: (pages) => countRows(pages) >= rows, + }, + ); + // `truncated` also covers a cursor GitHub repeated before `rows` arrived. + return { + items: pages.flatMap((read) => read.items), + rawCount: countRows(pages), + truncated, + }; + }); + 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, truncated: cutShort }) => { + 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: + cutShort || + (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 document = buildPullRequestStackMembershipsGraphQlQuery( + input.repository, + chunk.map((item) => item.number), + ); + if (document === null) return Effect.succeed(chunk); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listPullRequestStackMemberships", + ...document, + 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 document = buildPullRequestStatsGraphQlQuery(chunk); + if (document === null) { + return Effect.fail( + new GitHubRepositorySelectorError({ + cwd: input.cwd, + operation: "listPullRequestStats", + }), + ); + } + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listPullRequestStats", + ...document, + 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. + if (dismissalCursor !== null) { + const { pages } = yield* readGraphQlPages( + (cursor) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listReviewThreadComments", + variables: { owner, name, number: input.number, cursor }, + query: REVIEW_DISMISSALS_GRAPHQL_QUERY, + decode: decodeReviewDismissalsJson, + }), + { + from: dismissalCursor, + maxPages: REVIEW_THREAD_PAGES, + nextCursor: (read) => read.nextCursor, + }, + ); + for (const read of pages) + for (const [id, message] of read.dismissalsByReviewId) + dismissalsByReviewId.set(id, message); + } + + 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(SourceControlHost.SourceControlHost, sourceControlHost), + 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); + return readGraphQlPages( + (after) => + 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, + }), + { maxPages: FILES_VIEWED_MAX_PAGES, nextCursor: (page) => page.nextCursor }, + ).pipe( + Effect.map(({ pages, truncated }) => ({ + files: pages.flatMap((page) => page.files), + truncated, + })), + ); + }, + + setPullRequestFilesViewed: (input) => { + if (input.files.length === 0) return Effect.void; + return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe( + Effect.flatMap((pullRequestId) => { + const mutation = buildSetFilesViewedGraphQlMutation(pullRequestId, input.files); + return mutation === null + ? Effect.void + : graphql({ host: input.host, operation: "setPullRequestFilesViewed", ...mutation }); + }), + ); + }, + + 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/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..98e8aeb5c15b4789361e81f935246e9c09be251b --- /dev/null +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts @@ -0,0 +1,1067 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; +import type { PullRequestReaction } from "@t3tools/contracts"; + +import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; +import * as GitHubApi from "./GitHubApi.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"; + +const coreFields = { + checksTruncated: false, + comparison: { behindBy: 0, viewerCanUpdate: true }, + viewerAccess: { + canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }, +}; + +it.effect("maps credential verification failures without relabeling operation failures", () => + Effect.gen(function* () { + let verificationFails = true; + let operations = 0; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + revalidateChecks: (_input, read) => read, + withVerifiedCredential: (_input, use) => + verificationFails + ? Effect.fail( + new GitHubPullRequestApi.GitHubViewerLoginUnavailableError({ + cwd: "/w", + }), + ) + : use({ accountId: "123", viewer: "viewer", credentialFingerprint: "fingerprint" }), + }), + ), + ); + const verify = provider.withVerifiedCredential; + if (verify === undefined) + return yield* Effect.die("credential verification was not implemented"); + const input = { cwd: "/w", host: "github.com" }; + const operation = () => + Effect.sync(() => { + operations++; + }).pipe(Effect.andThen(Effect.fail("operation-failed"))); + expect(yield* verify(input, operation).pipe(Effect.flip)).toMatchObject({ + _tag: "PullRequestProviderError", + operation: "routeIdentity", + }); + expect(operations).toBe(0); + verificationFails = false; + expect(yield* verify(input, operation).pipe(Effect.flip)).toBe("operation-failed"); + expect(operations).toBe(1); + }), +); + +it.effect("refreshes checks without permissions or comparison reads", () => + Effect.gen(function* () { + let reads = 0; + const snapshot = Result.getOrThrow( + decodePullRequestDetailJson(`{ + "number": 7, "title": "Checks", "url": "https://github.com/acme/web/pull/7", + "headRefName": "feature", "baseRefName": "main", "state": "OPEN", + "createdAt": "2026-07-01T00:00:00Z", "updatedAt": "2026-07-01T00:00:00Z" + }`), + ); + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + revalidateChecks: (_input, read) => read, + getPullRequestDetail: () => + Effect.sync(() => { + reads++; + return { + ...snapshot, + ...coreFields, + state: reads === 3 ? ("merged" as const) : ("open" as const), + checks: [ + { + name: "build", + status: reads === 1 ? ("pending" as const) : ("success" as const), + description: null, + url: null, + }, + ], + }; + }), + }), + ), + ); + const read = provider.getChangeRequestChecks; + if (read === undefined) return yield* Effect.die("checks read missing"); + for (let tick = 1; tick <= 3; tick++) { + const result = yield* read({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + expect(result.checks[0]?.status).toBe(tick === 1 ? "pending" : "success"); + expect(result.state).toBe(tick === 3 ? "merged" : "open"); + } + expect(reads).toBe(3); + }), +); + +it.effect("uses one narrow read for a linked pull request summary", () => + Effect.gen(function* () { + let summaryReads = 0; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + revalidateChecks: (_input, read) => read, + getPullRequestSummary: () => + Effect.sync(() => { + summaryReads += 1; + return { + number: 7, + title: "Summary", + url: "https://github.com/acme/web/pull/7", + headBranch: "feat/summary", + baseBranch: "main", + state: "open" as const, + updatedAt: "2026-08-24T12:34:56.000Z", + author: { login: "octocat", name: null, avatarUrl: null }, + }; + }), + }), + ), + ); + + const readSummary = provider.getChangeRequestSummary; + if (readSummary === undefined) return yield* Effect.die("summary read was not implemented"); + const summary = yield* readSummary({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(summary.state).toBe("open"); + expect(summaryReads).toBe(1); + // The author's avatar comes from the login-shaped URL, not a second request. + expect(summary.author?.avatarUrl).toBe("https://github.com/octocat.png?size=80"); + }), +); + +it.effect("declares host-native stacks and passes the one the CLI reads through", () => + Effect.gen(function* () { + const stack = { + id: "42", + number: 3, + url: "https://github.com/acme/web/stacks/3", + base: "main", + layers: [ + { number: 6, headBranch: "feat/one", state: "merged" as const }, + { number: 7, headBranch: "feat/two", state: "open" as const }, + ], + }; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + revalidateChecks: (_input, read) => read, + getPullRequestStack: (input) => Effect.succeed(input.number === 7 ? stack : null), + }), + ), + ); + + expect(provider.capabilities.stacks).toBe(true); + const readStack = provider.getChangeRequestStack; + if (readStack === undefined) return yield* Effect.die("stack read was not implemented"); + const ref = { cwd: "/w", repository: "acme/web", host: "github.com" }; + expect(yield* readStack({ ...ref, number: 7 })).toEqual(stack); + expect(yield* readStack({ ...ref, number: 8 })).toBeNull(); + }), +); + +it.effect("reports a failed stack read against its own operation", () => + Effect.gen(function* () { + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + revalidateChecks: (_input, read) => read, + getPullRequestStack: () => + Effect.fail( + new GitHubPullRequestApi.GitHubPullRequestReadError({ + cwd: "/w", + operation: "getPullRequestStack", + cause: new Error("unreadable"), + }), + ), + }), + ), + ); + + const readStack = provider.getChangeRequestStack; + if (readStack === undefined) return yield* Effect.die("stack read was not implemented"); + const error = yield* Effect.flip( + readStack({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }), + ); + + expect(error.operation).toBe("getChangeRequestStack"); + expect(error.reason).toBe("failed"); + }), +); + +describe("gitHubViewerPermissions", () => { + it("offers everything to a viewer who can write to the repository", () => { + expect( + gitHubViewerPermissions({ + canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, + }), + ).toEqual({ + // Arming a merge for later is the merge, so it travels with it. + actions: [ + "merge", + "enable-auto-merge", + "disable-auto-merge", + "revert", + "approve-workflows", + "ready", + "draft", + "close", + "reopen", + ], + comment: true, + resolve: true, + stackRebase: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + labels: true, + }); + }); + + it("leaves a passer-by on a repository they can only read nothing but the review", () => { + // Every open-source pull request somebody else opened: GitHub says no to all five actions + // and to resolving, and yes to commenting and to every verdict. + expect( + gitHubViewerPermissions({ + canWrite: false, + canTriage: false, + canUpdate: false, + didAuthor: false, + }), + ).toEqual({ + actions: [], + comment: true, + resolve: false, + verdicts: ["comment", "approve", "request-changes"], + // Asking somebody else to review is the one thing read access never stretches to. + requestReviewers: false, + labels: false, + }); + }); + + it("lets a triager label without letting them merge or ask for a review", () => { + const permissions = gitHubViewerPermissions({ + canWrite: false, + canTriage: true, + canUpdate: false, + didAuthor: false, + }); + expect(permissions.labels).toBe(true); + expect(permissions.requestReviewers).toBe(false); + expect(permissions.actions).toEqual([]); + }); + + it("keeps an author's own pull request theirs to close, with read access and no more", () => { + expect( + gitHubViewerPermissions({ + canWrite: false, + canTriage: false, + canUpdate: true, + didAuthor: true, + }), + ).toEqual({ + // Merging is the one thing writing is needed for, now or later; the rest an author may do. + actions: ["ready", "draft", "close", "reopen"], + comment: true, + resolve: true, + // GitHub refuses an author's approval of their own change, so the page does not offer one. + verdicts: ["comment"], + requestReviewers: false, + labels: false, + }); + }); + + it.effect("uses the permissions carried by the core read without a second request", () => + Effect.gen(function* () { + const provider = yield* make; + const detail = yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(detail.viewerPermissions).toEqual({ + actions: ["ready", "draft", "close", "reopen"], + comment: true, + resolve: false, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: false, + labels: false, + }); + expect(detail.workflowApprovalsRequired).toBeUndefined(); + expect(detail.checks).toContainEqual({ + name: "Workflow approval status", + status: "action-required", + description: "GitHub could not determine whether workflows are awaiting approval.", + url: null, + }); + for (const fingerprint of ["broad", "restricted", "broad"]) { + const scoped = yield* provider + .getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }) + .pipe( + Effect.provideService(GitHubApi.PinnedGitHubCredential, { + host: "github.com", + token: Redacted.make("credential"), + credentialFingerprint: fingerprint, + }), + ); + expect(scoped.mergeCapabilities.squash).toBe(fingerprint !== "restricted"); + } + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + revalidateChecks: (_input, read) => read, + getPullRequestDetail: () => + Effect.succeed({ + ...coreFields, + comparison: null, + viewerAccess: { ...coreFields.viewerAccess, canWrite: false, canTriage: false }, + authorId: null, + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + author: null, + isCrossRepository: true, + headRepositoryOwner: null, + headBranch: "feat/page", + baseBranch: "main", + state: "open" as const, + isDraft: false, + mergeability: "mergeable" as const, + reviewDecision: null, + additions: 1, + deletions: 1, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + reviewRequestLogins: [], + hasTeamReviewRequest: false, + checksState: null, + labels: [], + body: "", + changedFiles: 1, + mergedAt: null, + closedAt: null, + checks: [], + comments: [], + commits: [], + }).pipe( + Effect.flatMap((detail) => + GitHubApi.PinnedGitHubCredential.pipe( + Effect.map((credential) => ({ + ...detail, + viewerAccess: { + ...detail.viewerAccess, + mergeCapabilities: { + ...detail.viewerAccess.mergeCapabilities, + squash: credential?.credentialFingerprint !== "restricted", + }, + }, + })), + ), + ), + ), + }), + ), + ), + ); + + it.effect("keeps fork workflows awaiting approval out of the passing state", () => + Effect.gen(function* () { + const provider = yield* make; + const detail = yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + const readChecks = provider.getChangeRequestChecks; + if (readChecks === undefined) return yield* Effect.die("checks read missing"); + expect( + yield* readChecks({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }), + ).toEqual({ state: detail.state, checks: detail.checks }); + expect(detail.workflowApprovalsRequired).toBe(1); + expect(detail.checks).toEqual([ + { + name: "manual gate", + status: "action-required", + description: null, + url: "https://example.com/manual-gate", + }, + { + name: "build", + status: "success", + description: null, + url: null, + }, + { + name: "contributor tests", + status: "action-required", + description: "A maintainer must approve this workflow before it can run.", + url: "https://github.com/acme/web/actions/runs/123", + }, + ]); + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + revalidateChecks: (_input, read) => read, + getPullRequestDetail: () => + Effect.succeed({ + ...coreFields, + authorId: null, + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + author: null, + isCrossRepository: true, + headRepositoryOwner: "octocat", + headSha: "abc123", + headBranch: "feat/page", + baseBranch: "main", + state: "open", + isDraft: false, + mergeability: "mergeable", + reviewDecision: null, + additions: 1, + deletions: 1, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + reviewRequestLogins: [], + hasTeamReviewRequest: false, + checksState: "passing", + labels: [], + body: "", + changedFiles: 1, + mergedAt: null, + closedAt: null, + checks: [ + { + name: "manual gate", + status: "action-required", + description: null, + url: "https://example.com/manual-gate", + }, + { name: "build", status: "success", description: null, url: null }, + ], + comments: [], + commits: [], + }), + listWorkflowRunsRequiringApproval: () => + Effect.succeed([ + { + id: 123, + name: "contributor tests", + url: "https://github.com/acme/web/actions/runs/123", + }, + ]), + getViewerAccess: () => + Effect.succeed({ + canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }), + }), + ), + ), + ); +}); + +const openDetail = { + ...coreFields, + authorId: null, + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + author: null, + isCrossRepository: true, + headRepositoryOwner: "acme", + headSha: "abc123", + headBranch: "feat/page", + baseBranch: "main", + state: "open" as const, + isDraft: false, + mergeability: "mergeable" as const, + reviewDecision: null, + additions: 1, + deletions: 1, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + reviewRequestLogins: [], + hasTeamReviewRequest: false, + checksState: null, + labels: [], + body: "", + changedFiles: 1, + mergedAt: null, + closedAt: null, + checks: [], + comments: [], + commits: [], +}; + +it.effect( + "uses the core comparison and permissions while preserving workflow approval checks", + () => + Effect.gen(function* () { + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + getPullRequestDetail: () => + Effect.succeed({ ...openDetail, comparison: { behindBy: 2, viewerCanUpdate: true } }), + listWorkflowRunsRequiringApproval: () => + Effect.succeed([{ id: 123, name: "tests", url: "https://example.com/runs/123" }]), + }), + ), + ); + + const detail = yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(detail.behindBy).toBe(2); + expect(detail.workflowApprovalsRequired).toBe(1); + expect(detail.checks).toContainEqual({ + name: "tests", + status: "action-required", + description: "A maintainer must approve this workflow before it can run.", + url: "https://example.com/runs/123", + }); + }), +); + +it.effect("does not classify same-repository gates as fork workflow approvals", () => + Effect.gen(function* () { + const provider = yield* make; + const detail = yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(detail.workflowApprovalsRequired).toBe(0); + expect(detail.checks).toEqual([]); + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + revalidateChecks: (_input, read) => read, + getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }), + listWorkflowRunsRequiringApproval: () => + Effect.die("same-repository pull requests must not probe fork workflow approvals"), + getViewerAccess: () => + Effect.succeed({ + canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }), + }), + ), + ), +); + +it.effect("keeps an unsafe workflow approval scope visible as unknown", () => + Effect.gen(function* () { + const provider = yield* make; + const detail = yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(detail.workflowApprovalsRequired).toBeUndefined(); + expect(detail.checks).toEqual([ + { + name: "Workflow approval status", + status: "action-required", + description: "GitHub could not determine whether workflows are awaiting approval.", + url: null, + }, + ]); + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + revalidateChecks: (_input, read) => read, + getPullRequestDetail: () => Effect.succeed(openDetail), + listWorkflowRunsRequiringApproval: () => + Effect.fail( + new GitHubPullRequestApi.GitHubWorkflowApprovalRefusedError({ + cwd: "/w", + number: 7, + reason: "head-not-unique", + observedCount: 2, + limit: 1_000, + }), + ), + getViewerAccess: () => + Effect.succeed({ + canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }), + }), + ), + ), +); + +it.effect("propagates workflow discovery rate limits", () => + Effect.gen(function* () { + const provider = yield* make; + const error = yield* provider + .getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }) + .pipe(Effect.flip); + + expect(error.operation).toBe("getChangeRequest"); + expect(error.reason).toBe("rate-limited"); + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + revalidateChecks: (_input, read) => read, + getPullRequestDetail: () => Effect.succeed(openDetail), + listWorkflowRunsRequiringApproval: () => + Effect.fail( + new GitHubApi.GitHubApiRateLimitError({ + host: "github.com", + operation: "listWorkflowRunsRequiringApproval", + }), + ), + getViewerAccess: () => + Effect.succeed({ + canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }), + }), + ), + ), +); + +describe("getViewerPermissions", () => { + const access = { + canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }; + + it.effect("checks fresh access without reading branch details for unrelated operations", () => { + let accessReads = 0; + return Effect.gen(function* () { + const provider = yield* make; + const permissions = yield* provider.getViewerPermissions({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + includeUpdateBranch: false, + }); + + expect(accessReads).toBe(1); + expect(permissions.actions).toContain("merge"); + expect(permissions.actions).not.toContain("update-branch"); + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + revalidateChecks: (_input, read) => read, + getPullRequestDetail: () => Effect.die("Unexpected detail read"), + getViewerAccess: () => Effect.sync(() => (accessReads++, access)), + }), + ), + ); + }); + + const layerWithDetail = ( + detail: Effect.Effect, + onDetail: (allowReserve: boolean) => void = () => {}, + ) => + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + revalidateChecks: (_input, read) => read, + getPullRequestDetail: () => + GitHubApi.AllowGitHubReserve.pipe( + Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))), + Effect.flatMap(() => detail), + ), + getViewerAccess: () => Effect.die("Unexpected access read"), + }); + + it.effect("answers access and update-branch from the one detail read", () => + Effect.gen(function* () { + const provider = yield* make; + const permissions = yield* provider.getViewerPermissions({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(permissions.actions).toContain("merge"); + expect(permissions.actions).toContain("update-branch"); + expect(permissions.updateMethods).toEqual(["merge", "rebase"]); + }).pipe( + Effect.provide( + layerWithDetail( + Effect.succeed({ ...openDetail, comparison: { behindBy: 3, viewerCanUpdate: true } }), + ), + ), + ), + ); + + it.effect("uses the GraphQL reserve for manual permission checks", () => { + let allowed: boolean | undefined; + return Effect.gen(function* () { + const provider = yield* make; + yield* provider.getViewerPermissions({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + expect(allowed).toBe(true); + }).pipe( + Effect.provide( + layerWithDetail(Effect.succeed(openDetail), (allowReserve) => (allowed = allowReserve)), + ), + ); + }); + + it.effect("still answers from the light access read when the detail read fails", () => + Effect.gen(function* () { + const provider = yield* make; + const permissions = yield* provider.getViewerPermissions({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + expect(permissions.actions).toContain("merge"); + expect(permissions.actions).not.toContain("update-branch"); + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + revalidateChecks: (_input, read) => read, + getPullRequestDetail: () => + Effect.fail( + new GitHubPullRequestApi.GitHubPullRequestReadError({ + cwd: "/w", + operation: "getPullRequestDetail", + cause: new Error("head changed"), + }), + ), + getViewerAccess: () => Effect.succeed(access), + }), + ), + ), + ); + + it.effect("withholds update-branch where GitHub could not compare the branch", () => + Effect.gen(function* () { + const provider = yield* make; + const permissions = yield* provider.getViewerPermissions({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(permissions.actions).not.toContain("update-branch"); + expect(permissions.updateMethods).toBeUndefined(); + expect(permissions.actions).toContain("merge"); + }).pipe(Effect.provide(layerWithDetail(Effect.succeed({ ...openDetail, comparison: null })))), + ); +}); + +describe("getChangeRequest commits", () => { + const baseDetail = { + authorId: null, + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + author: null, + headBranch: "feat/page", + baseBranch: "main", + state: "open" as const, + isDraft: false, + mergeability: "mergeable" as const, + additions: 1, + deletions: 1, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + reviewRequestLogins: [], + hasTeamReviewRequest: false, + checksState: null, + labels: [], + body: "", + changedFiles: 1, + mergedAt: null, + closedAt: null, + checks: [], + comments: [], + }; + + const baseThreadComments = { + comments: [], + dismissalsByReviewId: new Map(), + reviewThreads: [], + commentCount: 0, + truncated: false, + reviewThreadsTruncated: false, + reactions: [], + reactionsById: new Map>(), + editedAtById: new Map(), + reviewers: [], + avatarsByLogin: new Map(), + botLogins: new Set(), + commitStats: new Map(), + viewer: { canUpdate: true, didAuthor: false }, + }; + + const layerWith = (commits: GitHubReviewThreadComments["commits"]) => + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + revalidateChecks: (_input, read) => read, + getPullRequestActivity: () => + Effect.succeed({ + author: baseDetail.author, + comments: baseDetail.comments, + commits: [ + { + oid: "view-oldest", + messageHeadline: "gh pr view's oldest commit", + committedDate: "2026-01-01T00:00:00Z", + authors: [], + }, + ], + }), + listReviewThreadComments: () => Effect.succeed({ ...baseThreadComments, commits }), + }); + + it.effect("prefers the GraphQL commits, which are the newest, over the gh view list", () => + Effect.gen(function* () { + const provider = yield* make; + const detail = yield* provider.getChangeRequestActivity({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(detail.commits.map((commit) => commit.oid)).toEqual(["graphql-newest"]); + }).pipe( + Effect.provide( + layerWith([ + { + oid: "graphql-newest", + messageHeadline: "the newest commit gh pr view drops", + committedDate: "2026-07-06T00:00:00Z", + authors: [], + }, + ]), + ), + ), + ); + + it.effect("falls back to the gh view list when the GraphQL read has no commits", () => + Effect.gen(function* () { + const provider = yield* make; + const detail = yield* provider.getChangeRequestActivity({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(detail.commits.map((commit) => commit.oid)).toEqual(["view-oldest"]); + }).pipe(Effect.provide(layerWith([]))), + ); +}); + +describe("getChangeRequestActivity dismissed reviews", () => { + const dismissedReview = (body: string) => ({ + id: "PRR_1", + kind: "review" as const, + author: { login: "macroscopeapp", name: null, avatarUrl: null }, + body, + createdAt: "2026-07-03T00:00:00Z", + url: null, + path: null, + reviewState: "DISMISSED", + }); + const threadComments: GitHubReviewThreadComments = { + comments: [], + dismissalsByReviewId: new Map([["PRR_1", "Dismissing prior approval to re-evaluate 9b66581"]]), + reviewThreads: [], + commentCount: 0, + truncated: false, + reviewThreadsTruncated: false, + reactions: [], + reactionsById: new Map(), + editedAtById: new Map([["PRR_1", "2026-07-04T00:00:00Z"]]), + reviewers: [], + avatarsByLogin: new Map(), + botLogins: new Set(["macroscopeapp"]), + commitStats: new Map(), + commits: [], + viewer: { canUpdate: true, didAuthor: false }, + }; + const layerFor = (body: string) => + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + revalidateChecks: (_input, read) => read, + getPullRequestActivity: () => + Effect.succeed({ author: null, comments: [dismissedReview(body)], commits: [] }), + listReviewThreadComments: () => Effect.succeed(threadComments), + }); + const readActivity = Effect.gen(function* () { + const provider = yield* make; + return yield* provider.getChangeRequestActivity({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + }); + + it.effect("fills a marker-only dismissed review with the timeline's reason", () => + // Macroscope's approvals carry only an HTML comment, which markdown renders as nothing — + // an empty-string check misses them and the card opens onto nothing. + readActivity.pipe( + Effect.map((activity) => { + expect(activity.comments[0]?.body).toBe("Dismissing prior approval to re-evaluate 9b66581"); + expect(activity.comments[0]?.author?.isBot).toBe(true); + expect(activity.comments[0]?.editedAt).toBe("2026-07-04T00:00:00Z"); + }), + Effect.provide(layerFor("")), + ), + ); + + it.effect("keeps the words of a dismissed review that has its own", () => + readActivity.pipe( + Effect.map((activity) => { + expect(activity.comments[0]?.body).toBe("These findings still stand."); + }), + Effect.provide(layerFor("These findings still stand.")), + ), + ); +}); + +describe("editing", () => { + const rewrites: Array = []; + + it.effect("hands a rewrite to the CLI as the request named it", () => + Effect.gen(function* () { + const provider = yield* make; + + expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: true }); + yield* provider.updateChangeRequest!({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + title: "A better title", + }); + yield* provider.updateComment!({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commentId: "IC_1", + kind: "review-comment", + body: "Reworded.", + }); + + expect(rewrites).toEqual([ + { + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + title: "A better title", + }, + { + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commentId: "IC_1", + kind: "review-comment", + body: "Reworded.", + }, + ]); + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + revalidateChecks: (_input, read) => read, + updatePullRequest: (input) => Effect.sync(() => void rewrites.push(input)), + updateComment: (input) => Effect.sync(() => void rewrites.push(input)), + }), + ), + ), + ); +}); + +describe("loginAvatarUrl", () => { + it("serves a user's picture from the host they belong to", () => { + expect(loginAvatarUrl("octocat", "github.com")).toBe("https://github.com/octocat.png?size=80"); + expect(loginAvatarUrl("octocat", "ghe.example.com")).toBe( + "https://ghe.example.com/octocat.png?size=80", + ); + }); + + it("has nothing for an app, which names no page", () => { + // `dependabot[bot]` has a picture, but not at `/dependabot[bot].png` — a guess that 404s is + // worse than the initials it would replace. + expect(loginAvatarUrl("dependabot[bot]", "github.com")).toBeNull(); + }); + + it("refuses anything that is not a login, rather than building a URL out of it", () => { + for (const login of ["../../etc", "a b", "-leading", "x".repeat(40), ""]) { + expect(loginAvatarUrl(login, "github.com")).toBeNull(); + } + }); +}); diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3030f6c86ad4d41415331274af59560d36edb66 --- /dev/null +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -0,0 +1,672 @@ +import * as Effect from "effect/Effect"; +import type { + PullRequestActor, + PullRequestCapabilities, + PullRequestCheck, + PullRequestReaction, + PullRequestViewerPermissions, +} from "@t3tools/contracts"; + +import * as GitHubApi from "./GitHubApi.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; +import { + PullRequestProviderError, + type PullRequestProviderFailure, + type ProviderChangeRequestActivity, + type ProviderChangeRequestDetail, + type PullRequestProviderApi, + type ProviderRepositoryRef, +} from "@t3tools/source-control-core/server/PullRequestProvider"; +import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts"; + +const CAPABILITIES: PullRequestCapabilities = { + diff: true, + comment: true, + actions: [ + "merge", + "ready", + "draft", + "close", + "reopen", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + "revert", + "approve-workflows", + ], + mergeMethods: ["merge", "squash", "rebase"], + updateMethods: ["merge", "rebase"], + search: true, + reactions: true, + viewedFiles: "host", + review: { + inlineComment: true, + reply: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + }, + reviewers: { request: true, listCandidates: true }, + edit: { changeRequest: true, comment: true }, + stacks: true, + stackActions: true, + labels: true, +}; + +/** + * What the signed-in account may do here, from the three things GitHub says about it. + * + * Merging needs a role that can push, which is the one thing a stranger on an open-source + * repository never has. The other four actions go by `viewerCanUpdate`, because the author of a + * pull request may close it, reopen it and move it in and out of draft with no more than read + * access on the repository it was opened against. + * + * Commenting and reviewing are not gated at all: read access is enough to say something and + * enough to approve or ask for changes, which is what open-source review consists of. Resolving a + * conversation is the exception — GitHub allows it to whoever can write, and to the author of the + * pull request the conversation is on. + * + * Asking somebody else for a review needs write access, which is the one thing here an author + * cannot do on their own pull request: GitHub shows an outside contributor the reviewer control + * and refuses the request behind it. + */ +export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions { + return { + ...(access.canWrite ? { stackRebase: true } : {}), + actions: [ + // Arming a merge and taking the arming back are the merge, deferred: whoever may not + // merge here may not leave an instruction to merge later either. + ...(access.canWrite + ? ([ + "merge", + "enable-auto-merge", + "disable-auto-merge", + "revert", + "approve-workflows", + ] as const) + : []), + ...(access.canUpdate ? (["ready", "draft", "close", "reopen"] as const) : []), + // Whether this viewer may update the branch is GitHub's own answer, read with the + // comparison; without it the action is offered to nobody rather than to everybody. + ...(access.canUpdateBranch === true ? (["update-branch"] as const) : []), + ], + comment: true, + resolve: access.canWrite || access.didAuthor, + // Anyone may review a pull request they can see, except their own: GitHub refuses an author's + // approval and their request for changes ("Can not approve your own pull request"), and + // leaves them commenting, which is what an author has to say about their own change anyway. + verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts, + requestReviewers: access.canWrite, + ...(access.canUpdateBranch === true ? { updateMethods: CAPABILITIES.updateMethods } : {}), + // Triage is the one role that labels without writing, which is what triage is for. + labels: access.canTriage, + }; +} + +/** The tags that mean GitHub is out of reach for this account, rather than one request failing. */ +export function gitHubProviderFailure( + error: GitHubPullRequestApi.GitHubPullRequestApiError, +): PullRequestProviderFailure { + switch (error._tag) { + case "GitHubCliMissingError": + return { reason: "missing-tool" }; + case "GitHubNotSignedInError": + case "GitHubHostDisabledError": + case "GitHubApiAuthenticationError": + return { reason: "unauthenticated" }; + case "GitHubApiRateLimitError": + return { + reason: "rate-limited", + ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), + }; + case "SourceControlRateLimitPausedError": + return { reason: "rate-limited", retryAt: error.retryAt }; + case "GitHubApiNotFoundError": + return { reason: "not-found" }; + default: + return { reason: "failed" }; + } +} + +/** + * `gh pr view --json` reports no avatar for anyone, so the ones the GraphQL read collected are + * applied here by login. An actor already carrying one keeps it. + * + * A login GitHub did not answer for falls back to the picture every GitHub install serves at + * `/.png`. The lookup is one more request per repository and can be refused — a rate + * limit, a slow host — and a face that comes and goes between two loads of the same page reads + * as a bug in the page rather than as a request that failed quietly. + */ +function withAvatar( + actor: PullRequestActor | null, + avatarsByLogin: ReadonlyMap, + host: string, + botLogins?: ReadonlySet, +): PullRequestActor | null { + if (actor === null) return actor; + if (botLogins?.has(actor.login)) actor = { ...actor, isBot: true }; + if (actor.avatarUrl !== null) return actor; + const avatarUrl = avatarsByLogin.get(actor.login) ?? loginAvatarUrl(actor.login, host); + return avatarUrl === null ? actor : { ...actor, avatarUrl }; +} + +function withWorkflowApprovals( + checks: ReadonlyArray, + runs: ReadonlyArray, + unavailable: boolean, +): ReadonlyArray { + const representedRunIds = new Set(); + for (const check of checks) { + if (check.status !== "action-required" || check.url === null) continue; + const id = check.url.match(/\/actions\/runs\/(\d+)(?:\/|$)/)?.[1]; + if (id !== undefined) representedRunIds.add(Number(id)); + } + const approvalChecks = runs + .filter((run) => !representedRunIds.has(run.id)) + .map((run): PullRequestCheck => ({ + name: run.name, + status: "action-required", + description: "A maintainer must approve this workflow before it can run.", + url: run.url, + })); + return [ + ...checks, + ...approvalChecks, + ...(unavailable + ? [ + { + name: "Workflow approval status", + status: "action-required" as const, + description: "GitHub could not determine whether workflows are awaiting approval.", + url: null, + }, + ] + : []), + ]; +} + +/** + * Null for anything that is not a plain user login: an app posts as `dependabot[bot]`, which + * names no page, and a guessed URL that 404s is worse than the initials it would replace. + */ +export function loginAvatarUrl(login: string, host: string): string | null { + return /^[a-z0-9][a-z0-9-]{0,38}$/iu.test(login) ? `https://${host}/${login}.png?size=80` : null; +} + +/** True where markdown would render nothing: whitespace, or only HTML comments. */ +const rendersEmpty = (body: string): boolean => + body.replace(//g, "").trim().length === 0; + +export const make = Effect.gen(function* () { + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const fail = (operation: string) => (error: GitHubPullRequestApi.GitHubPullRequestApiError) => + new PullRequestProviderError({ + provider: "github", + operation, + ...gitHubProviderFailure(error), + detail: error.message, + cause: error, + }); + + const readChecks = (input: ProviderRepositoryRef & { readonly number: number }) => + cli.getPullRequestDetail(input).pipe( + Effect.flatMap((pullRequest) => + (pullRequest.state !== "open" || pullRequest.isCrossRepository !== true + ? Effect.succeed({ + runs: [] as ReadonlyArray, + unavailable: false, + }) + : pullRequest.headSha == null || pullRequest.headRepositoryOwner == null + ? Effect.succeed({ + runs: [] as ReadonlyArray, + unavailable: true, + }) + : cli + .listWorkflowRunsRequiringApproval({ + ...input, + headSha: pullRequest.headSha, + headBranch: pullRequest.headBranch, + headRepositoryOwner: pullRequest.headRepositoryOwner, + isCrossRepository: true, + }) + .pipe( + Effect.matchEffect({ + onFailure: (error) => + error._tag === "GitHubApiRateLimitError" || + error._tag === "SourceControlRateLimitPausedError" + ? Effect.fail(error) + : Effect.succeed({ + runs: [] as ReadonlyArray, + unavailable: true, + }), + onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }), + }), + ) + ).pipe( + Effect.map((workflowApprovals) => ({ + ...pullRequest, + author: withAvatar(pullRequest.author, new Map(), input.host), + checks: withWorkflowApprovals( + pullRequest.checks, + workflowApprovals.runs, + workflowApprovals.unavailable, + ), + ...(workflowApprovals.unavailable + ? {} + : { workflowApprovalsRequired: workflowApprovals.runs.length }), + })), + ), + ), + ); + + const provider: PullRequestProviderApi = { + kind: "github", + capabilities: CAPABILITIES, + getRoutingIdentity: (input) => + cli.getRoutingIdentity(input).pipe(Effect.mapError(fail("routeIdentity"))), + withVerifiedCredential: (input, use) => + cli + .withVerifiedCredential(input, (identity) => use(identity).pipe(Effect.result)) + .pipe(Effect.mapError(fail("routeIdentity")), Effect.flatMap(Effect.fromResult)), + + getViewer: (input) => + cli + .getViewerLogin({ cwd: input.cwd, host: input.host ?? "github.com" }) + .pipe(Effect.mapError(fail("getViewer"))), + + listChangeRequests: (input) => + cli + .listPullRequests({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + limit: input.limit, + query: input.query, + cursor: input.cursor, + filters: input.filters, + }) + .pipe( + Effect.mapError(fail("listChangeRequests")), + Effect.flatMap((page) => + cli + .listActorAvatars({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + ids: [...new Set(page.items.flatMap((item) => item.authorId ?? []))], + }) + // A listing without faces is still a listing, so a failed lookup falls back to + // the initials rather than taking the rows down with it. + .pipe( + Effect.orElseSucceed(() => new Map()), + Effect.map((avatarsByLogin) => ({ + ...page, + items: page.items.map((item) => ({ + ...item, + author: withAvatar(item.author, avatarsByLogin, input.host), + })), + })), + ), + ), + ), + + /** + * The same listing for a whole host in one search. The avatar lookup the per-repository read + * needs is not here: a search reports an author's picture itself, so a face costs no request + * of its own — `withAvatar` still stands behind it for the login GitHub answered nothing for. + */ + listChangeRequestsAcross: (input) => + cli + .searchPullRequests({ + cwd: input.cwd, + host: input.host, + repositories: input.repositories, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + limit: input.limit, + query: input.query, + cursor: input.cursor, + filters: input.filters, + }) + .pipe( + Effect.mapError(fail("listChangeRequestsAcross")), + Effect.map((batch) => ({ + truncated: batch.truncated, + items: batch.items.map((item) => ({ + ...item, + author: withAvatar(item.author, new Map(), input.host), + })), + })), + ), + + listChangeRequestStats: (input) => + cli + .listPullRequestStats({ + cwd: input.cwd, + host: input.host, + changeRequests: input.changeRequests, + }) + .pipe(Effect.mapError(fail("listChangeRequestStats"))), + + getChangeRequestSummary: (input) => + cli.getPullRequestSummary(input).pipe( + // `gh pr view` names the author without an avatar; the login-shaped URL every user + // has stands in, without the second request the listing spends on it. + Effect.map((summary) => ({ + ...summary, + ...(summary.author === undefined + ? {} + : { author: withAvatar(summary.author, new Map(), input.host) }), + })), + Effect.mapError(fail("getChangeRequestSummary")), + ), + + getChangeRequestStack: (input) => + cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))), + + getChangeRequestWatchFingerprint: (input) => + cli + .getPullRequestWatchFingerprint(input) + .pipe(Effect.mapError(fail("getChangeRequestWatchFingerprint"))), + + getChangeRequestPreview: (input) => + cli.getPullRequestPreview(input).pipe(Effect.mapError(fail("getChangeRequestPreview"))), + + getChangeRequestChecks: (input) => + cli.revalidateChecks(input, readChecks(input)).pipe( + Effect.map(({ state, checks }) => ({ state, checks })), + Effect.mapError(fail("getChangeRequestChecks")), + ), + + getChangeRequest: (input) => + readChecks(input).pipe( + Effect.map((pullRequest): ProviderChangeRequestDetail => ({ + ...pullRequest, + author: withAvatar(pullRequest.author, new Map(), input.host), + reviewers: pullRequest.reviewRequestLogins.map((login) => ({ + login, + name: null, + avatarUrl: null, + })), + mergeCapabilities: pullRequest.viewerAccess.mergeCapabilities, + viewerPermissions: gitHubViewerPermissions({ + ...pullRequest.viewerAccess, + canUpdateBranch: pullRequest.comparison?.viewerCanUpdate === true, + }), + baseComparison: + pullRequest.comparison === null || pullRequest.comparison.behindBy === null + ? "unknown" + : pullRequest.comparison.behindBy > 0 + ? "behind" + : "up-to-date", + ...(pullRequest.comparison?.behindBy == null + ? {} + : { behindBy: pullRequest.comparison.behindBy }), + })), + Effect.mapError(fail("getChangeRequest")), + ), + + getChangeRequestActivity: (input) => + Effect.all( + [ + cli.getPullRequestActivity(input), + // Line comments live on review threads, which `gh pr view --json` cannot reach. A + // GraphQL hiccup degrades to a truncated conversation rather than blanking activity. + cli.listReviewThreadComments(input).pipe( + Effect.orElseSucceed(() => ({ + comments: [], + dismissalsByReviewId: new Map(), + reactions: [], + reactionsById: new Map>(), + editedAtById: new Map(), + reviewThreads: [], + commentCount: 0, + truncated: true, + reviewThreadsTruncated: true, + reviewers: [], + avatarsByLogin: new Map(), + botLogins: new Set(), + commitStats: new Map< + string, + { readonly additions: number; readonly deletions: number } + >(), + commits: [], + viewer: { canUpdate: true, didAuthor: false }, + })), + ), + ], + { concurrency: 2 }, + ).pipe( + Effect.mapError(fail("getChangeRequestActivity")), + Effect.map(([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({ + author: withAvatar( + pullRequest.author, + reviewThreads.avatarsByLogin, + input.host, + reviewThreads.botLogins, + ), + reviewers: reviewThreads.reviewers, + reactions: reviewThreads.reactions, + commits: (reviewThreads.commits.length > 0 + ? reviewThreads.commits + : pullRequest.commits + ).map((commit) => ({ + ...commit, + ...reviewThreads.commitStats.get(commit.oid), + authors: commit.authors?.map( + (author) => + withAvatar( + author, + reviewThreads.avatarsByLogin, + input.host, + reviewThreads.botLogins, + ) ?? author, + ), + })), + comments: [...pullRequest.comments, ...reviewThreads.comments] + .map((comment) => ({ + ...comment, + // GitHub keeps the dismissal reason on the timeline event, not on the review, + // so a dismissed review with nothing visible of its own reads its words from + // there. "Visible" and not "empty": bot reviews often carry only an HTML + // marker comment, which markdown renders as nothing. + body: + comment.kind === "review" && + comment.reviewState?.toUpperCase() === "DISMISSED" && + rendersEmpty(comment.body) + ? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body) + : comment.body, + author: withAvatar( + comment.author, + reviewThreads.avatarsByLogin, + input.host, + reviewThreads.botLogins, + ), + // A comment out of `gh pr view --json` carries none of its own: that read + // reports no reaction at all, so they arrive from the GraphQL page by node id. + reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [], + editedAt: reviewThreads.editedAtById.get(comment.id) ?? comment.editedAt ?? null, + })) + .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), + // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two + // are always whole and only the thread walk can stop short of the host. + commentCount: pullRequest.comments.length + reviewThreads.commentCount, + commentsTruncated: reviewThreads.truncated, + reviewThreadsTruncated: reviewThreads.reviewThreadsTruncated, + reviewThreads: reviewThreads.reviewThreads.map((thread) => ({ + ...thread, + comments: thread.comments.map((comment) => ({ + ...comment, + author: withAvatar( + comment.author, + reviewThreads.avatarsByLogin, + input.host, + reviewThreads.botLogins, + ), + })), + })), + })), + ), + + getReviewThreadComments: (input) => + cli.getReviewThreadComments(input).pipe(Effect.mapError(fail("getReviewThreadComments"))), + + getViewerPermissions: (input) => { + const lightAccess = cli + .getViewerAccess({ ...input, allowReserve: true }) + .pipe(Effect.map((access) => gitHubViewerPermissions(access))); + if (input.includeUpdateBranch === false) { + return lightAccess.pipe(Effect.mapError(fail("getViewerPermissions"))); + } + // The core detail already carries the viewer's access, the merge settings, and the base + // 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(GitHubApi.AllowGitHubReserve, true), + Effect.map((pullRequest) => + gitHubViewerPermissions({ + ...pullRequest.viewerAccess, + canUpdateBranch: + pullRequest.state === "open" && pullRequest.comparison?.viewerCanUpdate === true, + }), + ), + Effect.catchIf( + (error) => + error._tag !== "GitHubApiRateLimitError" && + error._tag !== "SourceControlRateLimitPausedError", + () => lightAccess, + ), + Effect.mapError(fail("getViewerPermissions")), + ); + }, + + getDiff: (input) => cli.getPullRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), + + getDiffFileContents: (input) => + cli.getPullRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))), + + getFilesViewed: (input) => + cli.getPullRequestFilesViewed(input).pipe(Effect.mapError(fail("getFilesViewed"))), + + setFilesViewed: (input) => + cli.setPullRequestFilesViewed(input).pipe(Effect.mapError(fail("setFilesViewed"))), + + listReviewerCandidates: (input) => + cli.listReviewerCandidates(input).pipe(Effect.mapError(fail("listReviewerCandidates"))), + + setReviewerRequest: (input) => + cli + .setReviewerRequest({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + reviewers: input.reviewers, + requested: input.requested, + }) + .pipe(Effect.mapError(fail("setReviewerRequest"))), + + listLabelCandidates: (input) => + cli.listLabelCandidates(input).pipe(Effect.mapError(fail("listLabelCandidates"))), + + setLabels: (input) => + cli + .setLabels({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + labels: input.labels, + applied: input.applied, + }) + .pipe(Effect.mapError(fail("setLabels"))), + + runAction: (input) => + cli + .runPullRequestAction({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + action: input.action, + ...(input.removeAgentCreditsOnMerge === true ? { removeAgentCreditsOnMerge: true } : {}), + ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), + ...(input.expectedStackHeads === undefined + ? {} + : { expectedStackHeads: input.expectedStackHeads }), + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), + }) + .pipe(Effect.mapError(fail("runAction"))), + + updateChangeRequest: (input) => + cli + .updatePullRequest({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + }) + .pipe(Effect.mapError(fail("updateChangeRequest"))), + + comment: (input) => cli.commentOnPullRequest(input).pipe(Effect.mapError(fail("comment"))), + + updateComment: (input) => + cli + .updateComment({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + commentId: input.commentId, + kind: input.kind, + body: input.body, + }) + .pipe(Effect.mapError(fail("updateComment"))), + + submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), + + replyToThread: (input) => + cli + .replyToReviewThread({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + threadId: input.threadId, + body: input.body, + }) + .pipe(Effect.mapError(fail("replyToThread"))), + + setReaction: (input) => + cli + .setReaction({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + ...(input.subjectId === undefined ? {} : { subjectId: input.subjectId }), + content: input.content, + reacted: input.reacted, + }) + .pipe(Effect.mapError(fail("setReaction"))), + + setThreadResolution: (input) => + cli + .setReviewThreadResolution({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + threadId: input.threadId, + resolved: input.resolved, + }) + .pipe(Effect.mapError(fail("setThreadResolution"))), + }; + + return provider; +}); diff --git a/packages/source-control-github/src/server/GitHubQuota.test.ts b/packages/source-control-github/src/server/GitHubQuota.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c2c707cde7dc4f19a100ba5f4a8a09db7ca87e4 --- /dev/null +++ b/packages/source-control-github/src/server/GitHubQuota.test.ts @@ -0,0 +1,86 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; + +import * as GitHubQuota from "./GitHubQuota.ts"; +import { CredentialScope } from "@t3tools/source-control-core/server/SourceControlRateLimit"; + +const RESET = Date.parse("2099-08-13T14:00:00Z"); +const headers = (remaining: number, resource = "graphql", reset = RESET) => ({ + "x-ratelimit-resource": resource, + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": String(remaining), + "x-ratelimit-reset": String(reset / 1000), +}); + +describe("GitHubQuota", () => { + it.effect("refuses a background request below the reserve, per quota and account", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(499)); + const refused = yield* Effect.flip(quota.admit("github.com", "graphql")); + assert.strictEqual(refused.retryAt, RESET); + // Another quota, another host and another account each have their own balance. + yield* quota.admit("github.com", "core"); + yield* quota.admit("ghe.example", "graphql"); + yield* quota.admit("github.com", "graphql").pipe(Effect.provideService(CredentialScope, "b")); + yield* quota.admit("github.com", "graphql", { allowReserve: true }); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("takes the latest balance in a window, and ignores an older window", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(400)); + yield* Effect.flip(quota.admit("github.com", "graphql")); + // GitHub can report more left later in the same window, from another region. + yield* quota.observe("github.com", headers(900)); + yield* quota.admit("github.com", "graphql"); + yield* quota.observe("github.com", headers(400)); + yield* Effect.flip(quota.admit("github.com", "graphql")); + // An answer from an older window says nothing about this one. + yield* quota.observe("github.com", headers(4000, "graphql", RESET - 3_600_000)); + yield* Effect.flip(quota.admit("github.com", "graphql")); + // A later window replaces it. + yield* quota.observe("github.com", headers(4000, "graphql", RESET + 3_600_000)); + yield* quota.admit("github.com", "graphql"); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("lets interactive requests spend the reserve but not an empty quota", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(1)); + yield* quota.admit("github.com", "graphql", { allowReserve: true }); + yield* quota.observe("github.com", headers(0)); + const refused = yield* Effect.flip( + quota.admit("github.com", "graphql", { allowReserve: true }), + ); + assert.strictEqual(refused.retryAt, RESET); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("spends nothing locally, so a free 304 cannot run the balance down", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(501, "core")); + // Conditional reads answer 304 with the balance unchanged, however many are sent. + for (let index = 0; index < 5; index++) { + yield* quota.admit("github.com", "core"); + yield* quota.observe("github.com", headers(501, "core")); + } + yield* quota.admit("github.com", "core"); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("forgets a balance once its window resets, and ignores answers naming no quota", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(0)); + yield* TestClock.setTime(RESET); + yield* quota.admit("github.com", "graphql"); + yield* quota.observe("other.example", { "x-ratelimit-remaining": "0" }); + yield* quota.admit("other.example", "graphql"); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); +}); diff --git a/packages/source-control-github/src/server/GitHubQuota.ts b/packages/source-control-github/src/server/GitHubQuota.ts new file mode 100644 index 0000000000000000000000000000000000000000..5dcd5469b9edc29a2457d94cbab24960b9680fce --- /dev/null +++ b/packages/source-control-github/src/server/GitHubQuota.ts @@ -0,0 +1,122 @@ +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; + +/** The share of each quota a background read leaves for a user's next click. */ +const RESERVE_RATIO = 0.1; + +/** + * GitHub's quotas: `core` for REST, `graphql` for GraphQL, and a few smaller ones (`search`, …). + * Each is spent and reset on its own, so each is tracked on its own. + */ +type GitHubQuotaResource = string; + +interface QuotaSnapshot { + readonly limit: number; + readonly remaining: number; + readonly resetAtMs: number; +} + +/** + * What GitHub says is left of one quota, from the `x-ratelimit-*` headers every API answer + * carries. Null when the answer named no quota, which an Enterprise host with limits off does. + */ +function quotaFromHeaders( + headers: Readonly>, +): (QuotaSnapshot & { readonly resource: GitHubQuotaResource }) | null { + const resource = headers["x-ratelimit-resource"]?.trim(); + const limit = Number(headers["x-ratelimit-limit"]); + const remaining = Number(headers["x-ratelimit-remaining"]); + const reset = Number(headers["x-ratelimit-reset"]); + if ( + !resource || + !Number.isFinite(limit) || + limit <= 0 || + !Number.isFinite(remaining) || + remaining < 0 || + !Number.isFinite(reset) + ) { + return null; + } + return { resource, limit, remaining, resetAtMs: reset * 1_000 }; +} + +/** + * Keeps the last tenth of each GitHub quota for interactive requests. A background sweep that + * would dip below it is refused until the quota resets, so a user's next click still has budget. + * + * The balance is GitHub's own count from the latest answer's headers, so it already includes + * what anything else spent with the same token, such as an agent's `gh` commands. Requests in + * flight together all see that one balance; the reserve absorbs what they spend before their + * answers land. Nothing is debited locally, because a 304 or a request that never reached GitHub + * spends nothing, and a local count would drift below GitHub's for the rest of the window. + * + * Interactive requests may spend the reserve, but not a quota GitHub already reported empty. + */ +export class GitHubQuota extends Context.Service< + GitHubQuota, + { + readonly admit: ( + host: string, + resource: GitHubQuotaResource, + options?: { readonly allowReserve: boolean }, + ) => Effect.Effect; + readonly observe: ( + host: string, + headers: Readonly>, + ) => Effect.Effect; + } +>()("@t3tools/source-control-github/server/GitHubQuota") {} + +const make = Effect.gen(function* () { + const snapshots = yield* Ref.make>(new Map()); + const keyOf = (host: string, resource: string, scope: string) => + `${host.trim().toLowerCase()}\0${resource}\0${scope}`; + + const admit: GitHubQuota["Service"]["admit"] = Effect.fn("GitHubQuota.admit")( + function* (host, resource, options) { + const now = yield* Clock.currentTimeMillis; + const key = keyOf(host, resource, yield* SourceControlRateLimit.CredentialScope); + const snapshot = (yield* Ref.get(snapshots)).get(key); + if (snapshot === undefined || snapshot.resetAtMs <= now) return; + const floor = options?.allowReserve === true ? 1 : snapshot.limit * RESERVE_RATIO; + if (snapshot.remaining >= floor) return; + return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({ + provider: "github", + host: host.trim().toLowerCase(), + retryAt: snapshot.resetAtMs, + }); + }, + ); + + const observe: GitHubQuota["Service"]["observe"] = Effect.fn("GitHubQuota.observe")( + function* (host, headers) { + const quota = quotaFromHeaders(headers); + if (quota === null) return; + const key = keyOf(host, quota.resource, yield* SourceControlRateLimit.CredentialScope); + yield* Ref.update(snapshots, (current) => { + const previous = current.get(key); + // The latest answer wins. GitHub serves requests from several regions, so a later answer + // can report more left in the same window, and keeping the lower one would hold + // background work at the reserve until the reset. An answer from an older window says + // nothing of this one. + if (previous !== undefined && quota.resetAtMs < previous.resetAtMs) return current; + const next = new Map(current); + next.set(key, { + limit: quota.limit, + remaining: quota.remaining, + resetAtMs: quota.resetAtMs, + }); + return next; + }); + }, + ); + + return GitHubQuota.of({ admit, observe }); +}); + +export const layer = Layer.effect(GitHubQuota, make); diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fee5d5e9546c9d6fc189006f1589c7cc3a8cd0fa --- /dev/null +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -0,0 +1,1169 @@ +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +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 Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/process"; +import * as TestClock from "effect/testing/TestClock"; + +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; +import * as GitHubApi from "./GitHubApi.ts"; +import * as GitHubCredentials from "./GitHubCredentials.ts"; +import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; +import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const processResult = ( + stdout: string, + options?: { + readonly stderr?: string; + readonly exitCode?: ChildProcessSpawner.ExitCode; + }, +): SourceControlHost.SourceControlProcessOutput => ({ + exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), + stdout, + stderr: options?.stderr ?? "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const processOutput = ( + stdout: string, + exitCode = 0, +): SourceControlHost.SourceControlProcessOutput => ({ + 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 }, +}); + +/** + * The provider 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; + readonly resolved?: string; + /** Fails the local branch listing, the first git read a checkout makes. */ + readonly gitFailure?: unknown; +}) { + const git: Array = []; + const record = + (name: string, value: A) => + (args: unknown) => + Effect.sync(() => { + git.push([name, args]); + return value; + }); + const hostGit: Partial = { + 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: () => + input.gitFailure === undefined + ? Effect.succeed([...(input.localBranches ?? [])]) + : Effect.fail(input.gitFailure as never), + resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), + }; + const host = TestSourceControlHost.layer({ + git: hostGit, + process: { + run: (args) => + Effect.succeed( + args.args[0] === "remote" + ? processOutput(input.remotes) + : processOutput(input.resolved ?? "", input.resolved ? 0 : 1), + ), + }, + }); + const layer = Layer.mergeAll( + host, + Layer.mock(GitHubApi.GitHubApi)(input.api), + NodeServices.layer, + ); + return { layer, git }; +} + +/** The provider context for a checkout whose remote is on `host`. */ +/** The provider over a mocked GitHubApi, for reads that never touch the checkout. */ +const makeProvider = (api: Partial) => + GitHubSourceControlProvider.make.pipe(Effect.provide(harness({ remotes: "", api }).layer)); + +const githubContext = (host: string) => ({ + provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` }, + remoteName: "origin", + remoteUrl: `git@${host}:acme/web.git`, +}); + +describe("GitHubSourceControlProvider 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* GitHubSourceControlProvider.make; + 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* GitHubSourceControlProvider.make; + 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", context: githubContext("github") }); + assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("names a missing repository rather than a pull request", () => { + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/gone.git"]), + api: { + rest: (input) => + Effect.fail( + new GitHubApi.GitHubApiNotFoundError({ host: input.host, operation: input.operation }), + ), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); + assert.include(error.detail, "Repository not found"); + }).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* GitHubSourceControlProvider.make; + const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); + assert.strictEqual(error._tag, "SourceControlProviderError"); + assert.include(error.detail, "No GitHub repository"); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubSourceControlProvider change request lookups by head", () => { + 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* GitHubSourceControlProvider.make; + const lookup = (headSelector: string) => + gh + .listChangeRequests({ + cwd: "/repo", + headSelector, + state: "all", + limit: 100, + context: githubContext("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* GitHubSourceControlProvider.make; + const lookups = yield* Effect.all( + Array.from({ length: 26 }, (_, index) => + gh.listChangeRequests({ + cwd: "/repo", + headSelector: `feature/${index}`, + state: "all", + limit: 100, + context: githubContext("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* GitHubSourceControlProvider.make; + const open = yield* gh + .listChangeRequests({ cwd: "/repo", headSelector: "me:main", state: "open", 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* GitHubSourceControlProvider.make; + const read = (headSelector: string) => + gh + .listChangeRequests({ cwd: "/repo", headSelector, state: "open", limit: 1 }) + .pipe(Effect.flip, Effect.forkChild); + const missing = yield* read("missing"); + yield* TestClock.adjust("500 millis"); + assert.include((yield* Fiber.join(missing)).detail, "No GitHub credential on the server"); + const limited = yield* read("limited"); + yield* TestClock.adjust("500 millis"); + const error = yield* Fiber.join(limited); + assert.include(error.detail, "rate limit exceeded"); + assert.propertyVal(error.cause, "retryAt", 123); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubSourceControlProvider.getChangeRequest", () => { + 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* GitHubSourceControlProvider.make; + assert.strictEqual( + (yield* gh.getChangeRequest({ cwd: "/repo", reference: "#42" })).number, + 42, + ); + yield* gh.getChangeRequest({ + 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* GitHubSourceControlProvider.make; + const error = yield* gh.getChangeRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); + assert.include(error.detail, "Pull request not found"); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubSourceControlProvider 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* GitHubSourceControlProvider.make; + yield* gh.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "me:feature", + title: "Title", + bodyFile, + }); + assert.strictEqual(requests[0]!.method, "POST"); + assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); + // A user's own write, so the background's reserve is not held against it. + assert.strictEqual(requests[0]!.allowReserve, true); + 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* GitHubSourceControlProvider.make; + 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("GitHubSourceControlProvider.checkoutChangeRequest", () => { + 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* GitHubSourceControlProvider.make; + yield* gh.checkoutChangeRequest({ 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* GitHubSourceControlProvider.make; + yield* Effect.flip(gh.checkoutChangeRequest({ 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* GitHubSourceControlProvider.make; + yield* gh.checkoutChangeRequest({ 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.effect("keeps git's own output out of what a failed checkout reports", () => { + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + gitFailure: new Error("fatal: /home/me/secret-path: permission denied"), + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), + ), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const error = yield* gh + .checkoutChangeRequest({ cwd: "/repo", reference: "5" }) + .pipe(Effect.flip); + assert.strictEqual(error.detail, "The pull request could not be checked out with git."); + assert.notInclude(error.message, "secret-path"); + }).pipe(Effect.provide(layer)); + }); + + it("names the local branch the way gh pr checkout does", () => { + const name = (headRefName: string, isCrossRepository: boolean) => + GitHubSourceControlProvider.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"); + }); +}); + +it("accepts active authenticated GitHub accounts when another account fails", () => { + const auth = GitHubSourceControlProvider.discovery.parseAuth( + processResult( + JSON.stringify({ + hosts: { + "github.com": [ + { + state: "success", + active: true, + host: "github.com", + login: "active-user", + tokenSource: "keyring", + gitProtocol: "ssh", + }, + { + state: "error", + active: false, + host: "github.com", + login: "stale-user", + tokenSource: "keyring", + gitProtocol: "ssh", + error: "The token in keyring is invalid.", + }, + ], + }, + }), + ), + ); + + assert.deepStrictEqual( + { + status: auth.status, + account: auth.account, + host: auth.host, + }, + { + status: "authenticated", + account: Option.some("active-user"), + host: Option.some("github.com"), + }, + ); +}); + +it("parses GitHub auth JSON from stdout when stderr has warnings", () => { + const auth = GitHubSourceControlProvider.discovery.parseAuth( + processResult( + JSON.stringify({ + hosts: { + "github.com": [ + { + state: "success", + active: true, + host: "github.com", + login: "active-user", + tokenSource: "keyring", + gitProtocol: "ssh", + }, + ], + }, + }), + { stderr: "warning: ignored diagnostic from gh\n" }, + ), + ); + + assert.deepStrictEqual( + { + status: auth.status, + account: auth.account, + host: auth.host, + }, + { + status: "authenticated", + account: Option.some("active-user"), + host: Option.some("github.com"), + }, + ); +}); + +it("parses GitHub auth status accounts by host and active state", () => { + assert.deepStrictEqual( + parseGitHubAuthStatus( + JSON.stringify({ + hosts: { + "github.com": [ + { + state: "success", + active: true, + host: "github.com", + login: "active-user", + tokenSource: "keyring", + gitProtocol: "ssh", + }, + { + state: "error", + active: false, + host: "github.com", + login: "stale-user", + tokenSource: "keyring", + gitProtocol: "ssh", + }, + ], + "github.example.test": [ + { + state: "success", + active: false, + host: "github.example.test", + login: "enterprise-user", + tokenSource: "keyring", + gitProtocol: "ssh", + }, + ], + }, + }), + ).accounts, + [ + { + host: "github.com", + account: "active-user", + authenticated: true, + active: true, + error: null, + environmentVariable: null, + }, + { + host: "github.com", + account: "stale-user", + authenticated: false, + active: false, + error: null, + environmentVariable: null, + }, + { + host: "github.example.test", + account: "enterprise-user", + authenticated: true, + active: false, + error: null, + environmentVariable: null, + }, + ], + ); +}); + +it("reports unauthenticated when GitHub JSON has accounts but none are valid", () => { + const auth = GitHubSourceControlProvider.discovery.parseAuth( + processResult( + JSON.stringify({ + hosts: { + "github.com": [ + { + state: "error", + active: true, + host: "github.com", + login: "stale-user", + tokenSource: "keyring", + gitProtocol: "ssh", + error: "The token in keyring is invalid.", + }, + ], + }, + }), + ), + ); + + assert.deepStrictEqual( + { + status: auth.status, + host: auth.host, + detail: auth.detail, + }, + { + status: "unauthenticated", + host: Option.some("github.com"), + detail: Option.some("The token in keyring is invalid."), + }, + ); +}); + +it("reports an update hint instead of unauthenticated when gh predates --json", () => { + const auth = GitHubSourceControlProvider.discovery.parseAuth( + processResult("", { + stderr: "unknown flag: --json\n\nUsage: gh auth status [flags]\n", + exitCode: ChildProcessSpawner.ExitCode(1), + }), + ); + + assert.strictEqual(auth.status, "unknown"); + assert.match( + Option.getOrElse(auth.detail, () => ""), + /2\.81\.0/, + ); +}); + +it.effect.each(["pull", "issues"])( + "resolves %s subjects on the linked host without using the checkout", + (kind) => + Effect.gen(function* () { + const provider = yield* makeProvider({ + rest: (input) => { + assert.strictEqual(input.host, "github.com"); + assert.strictEqual(input.path, "repos/owner/repo/issues/42"); + return Effect.succeed( + restResponse({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }), + ); + }, + }); + const lookup = provider.resolveLink?.({ + cwd: "/unrelated", + url: new URL(`https://github.com/owner/repo/${kind}/42`), + }); + assert.ok(lookup); + assert.deepStrictEqual(yield* lookup, { + title: "Pairing expiry", + body: "Preserve remote access", + }); + assert.strictEqual( + provider.resolveLink?.({ + cwd: "/unrelated", + url: new URL("https://github.com/owner/repo"), + }), + undefined, + ); + }), +); + +it.effect.each(["read", "decode"] as const)( + "retains the %s failure without exposing its raw contents", + (stage) => + Effect.gen(function* () { + const cause = new GitHubApi.GitHubApiResponseError({ + host: "github.com", + operation: "resolveLink", + status: 500, + }); + const provider = yield* makeProvider({ + rest: () => + stage === "read" + ? Effect.fail(cause) + : Effect.succeed({ ...restResponse(undefined), body: "private response text" }), + }); + const lookup = provider.resolveLink?.({ + cwd: "/repo", + url: new URL("https://github.com/owner/repo/issues/42"), + }); + assert.ok(lookup); + const error = yield* Effect.flip(lookup); + assert.strictEqual(error.operation, stage === "read" ? "resolveLink" : "resolveLink.decode"); + assert.strictEqual(error.detail, "The linked subject could not be read."); + assert.notInclude(error.message, "private response text"); + if (stage === "read") assert.strictEqual(error.cause, cause); + else assert.propertyVal(error.cause, "_tag", "SchemaError"); + }), +); + +const multiAccountStatus = (extra: ReadonlyArray> = []) => + processResult( + JSON.stringify({ + hosts: { + "github.com": [ + { state: "success", active: true, host: "github.com", login: "personal" }, + { state: "success", active: false, host: "github.com", login: "work" }, + ...extra, + ], + "ghe.acme.test": [ + { state: "error", active: true, host: "ghe.acme.test", login: "jm", error: "expired" }, + ], + }, + }), + ); + +it("reports every gh login and leads with the account Settings pin", () => { + const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), { + hosts: { "github.com": { account: "work", enabled: true } }, + tokens: {}, + }); + assert.deepStrictEqual(auth.account, Option.some("work")); + assert.deepStrictEqual(auth.accounts, [ + { host: "github.com", account: "personal", active: true, authenticated: true }, + { host: "github.com", account: "work", active: false, authenticated: true }, + { host: "ghe.acme.test", account: "jm", active: true, authenticated: false, error: "expired" }, + ]); +}); + +it("falls back to gh's active login when the pinned account is gone", () => { + const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), { + hosts: { "github.com": { account: "former-job", enabled: true } }, + tokens: {}, + }); + assert.deepStrictEqual(auth.account, Option.some("personal")); +}); + +it("reports unauthenticated when Settings turn off every signed-in host", () => { + const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), { + hosts: { "github.com": { enabled: false } }, + tokens: {}, + }); + assert.strictEqual(auth.status, "unauthenticated"); + assert.deepStrictEqual( + auth.detail, + Option.some("Every GitHub host gh is signed in to is turned off in Settings → Source Control."), + ); +}); + +it("names the environment token that overrides the Settings choice", () => { + const auth = GitHubSourceControlProvider.parseGitHubAuth( + multiAccountStatus([ + { + state: "success", + active: false, + host: "github.com", + login: "bot", + tokenSource: "GH_TOKEN", + }, + ]), + { hosts: { "github.com": { account: "work", enabled: true } }, tokens: {} }, + ); + assert.deepStrictEqual(auth.account, Option.some("bot")); + assert.deepStrictEqual( + auth.detail, + Option.some( + "Using GH_TOKEN from the server environment; it overrides the account chosen in Settings.", + ), + ); + assert.strictEqual(auth.accounts?.[2]?.environmentVariable, "GH_TOKEN"); +}); + +it.live.each([ + "git@code.example.test:team/project.git", + "ssh://git@code.example.test/team/project.git", + "https://code.example.test/team/project.git", +])("keeps Enterprise operations on the selected remote: %s", (remoteUrl) => { + const graphql: GitHubApi.GitHubGraphQlInput[] = []; + const rest: GitHubApi.GitHubRestInput[] = []; + const api = { + graphql: (input: GitHubApi.GitHubGraphQlInput) => + Effect.sync(() => { + graphql.push(input); + const pr = { + ...node(42, "feature", "team"), + url: "https://code.example.test/team/project/pull/42", + isCrossRepository: false, + headRepository: { name: "project", nameWithOwner: "team/project" }, + }; + return encodeJson({ + data: { + repository: + input.operation === "getPullRequest" ? { pullRequest: pr } : { h0: { nodes: [pr] } }, + }, + }); + }), + rest: (input: GitHubApi.GitHubRestInput) => + Effect.sync(() => { + rest.push(input); + return restResponse({ + full_name: "team/project", + html_url: `https://${input.host}/team/project`, + ssh_url: `git@${input.host}:team/project.git`, + default_branch: "main", + }); + }), + }; + const { layer, git } = harness({ + remotes: remotesOutput( + ["origin", "git@github.com:team/project.git"], + ["enterprise", remoteUrl], + ), + api, + }); + return Effect.gen(function* () { + const provider = yield* GitHubSourceControlProvider.make; + const fs = yield* FileSystem.FileSystem; + const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); + yield* fs.writeFileString(bodyFile, "Enterprise PR"); + const input = { + cwd: "/repo", + context: { + provider: { + kind: "github" as const, + name: "GitHub Self-Hosted", + baseUrl: "https://code.example.test", + }, + remoteName: "enterprise", + remoteUrl, + }, + }; + for (const state of ["open", "closed"] as const) { + const prs = yield* provider.listChangeRequests({ + ...input, + state, + headSelector: "feature", + }); + assert.strictEqual(prs[0]?.url, "https://code.example.test/team/project/pull/42"); + } + yield* provider.getChangeRequest({ ...input, reference: "42" }); + yield* provider.getChangeRequest({ ...input, reference: "feature" }); + yield* provider.createChangeRequest({ + ...input, + baseRefName: "main", + headSelector: "feature", + title: "PR", + bodyFile, + }); + assert.strictEqual(yield* provider.getDefaultBranch(input), "main"); + yield* provider.checkoutChangeRequest({ ...input, reference: "42" }); + yield* provider.getRepositoryCloneUrls({ ...input, repository: "team/other" }); + yield* provider.getRepositoryCloneUrls({ ...input, repository: "github.com/team/explicit" }); + assert.strictEqual(graphql.length, 5); + for (const request of graphql) { + assert.strictEqual(request.host, "code.example.test"); + assert.strictEqual(request.variables?.owner, "team"); + assert.strictEqual(request.variables?.name, "project"); + } + assert.deepStrictEqual( + rest.map(({ host, path }) => [host, path]), + [ + ["code.example.test", "repos/team/project/pulls"], + ["code.example.test", "repos/team/project"], + ["code.example.test", "repos/team/other"], + ["github.com", "repos/team/explicit"], + ], + ); + assert.deepStrictEqual(git.find(([name]) => name === "fetchRemoteTrackingBranch")?.[1], { + cwd: "/repo", + remoteName: "enterprise", + remoteBranch: "feature", + }); + }).pipe(Effect.provide(layer), Effect.provideService(HostProcessEnvironment, {}), Effect.scoped); +}); + +it.effect.each([ + { host: "github.com", resolved: "", envRepository: "", expected: "acme/web" }, + { host: "code.example.test", resolved: "", envRepository: "", expected: "acme/web" }, + { + host: "github.com", + resolved: "remote.origin.gh-resolved base", + envRepository: "", + expected: "me/web", + }, + { + host: "code.example.test", + resolved: "remote.origin.gh-resolved base", + envRepository: "", + expected: "me/web", + }, + { + host: "github.com", + resolved: "remote.upstream.gh-resolved acme/other", + envRepository: "", + expected: "acme/other", + }, + { + host: "code.example.test", + resolved: "remote.upstream.gh-resolved acme/other", + envRepository: "", + expected: "acme/other", + }, + { host: "github.com", resolved: "", envRepository: "chosen/repo", expected: "chosen/repo" }, + { + host: "code.example.test", + resolved: "", + envRepository: "chosen/repo", + expected: "chosen/repo", + }, +])( + "preserves repository precedence on $host: $resolved $envRepository", + ({ host, resolved, envRepository, expected }) => { + const targets: string[] = []; + const { layer } = harness({ + remotes: remotesOutput( + ["origin", `git@${host}:me/web.git`], + ["upstream", `git@${host}:acme/web.git`], + ), + resolved, + api: { + rest: (input) => + Effect.sync(() => { + targets.push(`${input.host}/${input.path}`); + return restResponse({ + full_name: expected, + html_url: `https://${host}/${expected}`, + ssh_url: `git@${host}:${expected}.git`, + default_branch: "main", + }); + }), + }, + }); + return Effect.gen(function* () { + const provider = yield* GitHubSourceControlProvider.make; + const fs = yield* FileSystem.FileSystem; + const bodyFile = yield* fs.makeTempFileScoped(); + yield* fs.writeFileString(bodyFile, "Body"); + const input = { + cwd: "/repo", + context: { + provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` }, + remoteName: "origin", + remoteUrl: `git@${host}:me/web.git`, + }, + }; + yield* provider.getDefaultBranch(input); + yield* provider.createChangeRequest({ + ...input, + baseRefName: "main", + headSelector: "me:feature", + title: "PR", + bodyFile, + }); + assert.deepStrictEqual(targets, [ + `${host}/repos/${expected}`, + `${host}/repos/${expected}/pulls`, + ]); + }).pipe( + Effect.provide(layer), + Effect.provideService(HostProcessEnvironment, { GH_REPO: envRepository }), + Effect.scoped, + ); + }, +); diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f442f65d712813c2bc97aadf3a152d79ac4d207 --- /dev/null +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -0,0 +1,1075 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +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 { + DEFAULT_SERVER_SETTINGS, + SourceControlProviderError, + TrimmedNonEmptyString, + type ChangeRequest, + type GitHubSettings, + type SourceControlProviderDiscoveryItem, + type SourceControlRepositoryCloneUrls, +} from "@t3tools/contracts"; +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; + +import * as GitHubApi from "./GitHubApi.ts"; +import { + decodeGitHubPullRequestEntries, + type NormalizedGitHubPullRequestRecord, +} from "./gitHubPullRequests.ts"; +import { + gitHubApiHostForRemote, + parseFetchRemotes, + parseGitHubRepositorySelector, + parsePullRequestReference, + resolveGitHubRepository, + type GitHubRepositoryLocator, +} from "./gitHubRepositoryResolution.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import { + effectiveGitHubAccount, + findAuthenticatedGitHubAccount, + parseGitHubAuthStatus, + type GitHubAuthStatusAccount, +} from "./gitHubAuthStatus.ts"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; +import { + combinedAuthOutput, + firstSafeAuthLine, + probeSourceControlProvider, + providerAuth, + type SourceControlAuthProbeInput, + type SourceControlCliDiscoverySpec, + type SourceControlManagedCliDiscoverySpec, +} from "@t3tools/source-control-core/server/discovery"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; + +const decodeLinkSubject = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ title: Schema.String, body: Schema.optional(Schema.NullOr(Schema.String)) }), + ), +); + +function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest { + return { provider: "github", ...record }; +} + +function authAccounts(accounts: ReadonlyArray) { + return accounts.map((entry) => ({ + host: entry.host, + account: entry.account, + active: entry.active, + authenticated: entry.authenticated, + ...(entry.error === null ? {} : { error: entry.error }), + ...(entry.environmentVariable === null + ? {} + : { environmentVariable: entry.environmentVariable }), + })); +} + +/** + * Reads `gh auth status --json hosts`. The headline account is the one GitHub requests will + * use: Settings can pin a login per host or turn a host off, and an environment token beats both. + */ +export function parseGitHubAuth( + input: SourceControlAuthProbeInput, + settings: GitHubSettings = DEFAULT_SERVER_SETTINGS.github, +) { + const output = combinedAuthOutput(input); + const authStatus = parseGitHubAuthStatus(input.stdout); + const hosts = [...new Set(authStatus.accounts.map((entry) => entry.host))]; + const fallback = findAuthenticatedGitHubAccount(authStatus.accounts); + // Lead with the host gh would pick, unless Settings turned it off. + const orderedHosts = fallback + ? [fallback.host, ...hosts.filter((host) => host !== fallback.host)] + : hosts; + const chosen = orderedHosts + .map((host) => effectiveGitHubAccount(host, authStatus.accounts, settings)) + .find((entry) => entry !== undefined); + const accounts = authStatus.parsed ? { accounts: authAccounts(authStatus.accounts) } : {}; + + if (chosen) { + return { + ...providerAuth({ + status: "authenticated", + account: chosen.account, + host: chosen.host, + detail: + chosen.environmentVariable === null + ? undefined + : `Using ${chosen.environmentVariable} from the server environment; it overrides the account chosen in Settings.`, + }), + ...accounts, + }; + } + + if (fallback) { + return { + ...providerAuth({ + status: "unauthenticated", + host: fallback.host, + detail: "Every GitHub host gh is signed in to is turned off in Settings → Source Control.", + }), + ...accounts, + }; + } + + const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0]; + if (authStatus.parsed) { + return { + ...providerAuth({ + status: "unauthenticated", + host: failedAccount?.host, + detail: + failedAccount?.error ?? + "Run `gh auth login` to authenticate GitHub CLI with an active account.", + }), + ...accounts, + }; + } + + // gh gained `auth status --json` in 2.81.0. Older versions reject the flag and exit + // non-zero, which reads exactly like a signed-out CLI. Name the real problem instead. + if (input.exitCode !== 0 && output.includes("unknown flag: --json")) { + return providerAuth({ + status: "unknown", + detail: + "GitHub CLI is too old to report sign-in status. Update `gh` to 2.81.0 or newer (for example `brew upgrade gh`) and rescan.", + }); + } + + if (input.exitCode !== 0) { + return providerAuth({ + status: "unauthenticated", + detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", + }); + } + + return providerAuth({ + status: "unknown", + detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", + }); +} + +export const discovery = { + type: "cli", + kind: "github", + label: "GitHub", + executable: "gh", + versionArgs: ["--version"], + authArgs: ["auth", "status", "--json", "hosts"], + parseAuth: parseGitHubAuth, + installHint: + "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", +} satisfies SourceControlCliDiscoverySpec; + +const decodeViewer = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ login: Schema.String })), +); + +/** The environment variable gh would take a github.com token from, if one is set. */ +function environmentTokenVariable(environment: NodeJS.ProcessEnv): string | null { + return ["GH_TOKEN", "GITHUB_TOKEN"].find((name) => environment[name]?.trim()) ?? null; +} + +/** + * GitHub is usable with a token saved in Settings, one from the environment, or `gh` to hand one + * over. Reads the + * GitHub settings on every probe, so a saved account choice shows on rescan. An environment + * token is checked against the API, since `gh auth status` may not know it. + */ +export const makeDiscovery = Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const sourceControlHost = yield* SourceControlHost.SourceControlHost; + const process = sourceControlHost.process; + const environment = yield* HostProcessEnvironment; + + return { + type: "managed-cli", + kind: discovery.kind, + label: discovery.label, + installHint: discovery.installHint, + probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) { + const settings = yield* sourceControlHost.settings.get.pipe( + Effect.map((current) => current.github), + Effect.orElseSucceed(() => DEFAULT_SERVER_SETTINGS.github), + ); + const cli = yield* probeSourceControlProvider({ + cwd, + process, + spec: { ...discovery, parseAuth: (input) => parseGitHubAuth(input, settings) }, + }); + // A token saved in Settings wins over the environment, which wins over gh. + const savedToken = (settings.tokens["github.com"] ?? "").trim() !== ""; + const variable = environmentTokenVariable(environment); + const tokenSource = savedToken ? "the token saved in Settings" : variable; + // A host turned off in Settings stays off even with a token. + if (tokenSource === null || settings.hosts["github.com"]?.enabled === false) return cli; + const viewer = yield* api + .rest({ host: "github.com", operation: "discovery", path: "user" }) + .pipe(Effect.result); + const login = Result.isSuccess(viewer) + ? Option.getOrUndefined(decodeViewer(viewer.success.body))?.login + : undefined; + // The per-host logins stay, so the account picker still lists every host gh knows. + const accounts = cli.auth.accounts === undefined ? {} : { accounts: cli.auth.accounts }; + return { + ...cli, + status: "available" as const, + auth: { + ...(login !== undefined + ? providerAuth({ + status: "authenticated", + account: login, + host: "github.com", + detail: savedToken + ? "Using the token saved in Settings; it overrides GH_TOKEN and the gh login." + : `Using ${variable} from the server environment; it overrides the account chosen in Settings.`, + }) + : Result.isFailure(viewer) && viewer.failure._tag !== "GitHubApiAuthenticationError" + ? // Only a refusal says the token is bad; a network error or a pause says nothing. + providerAuth({ + status: "unknown", + host: "github.com", + detail: `Could not check ${savedToken ? tokenSource : `the token in ${tokenSource}`}: ${viewer.failure.message}`, + }) + : providerAuth({ + status: "unauthenticated", + host: "github.com", + detail: savedToken + ? "GitHub refused the token saved in Settings. Replace or remove it in Settings → Source Control." + : `GitHub refused the token in ${tokenSource}. Replace it, or unset it to use \`gh auth login\`.`, + })), + ...accounts, + }, + } satisfies SourceControlProviderDiscoveryItem; + }), + /** + * Claims a custom host GitHub holds a credential for, from whichever source supplies it + * (Settings, the environment or gh). A host turned off in Settings is claimed too, so its + * error says so instead of "unsupported host". + */ + refineUnknownRemote: ({ context }) => { + // Identity resolution also probes providers before a web base URL is known. + const url = URL.parse(context.provider.baseUrl); + if (!url?.host) return Effect.succeed(null); + return api.credential(url.host).pipe( + Effect.as(true), + Effect.catchTags({ GitHubHostDisabledError: () => Effect.succeed(true) }), + Effect.orElseSucceed(() => false), + Effect.map((known) => + known + ? ({ + kind: "github", + name: "GitHub Self-Hosted", + baseUrl: context.provider.baseUrl, + } as const) + : null, + ), + ); + }, + } satisfies SourceControlManagedCliDiscoverySpec; +}); + +/** + * Why a GitHub read or write failed, in words a user can act on. The provider methods attach the + * operation and request context; `cause` keeps the raw failure for logs, never for transport. + */ +class GitHubFailure extends Data.TaggedError("GitHubFailure")<{ + readonly detail: string; + readonly cause: unknown; +}> {} + +const failure = (detail: string, cause?: unknown) => new GitHubFailure({ detail, cause }); + +const PULL_REQUEST_NOT_FOUND = "Pull request not found. Check the PR number or URL and try again."; +const REPOSITORY_NOT_FOUND = "Repository not found. Check the owner and name and try again."; + +/** `notFound` names what was missing, which only the operation knows. */ +function fromGitHubApiError( + error: GitHubApi.GitHubApiError, + notFound = PULL_REQUEST_NOT_FOUND, +): GitHubFailure { + switch (error._tag) { + case "GitHubCliMissingError": + return failure( + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", + error, + ); + case "GitHubNotSignedInError": + case "GitHubHostDisabledError": + // A missing or turned-off credential already says what to do about it. + return failure(error.message, error); + case "GitHubApiAuthenticationError": + return failure( + "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.", + error, + ); + case "GitHubApiRateLimitError": + case "SourceControlRateLimitPausedError": + return failure( + "GitHub API rate limit exceeded. Requests resume when the limit resets.", + error, + ); + case "GitHubApiNotFoundError": + return failure(notFound, error); + case "GitHubCliFailedError": + case "GitHubApiResponseError": + case "GitHubApiRequestError": + // GitHub's own reason ("A pull request already exists…") when it gave one. + return failure(error.message.trim() || "GitHub request failed.", error); + } +} + +const notFound = (cause: string) => failure(PULL_REQUEST_NOT_FOUND, new Error(cause)); + +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, +): SourceControlRepositoryCloneUrls { + 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 host: string; + readonly owner: string; + readonly name: string; + readonly headRefName: string; + readonly state: PullRequestListState; + readonly limit: number; + readonly allowReserve: boolean; + }, + ReadonlyArray, + GitHubFailure +> {} + +/** 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) })), + }), + }), +); + +/** 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; +} + +/** The checkout's GitHub API host, from the remote the caller resolved the provider from. */ +const contextHost = (context: SourceControlProvider.SourceControlProviderContext | undefined) => + context === undefined ? undefined : new URL(context.provider.baseUrl).host; + +export const make = Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const sourceControlHost = yield* SourceControlHost.SourceControlHost; + const process = sourceControlHost.process; + const environment = yield* HostProcessEnvironment; + const git = sourceControlHost.git; + const fileSystem = yield* FileSystem.FileSystem; + + const gitRead = (cwd: string, args: ReadonlyArray) => + process.run({ + operation: "GitHubSourceControlProvider.resolveRepository", + command: "git", + args, + cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + }); + + /** + * Uses GH_REPO, then gh's remote selection on the detected host. The provider context is + * a fallback when the checkout's remotes cannot resolve that repository. + */ + const resolveRepository = Effect.fn("GitHubSourceControlProvider.resolveRepository")( + function* (input: { + readonly cwd: string; + readonly host?: string | undefined; + readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; + }) { + const envRepository = environment.GH_REPO?.trim(); + const hostHint = input.host ?? contextHost(input.context); + const defaultHost = (hostHint ?? 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 { host, locator } = resolveGitHubRepository({ + remotes: remotes?.exitCode === 0 ? remotes.stdout : "", + resolved: resolved?.exitCode === 0 ? resolved.stdout : "", + hostHint, + defaultHost, + }); + if (locator !== null) return locator; + if (input.context) { + const remote = normalizeGitRemoteUrl(input.context.remoteUrl); + const fallback = parseGitHubRepositorySelector( + `${host}/${remote.slice(remote.indexOf("/") + 1)}`, + host, + ); + if (fallback !== null) return fallback; + } + return yield* failure( + `No GitHub repository on ${host} was found among this checkout's git remotes.`, + ); + }, + ); + + const graphqlJson = ( + input: GitHubApi.GitHubGraphQlInput, + decode: (raw: string) => Result.Result, + decodeDetail: string, + ) => + api.graphql(input).pipe( + Effect.mapError(fromGitHubApiError), + Effect.flatMap((raw) => { + const decoded = decode(raw); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(failure(decodeDetail, decoded.failure)); + }), + ); + + const rest = (input: GitHubApi.GitHubRestInput, notFoundDetail?: string) => + api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(error, notFoundDetail))); + + 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 { host, owner, name, allowReserve } = first.request; + const query = buildPullRequestsByHeadQuery(entries.map((entry) => entry.request)); + return graphqlJson( + { + host, + operation: "listPullRequestsByHead", + query: query.document, + variables: { owner, name, ...query.variables }, + 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, + "GitHub returned an invalid change request list.", + ).pipe( + Effect.flatMap((decoded) => + decoded.data.repository === null + ? Effect.fail(notFound("The repository could not be read.")) + : Effect.succeed(decoded.data.repository), + ), + 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), + ); + + /** + * 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. + */ + const listByHead = Effect.fn("GitHubSourceControlProvider.listByHead")(function* (input: { + readonly cwd: string; + readonly headSelector: string; + readonly state: PullRequestListState; + readonly limit: number; + readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; + readonly host?: string | undefined; + readonly allowReserve: boolean; + }) { + const locator = yield* resolveRepository(input); + 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({ + ...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 readPullRequest = Effect.fn("GitHubSourceControlProvider.readPullRequest")( + function* (input: { + readonly cwd: string; + readonly reference: string; + readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; + readonly host?: 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 lookup = (state: PullRequestListState) => + listByHead({ + cwd: input.cwd, + headSelector: parsed.headSelector, + state, + limit: 1, + host: input.host, + context: input.context, + allowReserve: true, + }); + const [open] = yield* lookup("open"); + const found = open ?? (yield* lookup("all"))[0]; + if (found === undefined) return yield* notFound("No pull request has this head branch."); + return found; + } + const locator = parsed.kind === "url" ? parsed.locator : yield* resolveRepository(input); + const decodeDetail = "GitHub returned an invalid pull request."; + const decoded = yield* graphqlJson( + { + host: locator.host, + operation: "getPullRequest", + query: PULL_REQUEST_BY_NUMBER_QUERY, + variables: { owner: locator.owner, name: locator.name, number: parsed.number }, + allowReserve: true, + }, + decodePullRequestByNumber, + decodeDetail, + ); + const node = decoded.data.repository?.pullRequest; + if (node == null) return yield* notFound("The pull request does not exist."); + const [record] = decodeGitHubPullRequestEntries([node]); + if (record === undefined) + return yield* failure(decodeDetail, new Error("Malformed pull request.")); + return record; + }, + ); + + const readRepository = Effect.fn("GitHubSourceControlProvider.readRepository")(function* ( + locator: GitHubRepositoryLocator, + ) { + const response = yield* rest( + { + host: locator.host, + operation: "getRepository", + path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, + allowReserve: true, + }, + REPOSITORY_NOT_FOUND, + ); + const decoded = decodeRawRepository(response.body); + if (Result.isFailure(decoded)) { + return yield* failure("GitHub returned an invalid repository.", decoded.failure); + } + return decoded.success; + }); + + const readViewerLogin = Effect.fn("GitHubSourceControlProvider.readViewerLogin")(function* ( + host: string, + ) { + const response = yield* rest({ + host, + operation: "getViewer", + path: "user", + allowReserve: true, + }); + const decoded = decodeViewerLogin(response.body); + if (Result.isFailure(decoded)) { + return yield* failure("GitHub request failed.", decoded.failure); + } + return decoded.success.login; + }); + + // Git and filesystem errors can carry arguments, paths and stderr, so the detail a client + // sees is fixed and the raw error stays in `cause`. + const gitFailure = (cause: unknown) => + failure("The pull request could not be checked out with git.", cause); + + const runGit = (cwd: string, operation: string, args: ReadonlyArray) => + git.execute({ + operation: `GitHubSourceControlProvider.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 = Effect.fn("GitHubSourceControlProvider.checkoutPullRequest")( + function* (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + readonly context?: SourceControlProvider.SourceControlProviderContext; + }) { + const reference = parsePullRequestReference(input.reference); + const pullRequest = yield* readPullRequest(input); + const base = reference.kind === "url" ? reference.locator : yield* resolveRepository(input); + 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), + ); + const remoteFor = (nameWithOwner: string) => + parseFetchRemotes(remotes).find( + (remote) => + (gitHubApiHostForRemote(remote.url) ?? + normalizeGitRemoteUrl(remote.url).split("/")[0]) === base.host && + normalizeGitRemoteUrl(remote.url).split("/").slice(1).join("/") === + nameWithOwner.toLowerCase(), + )?.name ?? 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(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* failure("The fork is gone."); + const known = remoteFor(headNameWithOwner); + if (known !== null) return known; + const [owner, name] = headNameWithOwner.split("/"); + const fork = yield* readRepository({ 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))).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), + ); + + 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)); + }, + ); + + /** Reports a failure against the operation and request that made it, with transport-safe context. */ + const providerError = + ( + operation: string, + cwd: string, + context?: { readonly reference?: string; readonly repository?: string }, + ) => + (error: GitHubFailure) => + new SourceControlProviderError({ + provider: "github", + operation, + cwd, + ...(context?.reference === undefined + ? {} + : { + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + context.reference, + ), + }), + ...(context?.repository === undefined + ? {} + : { + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + context.repository, + ), + }), + detail: error.detail, + cause: error.cause, + }); + + const readLinkSubject = Effect.fn("GitHubSourceControlProvider.readLinkSubject")(function* ( + input: { readonly cwd: string; readonly url: URL }, + endpoint: string, + ) { + const result = yield* api + .rest({ + host: input.url.host, + operation: "resolveLink", + path: endpoint, + maxResponseBytes: 1_000_000, + }) + .pipe( + Effect.timeout("3 seconds"), + Effect.mapError( + (cause) => + new SourceControlProviderError({ + provider: "github", + operation: "resolveLink", + cwd: input.cwd, + detail: "The linked subject could not be read.", + cause, + }), + ), + ); + const subject = yield* decodeLinkSubject(result.body).pipe( + Effect.mapError( + (cause) => + new SourceControlProviderError({ + provider: "github", + operation: "resolveLink.decode", + cwd: input.cwd, + detail: "The linked subject could not be read.", + cause, + }), + ), + ); + return { title: subject.title, body: subject.body ?? null }; + }); + + return SourceControlProvider.SourceControlProvider.of({ + kind: "github", + resolveLink: (input) => { + // Automatic enrichment must not send ambient CLI credentials to a host from message text. + if (input.url.host !== "github.com") return undefined; + const match = /^\/([\w.-]+)\/([\w.-]+)\/(?:pull|issues)\/([1-9]\d*)(?:\/.*)?$/.exec( + input.url.pathname, + ); + if (!match) return undefined; + return readLinkSubject(input, `repos/${match[1]}/${match[2]}/issues/${match[3]}`); + }, + listChangeRequests: (input) => + // An open lookup is a user waiting on a status; the rest may be a background sweep. + (input.state === "open" ? Effect.succeed(true) : GitHubApi.AllowGitHubReserve).pipe( + Effect.flatMap((allowReserve) => + listByHead({ + cwd: input.cwd, + headSelector: input.headSelector, + state: input.state, + limit: input.limit ?? (input.state === "open" ? 1 : 20), + host: contextHost(input.context), + context: input.context, + allowReserve, + }), + ), + Effect.map((records) => records.map(toChangeRequest)), + Effect.mapError( + providerError("listChangeRequests", input.cwd, { reference: input.headSelector }), + ), + ), + getChangeRequest: (input) => + readPullRequest({ ...input, host: contextHost(input.context) }).pipe( + Effect.map(toChangeRequest), + Effect.mapError( + providerError("getChangeRequest", input.cwd, { reference: input.reference }), + ), + ), + createChangeRequest: (input) => + Effect.gen(function* () { + const locator = yield* resolveRepository(input); + const body = yield* fileSystem + .readFileString(input.bodyFile) + .pipe( + Effect.mapError((cause) => + failure("The pull request description could not be read.", cause), + ), + ); + yield* rest({ + host: locator.host, + operation: "createPullRequest", + method: "POST", + // A user's own write may spend the reserve the background leaves for it. + allowReserve: true, + 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.baseRefName, + head: input.headSelector, + title: input.title, + body, + maintainer_can_modify: true, + }, + }); + }).pipe( + Effect.mapError( + providerError("createChangeRequest", input.cwd, { reference: input.headSelector }), + ), + ), + getRepositoryCloneUrls: (input) => + Effect.gen(function* () { + const fallbackHost = (yield* resolveRepository(input).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* failure("Repositories are named owner/name."); + return repositoryCloneUrls(yield* readRepository(locator)); + }).pipe( + Effect.mapError( + providerError("getRepositoryCloneUrls", input.cwd, { repository: input.repository }), + ), + ), + createRepository: (input) => + Effect.gen(function* () { + const locator = parseGitHubRepositorySelector( + input.repository, + (environment.GH_HOST ?? "github.com").toLowerCase(), + ); + const viewer = locator === null ? null : yield* readViewerLogin(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( + { + host, + operation: "createRepository", + method: "POST", + allowReserve: true, + path: + isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, + body: { name, private: input.visibility === "private" }, + }, + // GitHub answers 404 for an organization the account cannot create repositories in. + `No organization named ${owner ?? "that"} that this account can create repositories in.`, + ); + const decoded = decodeRawRepository(response.body); + if (Result.isFailure(decoded)) { + return yield* failure("GitHub returned an invalid repository.", decoded.failure); + } + return repositoryCloneUrls(decoded.success); + }).pipe( + Effect.mapError( + providerError("createRepository", input.cwd, { repository: input.repository }), + ), + ), + getDefaultBranch: (input) => + Effect.gen(function* () { + const locator = yield* resolveRepository({ + cwd: input.cwd, + host: contextHost(input.context), + context: input.context, + }); + const repository = yield* readRepository(locator); + const branch = repository.default_branch?.trim() ?? ""; + return branch.length > 0 ? branch : null; + }).pipe(Effect.mapError(providerError("getDefaultBranch", input.cwd))), + checkoutChangeRequest: (input) => + checkoutPullRequest(input).pipe( + Effect.mapError( + providerError("checkoutChangeRequest", input.cwd, { reference: input.reference }), + ), + ), + }); +}); diff --git a/packages/source-control-github/src/server/driver.ts b/packages/source-control-github/src/server/driver.ts new file mode 100644 index 0000000000000000000000000000000000000000..d57ba07b2017016ac86b86cbc6f634d1990e8cdf --- /dev/null +++ b/packages/source-control-github/src/server/driver.ts @@ -0,0 +1,20 @@ +/** + * The GitHub driver: repository operations and discovery through `gh` and git, and pull requests + * through the GitHub REST and GraphQL APIs. + * + * @module source-control-github/server/driver + */ +import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import * as Effect from "effect/Effect"; + +import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; +import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; + +export const driver = defineSourceControlDriver({ + kind: "github", + make: Effect.all({ + sourceControl: GitHubSourceControlProvider.make, + discovery: GitHubSourceControlProvider.makeDiscovery, + pullRequests: GitHubPullRequestProvider.make, + }), +}); diff --git a/packages/source-control-github/src/server/gitHubAuthStatus.ts b/packages/source-control-github/src/server/gitHubAuthStatus.ts new file mode 100644 index 0000000000000000000000000000000000000000..7321ead2d4ec1f83bba46fb207ad3d75f68cf4af --- /dev/null +++ b/packages/source-control-github/src/server/gitHubAuthStatus.ts @@ -0,0 +1,103 @@ +import type { GitHubSettings } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const GitHubAuthStatusAccountSchema = Schema.Struct({ + state: Schema.String, + error: Schema.optional(Schema.String), + active: Schema.Boolean, + host: Schema.String, + login: Schema.String, + tokenSource: Schema.optional(Schema.String), +}); + +const GitHubAuthStatusSchema = Schema.Struct({ + hosts: Schema.Record(Schema.String, Schema.Array(GitHubAuthStatusAccountSchema)), +}); + +const decodeGitHubAuthStatusJson = Schema.decodeUnknownOption( + Schema.fromJsonString(GitHubAuthStatusSchema), +); + +export interface GitHubAuthStatusAccount { + readonly host: string; + readonly account: string; + readonly authenticated: boolean; + readonly active: boolean; + readonly error: string | null; + /** The variable (GH_TOKEN and kin) the login came from; it overrides every stored login. */ + readonly environmentVariable: string | null; +} + +export interface GitHubAuthStatus { + readonly parsed: boolean; + readonly accounts: ReadonlyArray; +} + +const ENVIRONMENT_TOKEN_SOURCE = /^(?:GH|GITHUB)_(?:ENTERPRISE_)?TOKEN$/u; + +function nonEmptyString(value: string): string | null { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function parseGitHubAuthStatus(text: string): GitHubAuthStatus { + return Option.match(decodeGitHubAuthStatusJson(text), { + onNone: () => ({ parsed: false, accounts: [] }), + onSome: (status) => + ({ + parsed: true, + accounts: Object.values(status.hosts).flatMap((accounts) => + accounts.flatMap((account) => { + const host = nonEmptyString(account.host); + const login = nonEmptyString(account.login); + if (host === null || login === null) return []; + + return [ + { + host: host.toLowerCase(), + account: login, + authenticated: account.state === "success", + active: account.active, + error: account.error?.trim() || null, + environmentVariable: ENVIRONMENT_TOKEN_SOURCE.test(account.tokenSource ?? "") + ? (account.tokenSource ?? null) + : null, + }, + ]; + }), + ), + }) satisfies GitHubAuthStatus, + }); +} + +export function findAuthenticatedGitHubAccount( + accounts: ReadonlyArray, +): GitHubAuthStatusAccount | undefined { + return ( + accounts.find((account) => account.authenticated && account.active) ?? + accounts.find((account) => account.authenticated) + ); +} + +/** + * The login GitHub requests for a host will use, honoring Settings: an environment token + * first (gh's own precedence), then the pinned account, then gh's active login. + * Returns undefined when the host is turned off or has no usable login. + */ +export function effectiveGitHubAccount( + host: string, + accounts: ReadonlyArray, + settings: GitHubSettings, +): GitHubAuthStatusAccount | undefined { + const choice = settings.hosts[host]; + if (choice?.enabled === false) return undefined; + const usable = accounts.filter((account) => account.host === host && account.authenticated); + return ( + usable.find((account) => account.environmentVariable !== null) ?? + (choice?.account === undefined + ? undefined + : usable.find((account) => account.account === choice.account)) ?? + findAuthenticatedGitHubAccount(usable) + ); +} diff --git a/packages/source-control-github/src/server/gitHubConditionalChecks.test.ts b/packages/source-control-github/src/server/gitHubConditionalChecks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a621e0e9ebb0c3f40fc13096a8963b74d62214f9 --- /dev/null +++ b/packages/source-control-github/src/server/gitHubConditionalChecks.test.ts @@ -0,0 +1,245 @@ +import { expect, it } from "@effect/vitest"; +import * as TestClock from "effect/testing/TestClock"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Redacted from "effect/Redacted"; +import type { PullRequestCheck } from "@t3tools/contracts"; + +import * as GitHubApi from "./GitHubApi.ts"; +import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const reference = { cwd: "/repo", repository: "acme/web", host: "github.com", number: 1 }; +const credential = { + host: "github.com", + token: Redacted.make("token"), + credentialFingerprint: "one", +}; + +it.effect( + "reuses unchanged checks and catches reruns, later pages, fork approvals, pushes and account changes", + () => + Effect.gen(function* () { + let sha = "a".repeat(40); + let changed = ""; + let reads = 0; + const requests: string[] = []; + const known: Array = []; + const revalidate = yield* makeChecksRevalidator.pipe( + Effect.provide( + Layer.mock(GitHubApi.GitHubApi)({ + rest: ({ path, ifNoneMatch }) => + Effect.sync(() => { + requests.push(path); + const head = path.endsWith("/pulls/1"); + const modified = + ifNoneMatch === undefined || (path.includes(changed) && changed !== ""); + const next = path.includes("check-runs") && path.endsWith("page=1"); + const runs = path.includes("/actions/runs"); + return { + status: modified ? 200 : 304, + headers: modified + ? { + etag: `"${sha}-${changed}"`, + ...(next ? { link: '; rel="next"' } : {}), + } + : {}, + body: !modified + ? "" + : head + ? encodeJson({ head: { sha, repo: { id: 2 } }, base: { repo: { id: 1 } } }) + : runs + ? encodeJson({ + workflow_runs: [ + { id: 9, status: "completed", conclusion: "action_required" }, + ], + }) + : "{}", + truncated: false, + invalidUtf8: false, + }; + }), + }), + ), + ); + const read = Effect.gen(function* () { + known.push(yield* KnownWorkflowRuns); + reads++; + return { + state: "open" as const, + checks: [ + { name: "build", status: "success", description: null, url: null }, + ] satisfies PullRequestCheck[], + headSha: sha, + workflowApprovalsRequired: 0, + }; + }); + const poll = (identity = credential) => + revalidate(reference, read).pipe( + Effect.provideService(GitHubApi.PinnedGitHubCredential, identity), + ); + yield* poll(); + expect(reads).toBe(1); + // The fork's runs were just confirmed, so the read is handed them instead of listing them. + expect(known[0]).toEqual({ + headSha: sha, + runs: [{ id: 9, status: "completed", conclusion: "action_required" }], + }); + requests.length = 0; + yield* poll(); + expect(reads).toBe(1); + expect(requests).toHaveLength(5); + for (const endpoint of [ + "check-runs?filter=all&per_page=100&page=2", + "/status", + "/actions/runs", + "/pulls/1", + ]) { + changed = endpoint; + yield* poll(); + changed = ""; + yield* poll(); + } + expect(reads).toBe(5); + sha = "b".repeat(40); + changed = "/pulls/1"; + requests.length = 0; + yield* poll(); + expect(reads).toBe(6); + expect( + requests + .filter((endpoint) => endpoint.includes("/commits/")) + .every((endpoint) => endpoint.includes(sha)), + ).toBe(true); + changed = ""; + yield* poll({ ...credential, credentialFingerprint: "two" }); + expect(reads).toBe(7); + yield* TestClock.adjust("5 minutes"); + yield* poll(); + expect(reads).toBe(8); + }), +); + +it.effect("does not retain failed or incomplete reads, and supports hosts without ETags", () => + Effect.gen(function* () { + const sha = "a".repeat(40); + let etags = true; + let unavailable = false; + let fail = true; + let complete = true; + let reads = 0; + const revalidate = yield* makeChecksRevalidator.pipe( + Effect.provide( + Layer.mock(GitHubApi.GitHubApi)({ + rest: ({ path, ifNoneMatch }) => + unavailable + ? Effect.fail( + new GitHubApi.GitHubApiResponseError({ + host: "github.com", + operation: "revalidateChecks", + status: 502, + }), + ) + : Effect.succeed( + ifNoneMatch !== undefined && etags + ? { status: 304, headers: {}, body: "", truncated: false, invalidUtf8: false } + : { + status: 200, + headers: etags ? { etag: '"one"' } : {}, + body: path.endsWith("/pulls/1") + ? encodeJson({ + head: { sha, repo: { id: 1 } }, + base: { repo: { id: 1 } }, + }) + : "{}", + truncated: false, + invalidUtf8: false, + }, + ), + }), + ), + ); + const read = Effect.suspend(() => { + reads++; + return fail + ? Effect.fail( + new GitHubApi.GitHubApiResponseError({ + host: "github.com", + operation: "read", + status: 500, + }), + ) + : Effect.succeed({ + state: "open" as const, + checks: [], + headSha: sha, + ...(complete ? { workflowApprovalsRequired: 0 } : {}), + }); + }); + const poll = () => + revalidate(reference, read).pipe( + Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), + ); + yield* poll().pipe(Effect.flip); + fail = false; + complete = false; + yield* poll(); + yield* poll(); + expect(reads).toBe(3); + complete = true; + yield* poll(); + yield* poll(); + expect(reads).toBe(4); + yield* TestClock.adjust("5 minutes"); + complete = false; + yield* poll(); + yield* poll(); + expect(reads).toBe(6); + complete = true; + unavailable = true; + yield* poll(); + expect(reads).toBe(7); + unavailable = false; + yield* poll(); + yield* poll(); + expect(reads).toBe(8); + etags = false; + yield* poll(); + yield* poll(); + expect(reads).toBe(10); + }), +); + +it.effect("falls back when REST checks are unavailable without retrying unsupported probes", () => + Effect.gen(function* () { + let probes = 0; + let reads = 0; + const revalidate = yield* makeChecksRevalidator.pipe( + Effect.provide( + Layer.mock(GitHubApi.GitHubApi)({ + rest: () => { + probes++; + return Effect.fail( + new GitHubApi.GitHubApiNotFoundError({ + host: "github.com", + operation: "revalidateChecks", + }), + ); + }, + }), + ), + ); + const read = Effect.sync(() => { + reads++; + return { state: "open" as const, checks: [] }; + }); + for (let tick = 0; tick < 2; tick++) + yield* revalidate(reference, read).pipe( + Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), + ); + expect(probes).toBe(1); + expect(reads).toBe(2); + }), +); diff --git a/packages/source-control-github/src/server/gitHubConditionalChecks.ts b/packages/source-control-github/src/server/gitHubConditionalChecks.ts new file mode 100644 index 0000000000000000000000000000000000000000..afeb14a0935e8edfb6d50c8d273e89add2a87505 --- /dev/null +++ b/packages/source-control-github/src/server/gitHubConditionalChecks.ts @@ -0,0 +1,195 @@ +import * as Clock from "effect/Clock"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts"; + +import * as GitHubApi from "./GitHubApi.ts"; +import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts"; +import type { GitHubPullRequestApiError } from "./GitHubPullRequestApi.ts"; +import type { ProviderRepositoryRef } from "@t3tools/source-control-core/server/PullRequestProvider"; + +const HeadSchema = Schema.Struct({ + head: Schema.Struct({ + sha: Schema.String.check(Schema.isPattern(/^[a-f0-9]{40,64}$/)), + // Null once the fork a pull request came from has been deleted. + repo: Schema.NullOr(Schema.Struct({ id: PositiveInt })), + }), + base: Schema.Struct({ repo: Schema.Struct({ id: PositiveInt }) }), +}); +const decodeHead = Schema.decodeUnknownEffect(Schema.fromJsonString(HeadSchema)); + +const WorkflowRunSchema = Schema.Struct({ + id: Schema.Int, + name: Schema.optional(Schema.NullOr(Schema.String)), + html_url: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional(Schema.NullOr(Schema.String)), + conclusion: Schema.optional(Schema.NullOr(Schema.String)), + head_branch: Schema.optional(Schema.NullOr(Schema.String)), +}); +const decodeWorkflowRuns = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ workflow_runs: Schema.Array(WorkflowRunSchema) })), +); + +export type KnownWorkflowRun = typeof WorkflowRunSchema.Type; + +/** + * Every `pull_request` workflow run of one head, as the revalidator just confirmed them against + * GitHub. A read running under it filters these rather than asking for the runs again. + */ +export const KnownWorkflowRuns = Context.Reference<{ + readonly headSha: string; + readonly runs: ReadonlyArray; +} | null>("@t3tools/source-control-github/server/gitHubConditionalChecks/KnownWorkflowRuns", { + defaultValue: () => null, +}); + +type Validator = { etag: string | undefined; next: boolean; body: string }; + +/** A host that answers these with one of them has no conditional REST for it at all. */ +const UNSUPPORTED_STATUSES = new Set([404, 405, 501]); + +export const makeChecksRevalidator = Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const entries = yield* Cache.makeWith( + (_key: string) => + Effect.sync(() => ({ + gate: Semaphore.makeUnsafe(1), + validators: new Map(), + head: null as typeof HeadSchema.Type | null, + value: null as PullRequestChecks | null, + readAt: 0, + supported: true, + })), + { capacity: 128, timeToLive: () => "30 minutes" }, + ); + return ( + input: ProviderRepositoryRef & { readonly number: number }, + read: Effect.Effect< + Pick & { + workflowApprovalsRequired?: number; + }, + GitHubPullRequestApiError + >, + ) => + Effect.gen(function* () { + const credential = yield* GitHubApi.PinnedGitHubCredential; + if (credential === null) return yield* read; + const key = `${credential.credentialFingerprint}\0${input.host}\0${input.repository}\0${input.number}`; + const entry = yield* Cache.get(entries, key); + return yield* entry.gate.withPermit( + Effect.gen(function* () { + if (!entry.supported) return yield* read; + const fail = (status: number) => + new GitHubApi.GitHubApiResponseError({ + host: input.host, + operation: "revalidateChecks", + status, + }); + /** Whether the endpoint has a next page, or null where the host cannot say. */ + const get = (endpoint: string, head = false) => + Effect.gen(function* () { + const previous = entry.validators.get(endpoint); + const response = yield* api + .rest({ + host: input.host, + operation: "revalidateChecks", + path: endpoint, + ...(previous?.etag ? { ifNoneMatch: previous.etag } : {}), + }) + .pipe( + Effect.catchTags({ + GitHubApiNotFoundError: () => + Effect.sync(() => { + entry.supported = false; + return null; + }), + GitHubApiResponseError: (error) => + Effect.sync(() => { + entry.supported = !UNSUPPORTED_STATUSES.has(error.status); + return null; + }), + }), + ); + if (response === null) { + entry.value = null; + return null; + } + if (response.status === 304 && previous) return previous.next; + if (response.status !== 200 || response.truncated) { + return yield* fail(response.status); + } + entry.value = null; + if (head) { + const decoded = yield* decodeHead(response.body).pipe( + Effect.mapError(() => fail(response.status)), + ); + if (entry.head?.head.sha !== decoded.head.sha) entry.validators.clear(); + entry.head = decoded; + } + const validator = { + etag: response.headers["etag"]?.trim(), + next: /rel="next"/.test(response.headers["link"] ?? ""), + body: response.body, + }; + entry.validators.set(endpoint, validator); + return validator.next; + }); + const root = `repos/${input.repository}`; + if ((yield* get(`${root}/pulls/${input.number}`, true)) === null) return yield* read; + const head = entry.head; + if (head === null) return yield* fail(200); + const runsEndpoint = + head.head.repo?.id !== head.base.repo.id + ? `${root}/actions/runs?head_sha=${head.head.sha}&event=pull_request` + : null; + const endpoints = [ + `${root}/commits/${head.head.sha}/check-runs?filter=all`, + `${root}/commits/${head.head.sha}/status`, + ...(runsEndpoint === null ? [] : [runsEndpoint]), + ]; + const runPages: Array = []; + for (const endpoint of endpoints) { + for (let page = 1; ; page++) { + if (page > 100) return yield* fail(200); + const paged = `${endpoint}${endpoint.includes("?") ? "&" : "?"}per_page=100&page=${page}`; + const next = yield* get(paged); + if (next === null) return yield* read; + if (endpoint === runsEndpoint) runPages.push(paged); + if (!next) break; + } + } + const now = yield* Clock.currentTimeMillis; + if (entry.value !== null && now - entry.readAt < 5 * 60_000) return entry.value; + // Every page of the head's runs was just confirmed current, so the read filters them + // rather than listing the runs a second time. + const pages = runPages.map((endpoint) => + decodeWorkflowRuns(entry.validators.get(endpoint)?.body ?? ""), + ); + const known = + runsEndpoint !== null && pages.every(Option.isSome) + ? { + headSha: head.head.sha, + runs: pages.flatMap((page) => + Option.isSome(page) ? page.value.workflow_runs : [], + ), + } + : null; + const fresh = yield* read.pipe(Effect.provideService(KnownWorkflowRuns, known)); + const value = { + state: fresh.state, + checks: fresh.checks, + }; + entry.value = + fresh.workflowApprovalsRequired !== undefined && fresh.headSha === head.head.sha + ? value + : null; + entry.readAt = now; + return value; + }), + ); + }); +}); diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..06e25c6e4709a90c6238fce99546b176cfa04d1b --- /dev/null +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts @@ -0,0 +1,2111 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildPullRequestSummariesGraphQlQuery, + buildPullRequestWatchFingerprintsGraphQlQuery, + decodePullRequestSummariesJson, + decodePullRequestWatchFingerprintsJson, + buildReviewSubmission, + buildPullRequestStackMembershipsGraphQlQuery, + decodePullRequestStackMembershipsJson, + buildReviewerRequest, + buildSetFilesViewedGraphQlMutation, + decodePullRequestActivityJson, + decodePullRequestDetailJson, + decodePullRequestFilesJson, + decodePullRequestFilesViewedJson, + decodePullRequestListJson, + decodePullRequestNodeIdJson, + decodePullRequestSearchJson, + decodePullRequestStacksJson, + decodeLabelCandidatesJson, + decodeReviewerCandidatesJson, + decodeReviewThreadCommentsJson, + decodeReviewThreadsJson, + decodeViewerPermissionsJson, + decodeWorkflowRunsJson, + reviewThreadConversation, + REVIEW_THREADS_GRAPHQL_QUERY, + pullRequestCoreGraphQlQuery, + pullRequestSearchGraphQlQuery, +} from "./gitHubPullRequestJson.ts"; + +/** + * Rows as a repository's own GraphQL list answers them. Entries may use flat shapes for brevity: + * `reviewRequests` and `latestReviews` as arrays, and `checks` as the rollup state. + */ +function listJson(entries: ReadonlyArray>): string { + return JSON.stringify({ + data: { + repository: { + pullRequests: { + pageInfo: { hasNextPage: false }, + nodes: entries.map(({ reviewRequests, latestReviews, checks, ...entry }) => ({ + number: 1, + title: "Add the pull requests page", + url: "https://github.com/pingdotgg/t3code/pull/1", + headRefName: "feat/page", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + ...(reviewRequests === undefined + ? {} + : { + reviewRequests: { + nodes: (reviewRequests as ReadonlyArray).map((requestedReviewer) => ({ + requestedReviewer, + })), + }, + }), + ...(latestReviews === undefined ? {} : { latestReviews: { nodes: latestReviews } }), + ...(checks === undefined + ? {} + : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }), + ...entry, + })), + }, + }, + }, + }); +} + +function expectSuccess(result: Result.Result): A { + expect(Result.isSuccess(result)).toBe(true); + if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); + return result.success; +} + +describe("pull request list decoding", () => { + it("treats a merge timestamp as merged even when the state still says closed", () => { + const [entry] = expectSuccess( + decodePullRequestListJson(listJson([{ state: "CLOSED", mergedAt: "2026-07-03T00:00:00Z" }])), + ).items; + expect(entry?.state).toBe("merged"); + }); + + it("normalizes mergeability and defaults unknown values", () => { + const batch = expectSuccess( + decodePullRequestListJson( + listJson([{ mergeable: "CONFLICTING" }, { mergeable: "SOMETHING_NEW" }, {}]), + ), + ); + expect(batch.items.map((entry) => entry.mergeability)).toEqual([ + "conflicting", + "unknown", + "unknown", + ]); + }); + + it("keeps user review requests and drops team ones, which are not logins", () => { + const [entry] = expectSuccess( + decodePullRequestListJson( + listJson([{ reviewRequests: [{ login: "octocat" }, { slug: "web-platform" }] }]), + ), + ).items; + expect(entry?.reviewRequestLogins).toEqual(["octocat"]); + }); + + it("normalizes the review decision and reports nothing for one GitHub does not summarize", () => { + const batch = expectSuccess( + decodePullRequestListJson( + listJson([ + { reviewDecision: "APPROVED" }, + { reviewDecision: "CHANGES_REQUESTED" }, + { reviewDecision: "REVIEW_REQUIRED" }, + { reviewDecision: null }, + ]), + ), + ); + expect(batch.items.map((entry) => entry.reviewDecision)).toEqual([ + "approved", + "changes-requested", + "review-required", + null, + ]); + }); + + it("takes the verdict from the latest reviews when GitHub summarizes none, as for a bot's approval", () => { + const batch = expectSuccess( + decodePullRequestListJson( + listJson([ + { + reviewDecision: null, + latestReviews: [{ author: { login: "macroscopeapp" }, state: "APPROVED" }], + }, + { + reviewDecision: "REVIEW_REQUIRED", + latestReviews: [ + { author: { login: "octocat" }, state: "APPROVED" }, + { author: { login: "hubot" }, state: "CHANGES_REQUESTED" }, + ], + }, + { + reviewDecision: "APPROVED", + latestReviews: [{ author: { login: "hubot" }, state: "CHANGES_REQUESTED" }], + }, + { + reviewDecision: null, + latestReviews: [{ author: { login: "octocat" }, state: "COMMENTED" }], + }, + ]), + ), + ); + expect(batch.items.map((entry) => entry.reviewDecision)).toEqual([ + "approved", + "changes-requested", + "approved", + null, + ]); + }); + + it("rolls the head commit's rollup up to the one word a row has space for", () => { + const batch = expectSuccess( + decodePullRequestListJson( + listJson([ + { checks: "FAILURE" }, + { checks: "PENDING" }, + { checks: "SUCCESS" }, + { checks: "ERROR" }, + { checks: "EXPECTED" }, + {}, + ]), + ), + ); + expect(batch.items.map((entry) => entry.checksState)).toEqual([ + "failing", + "pending", + "passing", + "failing", + "pending", + null, + ]); + }); + + it("skips malformed entries but still counts them, so paging does not stop early", () => { + const batch = expectSuccess( + decodePullRequestListJson(listJson([{}, { number: "not-a-number" }])), + ); + expect(batch.items).toHaveLength(1); + expect(batch.rawCount).toBe(2); + }); +}); + +describe("pull request search decoding", () => { + function searchJson(rollupStates: ReadonlyArray): string { + return JSON.stringify({ + data: { + search: { + pageInfo: { hasNextPage: false }, + nodes: rollupStates.map((state, index) => ({ + number: index + 1, + title: "Add the pull requests page", + url: "https://github.com/pingdotgg/t3code/pull/1", + headRefName: "feat/page", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + repository: { nameWithOwner: "pingdotgg/t3code" }, + commits: { + nodes: [{ commit: { statusCheckRollup: state === null ? null : { state } } }], + }, + })), + }, + }, + }); + } + + it("keeps stack membership beside search results without extra per-PR reads", () => { + const raw = JSON.parse(searchJson(["SUCCESS", null])); + raw.data.search.nodes[0].stack = { number: 3, size: 2, baseRefName: "main" }; + raw.data.search.nodes[0].stackEntry = { position: 1 }; + const batch = expectSuccess(decodePullRequestSearchJson(JSON.stringify(raw))); + expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, position: 1, base: "main" }); + expect(batch.items[1]?.stack).toBeUndefined(); + expect(pullRequestSearchGraphQlQuery(20, true)).toContain("stackEntry"); + expect(pullRequestSearchGraphQlQuery(20)).not.toContain("stackEntry"); + }); + + it("maps the rollup enum the search answers with onto the same three words", () => { + // The search asks GitHub for the verdict rather than the checks behind it, so this path sees + // one enum where the listing sees an array. + const batch = expectSuccess( + decodePullRequestSearchJson( + searchJson(["SUCCESS", "FAILURE", "ERROR", "PENDING", "EXPECTED", null]), + ), + ); + expect(batch.items.map((entry) => entry.checksState)).toEqual([ + "passing", + "failing", + "failing", + "pending", + "pending", + null, + ]); + }); +}); + +describe("pull request detail decoding", () => { + const detailJson = JSON.stringify({ + number: 7, + title: "Detail", + url: "https://github.com/pingdotgg/t3code/pull/7", + headRefName: "feat/detail", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-05T00:00:00Z", + body: "Body", + statusCheckRollup: [ + { __typename: "CheckRun", name: "build", status: "IN_PROGRESS" }, + { __typename: "CheckRun", name: "test", status: "COMPLETED", conclusion: "FAILURE" }, + { __typename: "StatusContext", context: "ci/legacy", state: "SUCCESS" }, + ], + comments: [{ id: "c1", body: "second", createdAt: "2026-07-04T00:00:00Z" }], + reviews: [ + { id: "r1", body: "first", state: "CHANGES_REQUESTED", submittedAt: "2026-07-03T00:00:00Z" }, + { id: "r2", body: " ", state: "APPROVED", submittedAt: "2026-07-06T00:00:00Z" }, + ], + commits: [ + { + oid: "abc1234", + messageHeadline: "Ship the timeline", + committedDate: "2026-07-05T00:00:00Z", + authors: [ + { login: "octocat", name: "Octo Cat", email: "octo@example.com" }, + { name: "Pair Author", email: "pair@example.com" }, + ], + }, + ], + }); + + /** The same conversation as the GraphQL activity query answers it. */ + const activityJson = (raw: Record) => + JSON.stringify({ + data: { + repository: { + pullRequest: { + comments: { nodes: raw["comments"] ?? [] }, + reviews: { nodes: raw["reviews"] ?? [] }, + commits: { + nodes: ((raw["commits"] ?? []) as ReadonlyArray>).map( + ({ authors, ...commit }) => ({ + commit: { + ...commit, + authors: { + nodes: ((authors ?? []) as ReadonlyArray>).map( + ({ login, ...author }) => ({ + ...author, + ...(login === undefined ? {} : { user: { login } }), + }), + ), + }, + }, + }), + ), + }, + }, + }, + }, + }); + const activity = (raw: Record) => + Result.map(decodePullRequestActivityJson(activityJson(raw)), (page) => ({ + comments: page.remarks.toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + commits: page.commits ?? [], + })); + + it("maps check-run status and commit-status state onto one vocabulary", () => { + const detail = expectSuccess(decodePullRequestDetailJson(detailJson)); + expect(detail.checks.map((check) => [check.name, check.status])).toEqual([ + ["build", "pending"], + ["test", "failure"], + ["ci/legacy", "success"], + ]); + }); + + it("keeps what branch protection requires, and asks for it on github.com only", () => { + const raw = JSON.parse(detailJson) as Record; + const detail = expectSuccess( + decodePullRequestDetailJson( + JSON.stringify({ + ...raw, + statusCheckRollup: [ + { __typename: "CheckRun", name: "test", status: "IN_PROGRESS", isRequired: true }, + { __typename: "StatusContext", context: "bot", state: "PENDING", isRequired: false }, + { __typename: "StatusContext", context: "legacy", state: "SUCCESS" }, + ], + }), + ), + ); + expect(detail.checks.map((check) => check.required)).toEqual([true, false, undefined]); + expect(pullRequestCoreGraphQlQuery("github.com")).toContain("isRequired"); + expect(pullRequestCoreGraphQlQuery("github.example.com")).not.toContain("isRequired"); + }); + + it("keeps a workflow waiting for approval out of the passing state", () => { + const raw = JSON.parse(detailJson) as Record; + const detail = expectSuccess( + decodePullRequestDetailJson( + JSON.stringify({ + ...raw, + statusCheckRollup: [ + { __typename: "CheckRun", name: "build", status: "COMPLETED", conclusion: "SUCCESS" }, + { + __typename: "CheckRun", + name: "contributor tests", + status: "COMPLETED", + conclusion: "ACTION_REQUIRED", + }, + ], + }), + ), + ); + + expect(detail.checks.map((check) => check.status)).toEqual(["success", "action-required"]); + expect(detail.checksState).toBe("pending"); + }); + + it("decodes workflow runs that can be approved", () => { + expect( + expectSuccess( + decodeWorkflowRunsJson( + JSON.stringify({ + workflow_runs: [ + { id: 10, name: "contributor tests", html_url: "https://example.com/10" }, + { id: 11, name: null, html_url: null }, + ], + }), + ), + ).runs, + ).toEqual([ + { id: 10, name: "contributor tests", url: "https://example.com/10" }, + { id: 11, name: "Workflow run 11", url: null }, + ]); + }); + + it("reads an auto-merge request and strategy, its null as off and its absence as neither", () => { + const raw = JSON.parse(detailJson) as Record; + const armed = (entry: Record) => + expectSuccess(decodePullRequestDetailJson(JSON.stringify({ ...raw, ...entry }))); + + expect( + armed({ autoMergeRequest: { enabledBy: { login: "octocat" }, mergeMethod: "SQUASH" } }), + ).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" }); + expect(armed({ autoMergeRequest: null }).autoMergeEnabled).toBe(false); + // `gh` not answering for the field at all is not GitHub saying the merge is unarmed. + expect(armed({}).autoMergeEnabled).toBeUndefined(); + }); + + it("shows a re-running check once, as the run that is happening now", () => { + // What `statusCheckRollup` reports while a workflow is being re-run: the same check twice, + // the finished run and the one that replaced it, with no id to tell them apart. + const raw = JSON.parse(detailJson) as Record; + const detail = expectSuccess( + decodePullRequestDetailJson( + JSON.stringify({ + ...raw, + statusCheckRollup: [ + { + __typename: "CheckRun", + name: "Prepare PR size config", + workflowName: "PR Size", + status: "COMPLETED", + conclusion: "SUCCESS", + startedAt: "2026-08-11T16:06:20Z", + completedAt: "2026-08-11T16:06:25Z", + }, + { + __typename: "CheckRun", + name: "Prepare PR size config", + workflowName: "PR Size", + status: "IN_PROGRESS", + conclusion: "", + startedAt: "2026-08-11T17:01:04Z", + completedAt: "0001-01-01T00:00:00Z", + }, + ], + }), + ), + ); + + expect(detail.checks.map((check) => [check.name, check.status])).toEqual([ + ["Prepare PR size config", "pending"], + ]); + expect(detail.checksState).toBe("pending"); + }); + + it("merges reviews with comments in time order and keeps a bodyless approval", () => { + const detail = expectSuccess(activity(JSON.parse(detailJson) as Record)); + // r2 approved without writing anything, which is still the event worth seeing. + expect(detail.comments.map((comment) => comment.id)).toEqual(["r1", "c1", "r2"]); + expect(detail.comments.at(-1)?.reviewState).toBe("APPROVED"); + }); + + it("keeps every attributed commit author, including an unlinked signature", () => { + const detail = expectSuccess(activity(JSON.parse(detailJson) as Record)); + expect(detail.commits[0]?.authors).toEqual([ + { login: "octocat", name: "Octo Cat", avatarUrl: null }, + { login: "Pair Author", name: "Pair Author", avatarUrl: null }, + ]); + }); + + it("drops the bodyless review GitHub opens to hold line comments", () => { + const raw = JSON.parse(detailJson) as Record; + const detail = expectSuccess( + activity({ + ...raw, + reviews: [ + // What a reviewer leaving inline comments produces: a container with a state but + // nothing to read. Its comments come from the review threads instead. + { id: "r4", body: "", state: "COMMENTED", submittedAt: "2026-07-07T00:00:00Z" }, + { + id: "r5", + body: "Looks good.", + state: "COMMENTED", + submittedAt: "2026-07-08T00:00:00Z", + }, + ], + }), + ); + + expect(detail.comments.map((comment) => comment.id)).toEqual(["c1", "r5"]); + }); + + it.each(["APPROVED", "CHANGES_REQUESTED", "DISMISSED"])( + "keeps a bodyless %s review, which is the event itself", + (state) => { + const raw = JSON.parse(detailJson) as Record; + const detail = expectSuccess( + activity({ + ...raw, + reviews: [{ id: "r6", body: "", state, submittedAt: "2026-07-07T00:00:00Z" }], + }), + ); + + expect(detail.comments.map((comment) => comment.id)).toContain("r6"); + }, + ); + + it("drops a review that carries neither a body nor a state", () => { + const raw = JSON.parse(detailJson) as Record; + const detail = expectSuccess( + activity({ + ...raw, + reviews: [{ id: "r3", body: " ", submittedAt: "2026-07-07T00:00:00Z" }], + }), + ); + expect(detail.comments.map((comment) => comment.id)).toEqual(["c1"]); + }); +}); + +describe("review thread decoding", () => { + const threadsJson = ( + nodes: ReadonlyArray>, + totalCount = nodes.length, + pageInfo: Record = { hasNextPage: false, endCursor: null }, + ): string => + JSON.stringify({ + data: { repository: { pullRequest: { reviewThreads: { totalCount, pageInfo, nodes } } } }, + }); + + /** The same query carries the review roster, so it is built alongside the threads. */ + const reviewJson = (input: { + readonly requested?: ReadonlyArray; + readonly reviewed?: ReadonlyArray; + }): string => + JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { totalCount: 0, nodes: [] }, + reviewRequests: { + nodes: (input.requested ?? []).map((r) => ({ requestedReviewer: r })), + }, + latestReviews: { nodes: (input.reviewed ?? []).map((a) => ({ author: a })) }, + }, + }, + }, + }); + + it("keeps a reviewer who has already reviewed, app or person, with their avatar", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + reviewJson({ + requested: [{ login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" }], + // An app that has reviewed is no longer an outstanding request, which is why asking + // only for requests reported nobody on a pull request a bot had reviewed. + reviewed: [ + { + __typename: "Bot", + login: "macroscopeapp", + avatarUrl: "https://avatars/in/900172.png", + }, + ], + }), + ), + ); + + expect(result.botLogins).toEqual(new Set(["macroscopeapp"])); + expect(result.reviewers).toEqual([ + { login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" }, + { + login: "macroscopeapp", + name: null, + avatarUrl: "https://avatars/in/900172.png", + isBot: true, + }, + ]); + }); + + it("carries per-commit line counts from the pull-request connection", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { totalCount: 0, nodes: [] }, + commits: { + nodes: [ + { commit: { oid: "abc123", additions: 18, deletions: 7 } }, + { commit: { oid: "def456", additions: 3, deletions: 0 } }, + ], + }, + }, + }, + }, + }), + ), + ); + + expect([...result.commitStats]).toEqual([ + ["abc123", { additions: 18, deletions: 7 }], + ["def456", { additions: 3, deletions: 0 }], + ]); + }); + + it("omits misleading line counts from merge commits", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { totalCount: 0, nodes: [] }, + commits: { + nodes: [ + { + commit: { + oid: "merge123", + additions: 36_858, + deletions: 12_928, + parents: { totalCount: 2 }, + }, + }, + ], + }, + }, + }, + }, + }), + ), + ); + + expect([...result.commitStats]).toEqual([]); + }); + + it("decodes the newest commits off the same connection, oldest to newest", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { totalCount: 0, nodes: [] }, + commits: { + nodes: [ + { + commit: { + oid: "abc123", + messageHeadline: "Ship the timeline", + committedDate: "2026-07-05T00:00:00Z", + additions: 18, + deletions: 7, + authors: { nodes: [{ name: "Julius", user: { login: "julius" } }] }, + }, + }, + { + commit: { + oid: "def456", + messageHeadline: "Fix the flaky test", + committedDate: "2026-07-06T00:00:00Z", + }, + }, + ], + }, + }, + }, + }, + }), + ), + ); + + expect(result.commits).toEqual([ + { + oid: "abc123", + messageHeadline: "Ship the timeline", + committedDate: "2026-07-05T00:00:00Z", + authors: [{ login: "julius", name: "Julius", avatarUrl: null }], + }, + { + oid: "def456", + messageHeadline: "Fix the flaky test", + committedDate: "2026-07-06T00:00:00Z", + authors: [], + }, + ]); + }); + + it("lists someone who was asked and then answered only once", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + reviewJson({ + requested: [{ login: "julius", avatarUrl: "https://avatars/j.png" }], + reviewed: [{ login: "julius", avatarUrl: "https://avatars/j.png" }], + }), + ), + ); + + expect(result.reviewers).toHaveLength(1); + }); + + it("skips a team request, which names nobody to show", () => { + const result = expectSuccess(decodeReviewThreadsJson(reviewJson({ requested: [null] }))); + + expect(result.reviewers).toEqual([]); + }); + + it("keeps the conversation when a request is from a team, which has no login", () => { + // GraphQL answers with an empty object for a union member the query has no fragment for. + // Failing on it would take the whole response down, comments included. + const result = expectSuccess( + decodeReviewThreadsJson( + reviewJson({ requested: [{}, { login: "julius", avatarUrl: "https://avatars/j.png" }] }), + ), + ); + + expect(result.reviewers).toEqual([ + { login: "julius", name: null, avatarUrl: "https://avatars/j.png" }, + ]); + }); + + it("carries a resolved thread into the conversation, which was still said", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_a", + isResolved: false, + path: "apps/server/src/ws.ts", + comments: { + nodes: [{ id: "t1", body: "fix this", createdAt: "2026-07-01T00:00:00Z" }], + }, + }, + { + id: "PRRT_b", + isResolved: true, + path: "apps/web/src/main.tsx", + comments: { nodes: [{ id: "t2", body: "done", createdAt: "2026-07-01T00:00:00Z" }] }, + }, + ]), + ), + ); + const comments = reviewThreadConversation(result.threads.map((entry) => entry.thread)); + expect(comments.map((comment) => comment.id)).toEqual(["t1", "t2"]); + expect(comments[0]).toMatchObject({ + id: "t1", + kind: "review-comment", + path: "apps/server/src/ws.ts", + }); + }); + + it("carries every reply, not only the remark each thread opened with", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_c", + isResolved: false, + path: "apps/server/src/ws.ts", + comments: { + nodes: [ + { id: "t1", body: "fix this", createdAt: "2026-07-01T00:00:00Z" }, + { id: "t2", body: "fixed", createdAt: "2026-07-01T01:00:00Z" }, + ], + }, + }, + ]), + ), + ); + const comments = reviewThreadConversation(result.threads.map((entry) => entry.thread)); + expect(comments.map((comment) => comment.id)).toEqual(["t1", "t2"]); + }); + + it("hands back the cursor the next page of threads carries on from", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson( + [ + { + id: "PRRT_d", + path: "apps/server/src/ws.ts", + isResolved: false, + comments: { nodes: [{ id: "t1", createdAt: "2026-07-01T00:00:00Z" }] }, + }, + ], + 80, + { hasNextPage: true, endCursor: "Y3Vyc29yOjE" }, + ), + ), + ); + expect(result.nextCursor).toBe("Y3Vyc29yOjE"); + }); + + it("keeps GitHub's own count of a thread whose comments were not all read", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_e", + path: "apps/server/src/ws.ts", + isResolved: false, + comments: { + totalCount: 140, + pageInfo: { hasNextPage: true, endCursor: "Y3Vyc29yOjI" }, + nodes: [{ id: "t1", createdAt: "2026-07-01T00:00:00Z" }], + }, + }, + ]), + ), + ); + expect(result.threads[0]).toMatchObject({ + commentCount: 140, + nextCommentCursor: "Y3Vyc29yOjI", + }); + }); + + it("ends a thread's walk on the last page, which still names a cursor", () => { + const decoded = expectSuccess( + decodeReviewThreadCommentsJson( + JSON.stringify({ + data: { + repository: { pullRequest: { id: "PR_1" } }, + node: { + pullRequest: { id: "PR_1" }, + comments: { + pageInfo: { hasNextPage: false, endCursor: "Y3Vyc29yOjk" }, + nodes: [{ id: "t9", body: "last", createdAt: "2026-07-01T00:00:00Z" }], + }, + }, + }, + }), + ), + ); + expect(decoded.comments.map((comment) => comment.id)).toEqual(["t9"]); + expect(decoded.nextCursor).toBeNull(); + }); +}); + +describe("reaction decoding", () => { + const commentWithGroups = (reactionGroups: ReadonlyArray>) => + JSON.stringify({ + data: { + repository: { pullRequest: { id: "PR_1" } }, + node: { + pullRequest: { id: "PR_1" }, + comments: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [{ id: "t1", body: "nice", createdAt: "2026-07-01T00:00:00Z", reactionGroups }], + }, + }, + }, + }); + + it("keeps a named group, widens a group whose reactors were cut short, drops an unknown content and an empty group", () => { + const decoded = expectSuccess( + decodeReviewThreadCommentsJson( + commentWithGroups([ + { + content: "THUMBS_UP", + viewerHasReacted: true, + reactors: { totalCount: 2, nodes: [{ login: "julius" }, { login: "bilal" }] }, + }, + // Not one of the eight the contract carries. + { + content: "PARTY_PARROT", + reactors: { totalCount: 1, nodes: [{ login: "hubot" }] }, + }, + // Nobody behind it, which GitHub still answers a group for. + { content: "HEART", reactors: { totalCount: 0, nodes: [] } }, + // More reactors than the bounded read named, and no `viewerHasReacted` at all. + { + content: "ROCKET", + reactors: { totalCount: 140, nodes: [{ login: "a" }, { login: "b" }, { login: "c" }] }, + }, + ]), + ), + ); + + expect(decoded.comments[0]?.reactions).toEqual([ + { content: "thumbs-up", count: 2, actors: ["julius", "bilal"], viewerHasReacted: true }, + { content: "rocket", count: 140, actors: ["a", "b", "c"], viewerHasReacted: false }, + ]); + }); + + it("leaves the viewer's own login out of actors, matched case-insensitively, while count still counts them", () => { + const decoded = expectSuccess( + decodeReviewThreadCommentsJson( + JSON.stringify({ + data: { + viewer: { login: "Bilal" }, + repository: { pullRequest: { id: "PR_1" } }, + node: { + pullRequest: { id: "PR_1" }, + comments: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + { + id: "t1", + body: "nice", + createdAt: "2026-07-01T00:00:00Z", + reactionGroups: [ + { + content: "HEART", + viewerHasReacted: true, + reactors: { + totalCount: 2, + nodes: [{ login: "bilal" }, { login: "julius" }], + }, + }, + ], + }, + ], + }, + }, + }, + }), + ), + ); + + expect(decoded.comments[0]?.reactions).toEqual([ + { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true }, + ]); + }); +}); + +describe("repository access decoding", () => { + const repositoryJson = (viewerPermission?: string | null) => + JSON.stringify({ + data: { + repository: { + pullRequest: null, + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + ...(viewerPermission === undefined ? {} : { viewerPermission }), + }, + }, + }); + + it("reads merge settings with viewer permissions", () => { + expect( + expectSuccess(decodeViewerPermissionsJson(repositoryJson("ADMIN"))).mergeCapabilities, + ).toEqual({ merge: true, squash: false, rebase: true }); + }); + + it("fails rather than defaulting open when a setting is missing", () => { + const decoded = decodeViewerPermissionsJson( + JSON.stringify({ data: { repository: { pullRequest: null, mergeCommitAllowed: true } } }), + ); + expect(Result.isSuccess(decoded)).toBe(false); + }); + + it("counts the roles that can push as write, and the ones that cannot as read", () => { + for (const permission of ["ADMIN", "MAINTAIN", "WRITE"]) { + expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(permission))).canWrite).toBe( + true, + ); + } + for (const permission of ["TRIAGE", "READ", "NONE"]) { + expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(permission))).canWrite).toBe( + false, + ); + } + }); + + it("withholds write where gh names no permission, which is not a standing it gave", () => { + // The one place an unknown answer is not granted: a Merge button a reader cannot use wastes + // the press, where a missing one still leaves the pull request open on its host. + expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson())).canWrite).toBe(false); + expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(null))).canWrite).toBe(false); + }); +}); + +describe("viewer permission decoding", () => { + const viewerJson = (repository: Record) => + JSON.stringify({ + data: { + repository: { + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + ...repository, + }, + }, + }); + + it("reads the repository's role and the pull request's own viewer fields together", () => { + expect( + expectSuccess( + decodeViewerPermissionsJson( + viewerJson({ + viewerPermission: "READ", + pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, + }), + ), + ), + ).toEqual({ + mergeCapabilities: { merge: true, squash: false, rebase: true }, + canWrite: false, + canTriage: false, + canUpdate: true, + didAuthor: true, + }); + }); + + it("says no to a passer-by on a repository they can only read", () => { + expect( + expectSuccess( + decodeViewerPermissionsJson( + viewerJson({ + viewerPermission: "READ", + pullRequest: { viewerCanUpdate: false, viewerDidAuthor: false }, + }), + ), + ), + ).toEqual({ + mergeCapabilities: { merge: true, squash: false, rebase: true }, + canWrite: false, + canTriage: false, + canUpdate: false, + didAuthor: false, + }); + }); + + it("reads silence as permission, but not as authorship", () => { + // A node the viewer cannot see comes back null. Updating is a permission, so an unknown + // answer grants it and lets the host refuse; authorship is a fact about who wrote the change, + // and claiming it for someone who did not is how an author's own rules get handed out. + expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({ + mergeCapabilities: { merge: true, squash: false, rebase: true }, + canWrite: false, + canTriage: false, + canUpdate: true, + didAuthor: false, + }); + }); + + it("reads triage as enough to label, and not enough to write", () => { + const access = expectSuccess( + decodeViewerPermissionsJson( + viewerJson({ + viewerPermission: "TRIAGE", + pullRequest: { viewerCanUpdate: false, viewerDidAuthor: false }, + }), + ), + ); + expect(access.canTriage).toBe(true); + expect(access.canWrite).toBe(false); + }); +}); + +describe("label candidate decoding", () => { + const labelsJson = (input: { + readonly defined: ReadonlyArray>; + readonly applied?: ReadonlyArray; + readonly hasNextPage?: boolean; + }) => + JSON.stringify({ + data: { + repository: { + labels: { + pageInfo: { hasNextPage: input.hasNextPage ?? false }, + nodes: input.defined, + }, + pullRequest: { labels: { nodes: (input.applied ?? []).map((name) => ({ name })) } }, + }, + }, + }); + + it("marks the labels the pull request already wears", () => { + const list = expectSuccess( + decodeLabelCandidatesJson( + labelsJson({ + defined: [ + { name: "bug", color: "d73a4a", description: "Something is broken" }, + { name: "size:XL", color: "e4572e", description: null }, + ], + applied: ["size:XL"], + }), + ), + ); + expect(list.candidates).toEqual([ + { name: "bug", color: "d73a4a", description: "Something is broken", isApplied: false }, + { name: "size:XL", color: "e4572e", description: null, isApplied: true }, + ]); + expect(list.truncated).toBe(false); + }); + + it("keeps a worn label the repository no longer defines, so it can be taken off", () => { + const list = expectSuccess( + decodeLabelCandidatesJson(labelsJson({ defined: [{ name: "bug" }], applied: ["legacy"] })), + ); + expect(list.candidates.map((label) => [label.name, label.isApplied])).toEqual([ + ["legacy", true], + ["bug", false], + ]); + }); + + it("says so when the repository defines more labels than the read asked for", () => { + expect( + expectSuccess(decodeLabelCandidatesJson(labelsJson({ defined: [], hasNextPage: true }))) + .truncated, + ).toBe(true); + }); +}); + +describe("review thread decoding", () => { + const threadsJson = ( + nodes: ReadonlyArray>, + pullRequest: Record = {}, + ) => + JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { totalCount: nodes.length, nodes }, + author: null, + comments: { nodes: [] }, + reviewRequests: { nodes: [] }, + latestReviews: { nodes: [] }, + ...pullRequest, + }, + }, + }, + }); + + it("carries what the reader may do with the pull request, off the conversation read", () => { + // The same response the threads arrive in, so knowing this costs no request of its own. + expect( + expectSuccess( + decodeReviewThreadsJson( + threadsJson([], { viewerCanUpdate: false, viewerDidAuthor: false }), + ), + ).viewer, + ).toEqual({ canUpdate: false, didAuthor: false }); + expect(expectSuccess(decodeReviewThreadsJson(threadsJson([]))).viewer).toEqual({ + canUpdate: true, + didAuthor: false, + }); + }); + + const comment = (id: string, body: string) => ({ + id, + author: { login: "bilal", avatarUrl: "https://avatars/b.png" }, + body, + createdAt: "2026-07-01T00:00:00Z", + url: `https://github.com/acme/web/pull/1#discussion_r${id}`, + }); + + it("anchors a thread to its line and side, keeping the whole conversation", () => { + const reviewThreads = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_1", + isResolved: false, + isOutdated: false, + path: "src/a.ts", + line: 42, + diffSide: "LEFT", + comments: { + totalCount: 2, + nodes: [ + { ...comment("c1", "first"), lastEditedAt: "2026-07-02T00:00:00Z" }, + comment("c2", "second"), + ], + }, + }, + ]), + ), + ); + expect(reviewThreads.threads.map((entry) => entry.thread)).toEqual([ + { + id: "PRRT_1", + path: "src/a.ts", + line: 42, + side: "left", + isResolved: false, + isOutdated: false, + comments: [ + { + id: "c1", + author: { login: "bilal", name: null, avatarUrl: "https://avatars/b.png" }, + body: "first", + createdAt: "2026-07-01T00:00:00Z", + editedAt: "2026-07-02T00:00:00Z", + url: "https://github.com/acme/web/pull/1#discussion_rc1", + reactions: [], + }, + { + id: "c2", + author: { login: "bilal", name: null, avatarUrl: "https://avatars/b.png" }, + body: "second", + createdAt: "2026-07-01T00:00:00Z", + editedAt: null, + url: "https://github.com/acme/web/pull/1#discussion_rc2", + reactions: [], + }, + ], + }, + ]); + expect( + reviewThreadConversation(reviewThreads.threads.map((entry) => entry.thread))[0]?.editedAt, + ).toBe("2026-07-02T00:00:00Z"); + }); + + it("leaves an outdated thread without a line rather than pinning it to a stale one", () => { + const reviewThreads = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_2", + isResolved: true, + isOutdated: true, + path: "src/a.ts", + // GitHub reports no current line once the thread has fallen off the diff. + line: null, + diffSide: "RIGHT", + comments: { totalCount: 1, nodes: [comment("c3", "stale")] }, + }, + ]), + ), + ); + expect(reviewThreads.threads[0]?.thread).toMatchObject({ + line: null, + isOutdated: true, + isResolved: true, + }); + }); + + it("keeps a resolved thread in the conversation as well as against its line", () => { + const decoded = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_3", + isResolved: true, + path: "src/a.ts", + line: 7, + diffSide: "RIGHT", + comments: { totalCount: 1, nodes: [comment("c4", "done")] }, + }, + ]), + ), + ); + // A resolved conversation is finished work, not unsaid work: the timeline reads it and the + // diff pins it to its line, the same as any other. + const threads = decoded.threads.map((entry) => entry.thread); + expect(reviewThreadConversation(threads).map((comment) => comment.id)).toEqual(["c4"]); + expect(threads).toHaveLength(1); + }); + + it("reads edit times for issue comments and reviews without using reaction updates", () => { + const editedAt = "2026-10-02T12:06:00Z"; + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson([], { + comments: { + nodes: [ + { id: "edited", lastEditedAt: editedAt }, + { id: "reaction-only", lastEditedAt: null, updatedAt: editedAt }, + ], + }, + reviews: { nodes: [{ id: "review", lastEditedAt: editedAt }] }, + }), + ), + ); + expect([...result.editedAtById]).toEqual([ + ["edited", editedAt], + ["review", editedAt], + ]); + }); + + it("puts an issue comment's and a review's reactions in reactionsById, and the pull request's own in reactions", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson([], { + reactionGroups: [ + { + content: "HEART", + viewerHasReacted: true, + reactors: { totalCount: 1, nodes: [{ login: "bilal" }] }, + }, + ], + comments: { + nodes: [ + { + id: "c1", + reactionGroups: [ + { + content: "THUMBS_UP", + reactors: { totalCount: 1, nodes: [{ login: "julius" }] }, + }, + ], + }, + ], + }, + reviews: { + nodes: [ + { + id: "r1", + reactionGroups: [ + { content: "EYES", reactors: { totalCount: 1, nodes: [{ login: "hubot" }] } }, + ], + }, + ], + }, + }), + ), + ); + + expect(result.reactions).toEqual([ + { content: "heart", count: 1, actors: ["bilal"], viewerHasReacted: true }, + ]); + expect([...result.reactionsById]).toEqual([ + ["c1", [{ content: "thumbs-up", count: 1, actors: ["julius"], viewerHasReacted: false }]], + ["r1", [{ content: "eyes", count: 1, actors: ["hubot"], viewerHasReacted: false }]], + ]); + }); + + it("leaves the viewer's own login out of the pull request's own reactions, matched case-insensitively, while count still counts them", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + JSON.stringify({ + data: { + viewer: { login: "Bilal" }, + repository: { + pullRequest: { + reviewThreads: { totalCount: 0, nodes: [] }, + reactionGroups: [ + { + content: "HEART", + viewerHasReacted: true, + reactors: { totalCount: 2, nodes: [{ login: "bilal" }, { login: "julius" }] }, + }, + ], + }, + }, + }, + }), + ), + ); + + expect(result.reactions).toEqual([ + { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true }, + ]); + }); +}); + +describe("decodePullRequestNodeIdJson", () => { + it("reads the pull request's own node id, which a reaction on its description is addressed by", () => { + expect( + expectSuccess( + decodePullRequestNodeIdJson( + JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }), + ), + ), + ).toBe("PR_kwDOA"); + }); +}); + +describe("REVIEW_THREADS_GRAPHQL_QUERY", () => { + it("caps the initial query after the 104-point rate-limit regression", () => { + const match = REVIEW_THREADS_GRAPHQL_QUERY.match( + /reviewThreads\(first: (\d+)[\s\S]*?comments\(first: (\d+)\)/u, + ); + + expect(match).not.toBeNull(); + if (match === null) throw new Error("expected review-thread connections"); + expect(Number(match[1]) * Number(match[2])).toBeLessThanOrEqual(1_000); + }); + + it("asks for reactionGroups on the pull request itself, its comments, its reviews and each thread's comments", () => { + expect(REVIEW_THREADS_GRAPHQL_QUERY.match(/reactionGroups/g)).toHaveLength(4); + // The reviews connection is new: only reactions were ever wanted off it. + expect(REVIEW_THREADS_GRAPHQL_QUERY).toContain("reviews(first:"); + }); +}); + +describe("reviewer candidate decoding", () => { + const candidatesJson = (input: { + readonly assignable: ReadonlyArray | null>; + readonly requested?: ReadonlyArray | null>; + readonly author?: string; + readonly hasNextPage?: boolean; + }) => + JSON.stringify({ + data: { + repository: { + assignableUsers: { + pageInfo: { hasNextPage: input.hasNextPage ?? false }, + nodes: input.assignable, + }, + pullRequest: { + author: input.author === undefined ? null : { login: input.author }, + reviewRequests: { + nodes: (input.requested ?? []).map((requestedReviewer) => ({ requestedReviewer })), + }, + }, + }, + }, + }); + + it("leaves the author out of the people their own pull request can be sent to", () => { + const list = expectSuccess( + decodeReviewerCandidatesJson( + candidatesJson({ + assignable: [{ login: "bilal" }, { login: "octocat", name: "The Octocat" }], + author: "bilal", + }), + ), + ); + expect(list.candidates).toEqual([ + { + id: "octocat", + kind: "user", + login: "octocat", + name: "The Octocat", + avatarUrl: null, + isRequested: false, + }, + ]); + expect(list.truncated).toBe(false); + }); + + it("marks whoever has already been asked, and leaves the rest to be asked", () => { + const list = expectSuccess( + decodeReviewerCandidatesJson( + candidatesJson({ + assignable: [{ login: "octocat" }, { login: "hubot" }], + requested: [{ login: "octocat" }], + }), + ), + ); + expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ + ["octocat", true], + ["hubot", false], + ]); + }); + + it("keeps a requested team apart from the people, so the request can be taken back", () => { + // A team is never among the assignable users, and a request that cannot be seen cannot be + // undone — so the ones GitHub reports are carried, marked as the teams they are. + const list = expectSuccess( + decodeReviewerCandidatesJson( + candidatesJson({ + assignable: [{ login: "octocat" }], + requested: [{ slug: "reviewers", name: "Reviewers" }], + }), + ), + ); + expect(list.candidates).toEqual([ + { + id: "reviewers", + kind: "team", + login: "reviewers", + name: "Reviewers", + avatarUrl: null, + isRequested: true, + }, + { + id: "octocat", + kind: "user", + login: "octocat", + name: null, + avatarUrl: null, + isRequested: false, + }, + ]); + }); + + it("says so when the repository has more people than the read asked for", () => { + expect( + expectSuccess( + decodeReviewerCandidatesJson( + candidatesJson({ assignable: [{ login: "octocat" }], hasNextPage: true }), + ), + ).truncated, + ).toBe(true); + }); +}); + +describe("reviewer request payload", () => { + it("sends people and teams in the two lists GitHub keeps them in", () => { + expect( + buildReviewerRequest([ + { id: "octocat", kind: "user" }, + { id: "reviewers", kind: "team" }, + { id: "hubot", kind: "user" }, + ]), + ).toEqual({ reviewers: ["octocat", "hubot"], team_reviewers: ["reviewers"] }); + }); + + it("sends both lists even where one of them is empty, which is what GitHub reads", () => { + expect(buildReviewerRequest([{ id: "octocat", kind: "user" }])).toEqual({ + reviewers: ["octocat"], + team_reviewers: [], + }); + }); +}); + +describe("review submission payload", () => { + it("sends the verdict, the summary and every line comment in one body", () => { + const payload = buildReviewSubmission({ + verdict: "request-changes", + body: "Two things.", + comments: [ + { + path: "src/a.ts", + position: { kind: "added", newLine: 12 }, + body: "rename this", + }, + { + path: "src/b.ts", + position: { kind: "deleted", oldLine: 3 }, + body: "why remove?", + }, + ], + }); + expect(payload).toEqual({ + event: "REQUEST_CHANGES", + body: "Two things.", + comments: [ + { path: "src/a.ts", line: 12, side: "RIGHT", body: "rename this" }, + { path: "src/b.ts", line: 3, side: "LEFT", body: "why remove?" }, + ], + }); + }); + + it("sends an approval with no words and no comments", () => { + expect(buildReviewSubmission({ verdict: "approve", body: "", comments: [] })).toEqual({ + event: "APPROVE", + body: "", + comments: [], + }); + }); +}); + +describe("decodePullRequestFilesJson", () => { + it("quotes literal backslashes without interpreting them as escapes", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + { + filename: String.raw`src\notes.ts`, + status: "modified", + patch: "@@ -1 +1 @@\n-old\n+new", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`, + String.raw`--- "a/src\\notes.ts"`, + String.raw`+++ "b/src\\notes.ts"`, + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"), + ); + }); + + it("preserves spaces and literal backslashes in both rename paths", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + { + previous_filename: String.raw` old\name.ts `, + filename: String.raw` new\name.ts `, + status: "renamed", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + String.raw`diff --git "a/ old\\name.ts " "b/ new\\name.ts "`, + String.raw`rename from " old\\name.ts "`, + String.raw`rename to " new\\name.ts "`, + String.raw`--- "a/ old\\name.ts "`, + String.raw`+++ "b/ new\\name.ts "`, + "", + ].join("\n"), + ); + }); + + it("assembles a unified patch the files API does not return", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + { filename: "src/app.ts", status: "modified", patch: "@@ -1 +1 @@\n-old\n+new" }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + "diff --git a/src/app.ts b/src/app.ts", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"), + ); + expect(result.truncated).toBe(false); + expect(result.rawCount).toBe(1); + }); + + it("points an added file at /dev/null on the left and a removed one on the right", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + { filename: "src/new.ts", status: "added", patch: "@@ -0,0 +1 @@\n+hello" }, + { filename: "src/gone.ts", status: "removed", patch: "@@ -1 +0,0 @@\n-bye" }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + "diff --git a/src/new.ts b/src/new.ts", + "new file mode 100644", + "--- /dev/null", + "+++ b/src/new.ts", + "@@ -0,0 +1 @@", + "+hello", + "diff --git a/src/gone.ts b/src/gone.ts", + "deleted file mode 100644", + "--- a/src/gone.ts", + "+++ /dev/null", + "@@ -1 +0,0 @@", + "-bye", + "", + ].join("\n"), + ); + }); + + it("names both paths of a rename, counting its hunks against the old one", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + { + filename: "src/new.ts", + status: "renamed", + previous_filename: "src/old.ts", + patch: "@@ -1 +1 @@\n-old\n+new", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + "diff --git a/src/old.ts b/src/new.ts", + "rename from src/old.ts", + "rename to src/new.ts", + "--- a/src/old.ts", + "+++ b/src/new.ts", + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"), + ); + }); + + it("still lists a file GitHub sent no hunks for, and says what was withheld", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + // Binary: it changed, and none of it can be shown. + { filename: "logo.png", status: "modified", additions: 4, deletions: 2 }, + { + filename: "src/app.ts", + status: "modified", + additions: 1, + deletions: 1, + patch: "@@ -1 +1 @@\n-old\n+new", + }, + ]), + ), + ); + + // Dropping it would take the file out of the change altogether, not just its contents. + expect(result.patch).toContain("diff --git a/logo.png b/logo.png"); + expect(result.patch).toContain("diff --git a/src/app.ts b/src/app.ts"); + expect(result.truncated).toBe(true); + expect(result.rawCount).toBe(2); + }); + + it("does not call a pure rename incomplete, since it has no hunks to withhold", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + { + filename: "src/new.ts", + previous_filename: "src/old.ts", + status: "renamed", + additions: 0, + deletions: 0, + }, + ]), + ), + ); + + expect(result.patch).toContain("rename from src/old.ts"); + expect(result.truncated).toBe(false); + }); +}); + +describe("decodePullRequestFilesViewedJson", () => { + const page = ( + nodes: ReadonlyArray, + pageInfo: { hasNextPage: boolean; endCursor: string | null }, + ) => + JSON.stringify({ + data: { repository: { pullRequest: { files: { pageInfo, nodes } } } }, + }); + + it("reads each file's state and where the next page carries on", () => { + const decoded = decodePullRequestFilesViewedJson( + page( + [ + { path: "src/a.ts", viewerViewedState: "VIEWED" }, + { path: "src/b.ts", viewerViewedState: "UNVIEWED" }, + { path: "src/c.ts", viewerViewedState: "DISMISSED" }, + ], + { hasNextPage: true, endCursor: "cursor-2" }, + ), + ); + expect(Result.isSuccess(decoded)).toBe(true); + if (!Result.isSuccess(decoded)) return; + expect(decoded.success).toEqual({ + files: [ + { path: "src/a.ts", state: "viewed" }, + { path: "src/b.ts", state: "unviewed" }, + { path: "src/c.ts", state: "dismissed" }, + ], + nextCursor: "cursor-2", + }); + }); + + it("treats a state it has never heard of as unread rather than failing the page", () => { + const decoded = decodePullRequestFilesViewedJson( + page([{ path: "src/a.ts", viewerViewedState: "SOMETHING_NEW" }], { + hasNextPage: false, + endCursor: null, + }), + ); + expect(Result.isSuccess(decoded)).toBe(true); + if (!Result.isSuccess(decoded)) return; + expect(decoded.success).toEqual({ + files: [{ path: "src/a.ts", state: "unviewed" }], + nextCursor: null, + }); + }); + + it("answers empty for a pull request the host has nothing to say about", () => { + const decoded = decodePullRequestFilesViewedJson( + JSON.stringify({ data: { repository: { pullRequest: null } } }), + ); + expect(Result.isSuccess(decoded)).toBe(true); + if (!Result.isSuccess(decoded)) return; + expect(decoded.success).toEqual({ files: [], nextCursor: null }); + }); +}); + +describe("buildSetFilesViewedGraphQlMutation", () => { + it("asks for nothing when nothing was pressed", () => { + expect(buildSetFilesViewedGraphQlMutation("PR_1", [])).toBeNull(); + }); + + it("clears and restores in one document, each file under its own alias", () => { + const mutation = buildSetFilesViewedGraphQlMutation("PR_1", [ + { path: "src/a.ts", viewed: true }, + { path: "src/b.ts", viewed: false }, + ]); + expect(mutation).not.toBeNull(); + if (mutation === null) return; + expect(mutation.query).toContain( + "mutation($pullRequestId: ID!, $f0_path: String!, $f1_path: String!)", + ); + expect(mutation.query).toContain( + "f0: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $f0_path })", + ); + expect(mutation.query).toContain( + "f1: unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $f1_path })", + ); + expect(mutation.variables).toEqual({ + pullRequestId: "PR_1", + f0_path: "src/a.ts", + f1_path: "src/b.ts", + }); + }); + + it("keeps a path out of the document, so one cannot be read as part of it", () => { + const mutation = buildSetFilesViewedGraphQlMutation("PR_1", [ + { path: '") { __typename } evil: markFileAsViewed(input: { path: "x', viewed: true }, + ]); + expect(mutation).not.toBeNull(); + if (mutation === null) return; + expect(mutation.query).not.toContain("evil"); + expect(mutation.variables.f0_path).toBe( + '") { __typename } evil: markFileAsViewed(input: { path: "x', + ); + }); +}); + +describe("host-native stack decoding", () => { + /** A stack as the preview lists it, bottom to top, with the fields it answers today. */ + function stack(overrides: Record = {}) { + return { + id: 42, + number: 3, + node_id: "STK_kwDO", + url: "https://api.github.com/repos/acme/web/stacks/3", + base: { ref: "main", sha: "abc" }, + open: true, + created_at: "2026-09-01T00:00:00Z", + pull_requests: [ + { + number: 10, + head: { ref: "feat/one" }, + state: "closed", + merged_at: "2026-09-02T00:00:00Z", + }, + { number: 11, head: { ref: "feat/two" }, state: "open", merged_at: null }, + { number: 12, head: { ref: "feat/three" }, state: "closed", merged_at: null }, + ], + ...overrides, + }; + } + + /** The one stack a listing answered with, which these reads all expect to find. */ + function expectStack(overrides: Record = {}) { + const decoded = expectSuccess(decodePullRequestStacksJson(JSON.stringify([stack(overrides)]))); + if (decoded === null) throw new Error("expected a stack"); + return decoded; + } + + it("reads the first stack, bottom to top, with merged_at outranking state", () => { + expect(expectStack()).toEqual({ + id: "42", + number: 3, + url: "https://api.github.com/repos/acme/web/stacks/3", + base: "main", + layers: [ + { number: 10, headBranch: "feat/one", state: "merged" }, + { number: 11, headBranch: "feat/two", state: "open" }, + { number: 12, headBranch: "feat/three", state: "closed" }, + ], + }); + }); + + it("retains the detailed layer titles, draft state and expected revision", () => { + expect( + expectStack({ + pull_requests: [ + { + number: 11, + title: "Second layer", + draft: true, + head: { ref: "feat/two", sha: "abc123" }, + state: "open", + merged_at: null, + }, + ], + }).layers, + ).toEqual([ + { + number: 11, + title: "Second layer", + isDraft: true, + headSha: "abc123", + headBranch: "feat/two", + state: "open", + }, + ]); + }); + + it("accepts a base named as a bare branch, which is what the preview started out sending", () => { + expect(expectStack({ base: "develop" }).base).toBe("develop"); + }); + + it("prefers the page a person opens over the API URL, where the host reports one", () => { + expect(expectStack({ html_url: "https://github.com/acme/web/stacks/3" }).url).toBe( + "https://github.com/acme/web/stacks/3", + ); + }); + + it("falls back to the node id, then the number, for a stack without an id", () => { + expect(expectStack({ id: undefined }).id).toBe("STK_kwDO"); + expect(expectStack({ id: null, node_id: null }).id).toBe("3"); + }); + + it("reads an empty listing as not stacked", () => { + expect(expectSuccess(decodePullRequestStacksJson("[]"))).toBeNull(); + }); + + it("refuses a stack without a number or without its pull requests", () => { + expect( + Result.isSuccess(decodePullRequestStacksJson(JSON.stringify([stack({ number: undefined })]))), + ).toBe(false); + expect( + Result.isSuccess( + decodePullRequestStacksJson(JSON.stringify([stack({ pull_requests: undefined })])), + ), + ).toBe(false); + expect(Result.isSuccess(decodePullRequestStacksJson("{"))).toBe(false); + }); +}); + +describe("pull request stack membership batches", () => { + it("maps aliases while skipping missing pull requests and incomplete memberships", () => { + const memberships = expectSuccess( + decodePullRequestStackMembershipsJson( + JSON.stringify({ + data: { + s0: { + pullRequest: { + stack: { number: 3, size: 2, baseRefName: "main" }, + stackEntry: { position: 1 }, + }, + }, + s1: null, + s2: { pullRequest: null }, + s3: { pullRequest: { stack: null, stackEntry: null } }, + s4: { pullRequest: { stack: { number: 3, size: 2, baseRefName: "main" } } }, + }, + }), + ), + ); + expect([...memberships]).toEqual([[0, { number: 3, size: 2, base: "main", position: 1 }]]); + }); + + it("refuses malformed responses and unsafe query selectors", () => { + expect(Result.isFailure(decodePullRequestStackMembershipsJson('{"errors":[]}'))).toBe(true); + expect(buildPullRequestStackMembershipsGraphQlQuery('acme/web") { x } #', [1])).toBeNull(); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [0])).toBeNull(); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [1.5])).toBeNull(); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [])).toBeNull(); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [7, 8])?.variables).toEqual({ + s0_owner: "acme", + s0_name: "web", + s0_number: 7, + s1_owner: "acme", + s1_name: "web", + s1_number: 8, + }); + }); +}); + +describe("batched pull request summaries", () => { + it("refuses a repository GraphQL cannot address, rather than writing it into the document", () => { + expect( + buildPullRequestSummariesGraphQlQuery([{ repository: 'acme/web") { x } #', number: 1 }]), + ).toBeNull(); + expect( + buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 0 }]), + ).toBeNull(); + expect(buildPullRequestSummariesGraphQlQuery([])).toBeNull(); + }); + + it("files each answer by its alias and skips what GitHub or the decoder could not give", () => { + const decoded = decodePullRequestSummariesJson( + JSON.stringify({ + data: { + s0: { + pullRequest: { + number: 7, + title: "Merged", + url: "https://github.com/acme/web/pull/7", + author: { __typename: "Bot", login: "renovate", avatarUrl: "https://a/r.png" }, + headRefName: "feat/seven", + baseRefName: "main", + state: "MERGED", + mergedAt: "2026-08-24T00:00:00Z", + closedAt: "2026-08-24T00:00:00Z", + updatedAt: "2026-08-24T00:00:00Z", + commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] }, + }, + }, + s1: { pullRequest: null }, + s2: { pullRequest: { number: 9 } }, + rateLimit: { cost: 1 }, + }, + }), + ); + expect(Result.isSuccess(decoded)).toBe(true); + if (!Result.isSuccess(decoded)) return; + expect([...decoded.success.keys()]).toEqual([0]); + expect(decoded.success.get(0)).toMatchObject({ + number: 7, + state: "merged", + mergedAt: "2026-08-24T00:00:00Z", + author: { login: "renovate", isBot: true }, + checksState: "failing", + mergeability: "unknown", + additions: 0, + }); + // The document did not ask about stacks, so the summary does not claim an answer. + expect(decoded.success.get(0)).not.toHaveProperty("stack"); + }); + + it("reads stack membership only where the document asked for it", () => { + expect( + buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }], true)?.query, + ).toContain("stack { number size baseRefName } stackEntry { position }"); + expect( + buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }])?.query, + ).not.toContain("stack {"); + const pullRequest = (number: number, stack: Record) => ({ + pullRequest: { + number, + title: "Stacked", + url: `https://github.com/acme/web/pull/${number}`, + headRefName: `feat/${number}`, + baseRefName: "main", + state: "OPEN", + updatedAt: "2026-08-24T00:00:00Z", + ...stack, + }, + }); + const decoded = expectSuccess( + decodePullRequestSummariesJson( + JSON.stringify({ + data: { + s0: pullRequest(7, { stack: null, stackEntry: null }), + s1: pullRequest(8, { + stack: { number: 3, size: 2, baseRefName: "main" }, + stackEntry: { position: 2 }, + }), + }, + }), + ), + ); + expect(decoded.get(0)?.stack).toBeNull(); + expect(decoded.get(1)?.stack).toEqual({ number: 3, size: 2, base: "main", position: 2 }); + }); +}); + +describe("pull request watch fingerprints", () => { + it("asks for every pull request in one aliased read, and refuses an unsafe selector", () => { + const document = buildPullRequestWatchFingerprintsGraphQlQuery([ + { repository: "pingdotgg/t3code", number: 7 }, + { repository: "pingdotgg/lakebed", number: 8 }, + ]); + expect(document?.query).toContain( + "w0: repository(owner: $w0_owner, name: $w0_name) { pullRequest(number: $w0_number)", + ); + expect(document?.query).toContain("w1: repository(owner: $w1_owner, name: $w1_name)"); + expect(document?.variables).toEqual({ + w0_owner: "pingdotgg", + w0_name: "t3code", + w0_number: 7, + w1_owner: "pingdotgg", + w1_name: "lakebed", + w1_number: 8, + }); + expect( + buildPullRequestWatchFingerprintsGraphQlQuery([{ repository: 'evil") { x', number: 1 }]), + ).toBeNull(); + }); + + it("moves status with checks and remarks with comments, each without the other", () => { + const pullRequest = (input: { + readonly running: number; + readonly failed: number; + readonly commentEditedAt: string | null; + readonly reviews: number; + }) => ({ + state: "OPEN", + mergeable: "MERGEABLE", + headRefOid: "abc123", + comments: { totalCount: 1, nodes: [{ lastEditedAt: input.commentEditedAt }] }, + reviews: { totalCount: input.reviews, nodes: [] }, + reviewThreads: { totalCount: 0 }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { + checkRunCountsByState: [ + { state: "IN_PROGRESS", count: input.running }, + { state: "FAILURE", count: input.failed }, + { state: "SUCCESS", count: 0 }, + ], + statusContextCountsByState: null, + }, + }, + }, + }, + ], + }, + }); + const decode = (...pullRequests: ReadonlyArray) => { + const decoded = decodePullRequestWatchFingerprintsJson( + JSON.stringify({ + data: Object.fromEntries( + pullRequests.map((value, index) => [`w${index}`, { pullRequest: value }]), + ), + }), + ); + if (!Result.isSuccess(decoded)) throw new Error("fingerprints did not decode"); + return decoded.success; + }; + const base = { running: 2, failed: 0, commentEditedAt: null, reviews: 1 }; + const fingerprint = (input: Parameters[0]) => + decode(pullRequest(input)).get(0)!; + const before = fingerprint(base); + const checkFinished = fingerprint({ ...base, running: 1, failed: 1 }); + const summaryEdited = fingerprint({ ...base, commentEditedAt: "2026-10-05T23:47:43Z" }); + const replied = fingerprint({ ...base, reviews: 2 }); + + expect(checkFinished.status).not.toBe(before.status); + expect(checkFinished.remarks).toBe(before.remarks); + expect(summaryEdited.remarks).not.toBe(before.remarks); + expect(summaryEdited.status).toBe(before.status); + expect(replied.remarks).not.toBe(before.remarks); + // A pull request GitHub had no answer for is left for a full read. + expect(decode(null, pullRequest(base)).has(0)).toBe(false); + }); +}); diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts new file mode 100644 index 0000000000000000000000000000000000000000..956f1ef5f4adfa2e8ba6e69e29f276fb31b09e41 --- /dev/null +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -0,0 +1,3462 @@ +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestStackMembership, + PullRequestActor, + PullRequestPreview, + PullRequestCheck, + PullRequestCheckStatus, + PullRequestChecksState, + PullRequestComment, + PullRequestCommit, + PullRequestFileViewedState, + PullRequestLabel, + PullRequestMergeCapabilities, + PullRequestMergeMethod, + PullRequestOmittedFileStat, + PullRequestMergeability, + PullRequestReaction, + PullRequestReactionContent, + PullRequestReviewCommentDraft, + PullRequestReviewDecision, + PullRequestReviewPosition, + PullRequestReviewThread, + PullRequestReviewVerdict, + PullRequestReviewerCandidate, + PullRequestReviewerCandidateList, + PullRequestReviewerKind, + PullRequestLabelCandidate, + PullRequestLabelCandidateList, + PullRequestState, + PullRequestThreadComment, +} from "@t3tools/contracts"; +import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +import { aliasedGraphQlDocument, type GraphQlDocument } from "./githubGraphQl.ts"; +import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks"; + +/** + * Enum-ish GitHub CLI fields are decoded as plain strings and normalized here: a `gh` + * release that adds a conclusion or a review state must not fail the whole payload. + */ +const RawActorSchema = Schema.Struct({ + __typename: Schema.optional(Schema.String), + is_bot: Schema.optional(Schema.Boolean), + /** + * Optional because a review can be requested from a team or a mannequin, which the query has + * no fragment for and GraphQL answers with an empty object. A reviewer with no login names + * nobody to show, and must not fail the response the conversation travels in. + */ + login: Schema.optional(Schema.String), + /** The node id, which is how a listing's authors are resolved to avatars in one request. */ + id: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + /** Only the GraphQL API reports one; `gh pr view --json` has no avatar to give. */ + avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawLabelSchema = Schema.Struct({ + name: Schema.String, + color: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawReviewRequestSchema = Schema.Struct({ + login: Schema.optional(Schema.NullOr(Schema.String)), + slug: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** One reviewer's most recent review: the state is all the verdict needs, the author is for who. */ +const RawLatestReviewSchema = Schema.Struct({ + author: Schema.optional(Schema.NullOr(RawActorSchema)), + state: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawCheckSchema = Schema.Struct({ + __typename: Schema.optional(Schema.String), + name: Schema.optional(Schema.NullOr(Schema.String)), + context: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional(Schema.NullOr(Schema.String)), + conclusion: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), + detailsUrl: Schema.optional(Schema.NullOr(Schema.String)), + targetUrl: Schema.optional(Schema.NullOr(Schema.String)), + /** + * What tells two same-named checks apart, and which run of one is the newest. All three ride + * along with `statusCheckRollup` already — it is asked for as a whole field — so reading them + * costs no request. Empty for an app-provided check run, which belongs to no workflow, and + * absent entirely on a commit status, which is not a run at all. + */ + workflowName: Schema.optional(Schema.NullOr(Schema.String)), + startedAt: Schema.optional(Schema.NullOr(Schema.String)), + completedAt: Schema.optional(Schema.NullOr(Schema.String)), + /** Branch protection requires this check; read by the detail query on github.com only. */ + isRequired: Schema.optional(Schema.NullOr(Schema.Boolean)), +}); + +const RawListItemSchema = Schema.Struct({ + number: Schema.Int, + title: Schema.String, + url: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + headRefName: Schema.String, + baseRefName: Schema.String, + state: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.Boolean), + mergeable: Schema.optional(Schema.NullOr(Schema.String)), + reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), + additions: Schema.optional(Schema.Int), + deletions: Schema.optional(Schema.Int), + createdAt: Schema.String, + updatedAt: Schema.String, + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), + reviewRequests: Schema.optional(Schema.Array(RawReviewRequestSchema)), + latestReviews: Schema.optional(Schema.NullOr(Schema.Array(RawLatestReviewSchema))), + labels: Schema.optional(Schema.Array(RawLabelSchema)), + /** + * Every check of the head commit, which is the only rollup `gh pr list --json` can give: there + * is no field for the one-word verdict. Measured against `pingdotgg/t3code`, asking for it costs + * 0.6s -> 7.9s at a hundred rows and 0.9s -> 2.1s at thirty, for 425 KB of checks a listing + * reduces to one word. The listing pays it because the alternative is a request per row; the + * cross-repository search below asks GitHub for the verdict itself instead. + */ + statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))), +}); + +const RawStackMembershipSchema = Schema.Struct({ + stack: Schema.optional( + Schema.NullOr( + Schema.Struct({ number: Schema.Int, size: Schema.Int, baseRefName: Schema.String }), + ), + ), + stackEntry: Schema.optional(Schema.NullOr(Schema.Struct({ position: Schema.Int }))), +}); + +/** + * A search's own answer, which is the listing's row one connection deeper: `gh pr list --json` + * flattens reviewers and labels, and GraphQL does not. Everything below the row is optional + * because a node that is not a pull request decodes as an empty object, which is skipped. + */ +const RawSearchItemSchema = Schema.Struct({ + ...RawStackMembershipSchema.fields, + number: Schema.Int, + title: Schema.String, + url: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + headRefName: Schema.String, + baseRefName: Schema.String, + state: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.Boolean), + mergeable: Schema.optional(Schema.NullOr(Schema.String)), + reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), + latestReviews: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLatestReviewSchema)) })), + ), + /** Asked for by the per-repository listing, and left out of the cross-repository search. */ + additions: Schema.optional(Schema.Int), + deletions: Schema.optional(Schema.Int), + createdAt: Schema.String, + updatedAt: Schema.String, + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), + repository: Schema.optional(Schema.NullOr(Schema.Struct({ nameWithOwner: Schema.String }))), + reviewRequests: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + requestedReviewer: Schema.optional( + Schema.NullOr( + Schema.Struct({ + ...RawActorSchema.fields, + slug: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + }), + ), + ), + ), + ), + }), + ), + ), + labels: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.NullOr(RawLabelSchema)))), + }), + ), + ), + /** + * GraphQL answers the rollup a listing actually wants — one enum for the head commit, rather + * than the whole check array `gh pr list --json` insists on. Measured at a hundred rows across + * this repository: 0.8s -> 3.0s and 15 KB, against 425 KB for the same verdict over `gh`. + */ + commits: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + commit: Schema.optional( + Schema.NullOr( + Schema.Struct({ + statusCheckRollup: Schema.optional( + Schema.NullOr(Schema.Struct({ state: Schema.String })), + ), + }), + ), + ), + }), + ), + ), + ), + ), + }), + ), + ), +}); + +const RawRowConnectionSchema = Schema.Struct({ + pageInfo: Schema.optional( + Schema.NullOr( + Schema.Struct({ + hasNextPage: Schema.Boolean, + endCursor: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + // Row by row: a node that is not a pull request — or one field GitHub changes — is skipped + // rather than blanking every repository at once. + nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), +}); + +const RawSearchSchema = Schema.Struct({ + data: Schema.Struct({ search: RawRowConnectionSchema }), +}); + +const RawRepositoryPullRequestsSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr(Schema.Struct({ pullRequests: RawRowConnectionSchema })), + }), +}); + +/** One aliased lookup per row, so the response is keyed by the position it was asked in. */ +const RawStatsSchema = Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr( + Schema.Struct({ + pullRequest: Schema.optional( + Schema.NullOr( + Schema.Struct({ + additions: Schema.optional(Schema.NullOr(Schema.Int)), + deletions: Schema.optional(Schema.NullOr(Schema.Int)), + }), + ), + ), + }), + ), + ), + ), + ), +}); + +const RawStackMembershipsSchema = Schema.Struct({ + data: Schema.Record( + Schema.String, + Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(RawStackMembershipSchema) })), + ), +}); + +/** How many of a reaction's people the hover names before it counts the rest. */ +const REACTORS_PER_GROUP = 10; + +/** + * A reaction group as every reactable node reports it. `reactors` is bounded rather than paged: + * a hover says who reacted, and a hundred and forty names is a count, not a sentence. + */ +const REACTION_GROUPS_FIELDS = `reactionGroups { + content + viewerHasReacted + reactors(first: ${REACTORS_PER_GROUP}) { + totalCount + nodes { + ... on User { login } + ... on Bot { login } + ... on Organization { login } + ... on Mannequin { login } + } + } +}`; + +/** GitHub's reaction names, which are the same eight the contract carries under other spellings. */ +const REACTION_CONTENT_BY_GITHUB: Readonly> = { + THUMBS_UP: "thumbs-up", + THUMBS_DOWN: "thumbs-down", + LAUGH: "laugh", + HOORAY: "hooray", + CONFUSED: "confused", + HEART: "heart", + ROCKET: "rocket", + EYES: "eyes", +}; + +const GITHUB_REACTION_BY_CONTENT: Readonly> = { + "thumbs-up": "THUMBS_UP", + "thumbs-down": "THUMBS_DOWN", + laugh: "LAUGH", + hooray: "HOORAY", + confused: "CONFUSED", + heart: "HEART", + rocket: "ROCKET", + eyes: "EYES", +}; + +export function gitHubReactionContent(content: PullRequestReactionContent): string { + return GITHUB_REACTION_BY_CONTENT[content]; +} + +const RawReactionGroupsSchema = Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + content: Schema.optional(Schema.NullOr(Schema.String)), + viewerHasReacted: Schema.optional(Schema.Boolean), + reactors: Schema.optional( + Schema.NullOr( + Schema.Struct({ + totalCount: Schema.optional(Schema.Int), + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) }), + ), + ), + ), + ), + }), + ), + ), + }), + ), + ), +); + +type RawReactionGroups = typeof RawReactionGroupsSchema.Type; + +/** + * The groups GitHub answered with, as the contract carries them. A group with nobody behind it is + * dropped: GitHub answers with a group per content it knows, including the ones nobody chose. The + * viewer's own login is left out of `actors` — the page names them "You" instead, and leaving it + * in would name them twice — but `count` still counts them along with everyone else. + */ +function toReactions( + groups: RawReactionGroups, + viewer: string | null, +): ReadonlyArray { + const normalizedViewer = viewer?.toLowerCase() ?? null; + const reactions: PullRequestReaction[] = []; + for (const group of groups ?? []) { + const content = REACTION_CONTENT_BY_GITHUB[trimmed(group.content)?.toUpperCase() ?? ""]; + if (content === undefined) continue; + const logins = (group.reactors?.nodes ?? []).flatMap((node) => trimmed(node?.login) ?? []); + const count = Math.max(group.reactors?.totalCount ?? logins.length, logins.length); + if (count <= 0) continue; + const actors = + normalizedViewer === null + ? logins + : logins.filter((login) => login.toLowerCase() !== normalizedViewer); + reactions.push({ content, count, actors, viewerHasReacted: group.viewerHasReacted === true }); + } + return reactions; +} + +const RawCommentSchema = Schema.Struct({ + id: Schema.String, + lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawActorSchema)), + body: Schema.optional(Schema.String), + createdAt: Schema.String, + url: Schema.optional(Schema.NullOr(Schema.String)), + /** Only ever present on a GraphQL read; `gh pr view --json` reports no reaction at all. */ + reactionGroups: RawReactionGroupsSchema, +}); + +const RawReviewSchema = Schema.Struct({ + id: Schema.String, + lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawActorSchema)), + body: Schema.optional(Schema.String), + state: Schema.optional(Schema.NullOr(Schema.String)), + submittedAt: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawCommitSchema = Schema.Struct({ + oid: Schema.String, + messageHeadline: Schema.optional(Schema.String), + committedDate: Schema.String, + authors: Schema.optional( + Schema.Array( + Schema.Struct({ + email: Schema.optional(Schema.NullOr(Schema.String)), + id: Schema.optional(Schema.NullOr(Schema.String)), + login: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), +}); + +const RawDetailSchema = Schema.Struct({ + ...RawListItemSchema.fields, + /** GitHub's explicit distinction between a fork head and a branch in the base repository. */ + isCrossRepository: Schema.optional(Schema.Boolean), + /** Names the fork a pull request came from, which is what qualifies its head ref. */ + headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), + /** The exact head revision, used to find workflow runs that GitHub has not started yet. */ + headRefOid: Schema.optional(Schema.NullOr(Schema.String)), + body: Schema.optional(Schema.String), + changedFiles: Schema.optional(Schema.Int), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + /** The standing instruction and strategy GitHub will use once its requirements are met. */ + autoMergeRequest: Schema.optional( + Schema.NullOr(Schema.Struct({ mergeMethod: Schema.optional(Schema.NullOr(Schema.String)) })), + ), +}); + +/** `GET /repos/{owner}/{repo}/actions/runs`, one page of it. */ +const RawWorkflowRunsSchema = Schema.Struct({ + workflow_runs: Schema.Array( + Schema.Struct({ + id: Schema.Int, + name: Schema.optional(Schema.NullOr(Schema.String)), + html_url: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), +}); + +const RawPullRequestHeadsSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ + pullRequests: Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchemaFields()), + nodes: Schema.Array( + Schema.Struct({ + number: Schema.Int, + headRefOid: Schema.String, + isCrossRepository: Schema.optional(Schema.Boolean), + headRepositoryOwner: Schema.optional( + Schema.NullOr(Schema.Struct({ login: Schema.String })), + ), + }), + ), + }), + }), + ), + }), +}); + +const RawActivityConnection = (node: S) => + Schema.optional( + Schema.NullOr( + Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchemaFields()), + nodes: Schema.Array(node), + }), + ), + ); + +const RawActivitySchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + author: Schema.optional(Schema.NullOr(RawActorSchema)), + comments: RawActivityConnection(RawCommentSchema), + reviews: RawActivityConnection(RawReviewSchema), + commits: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + commit: Schema.Struct({ + oid: Schema.String, + messageHeadline: Schema.optional(Schema.String), + committedDate: Schema.String, + authors: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + email: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + user: Schema.optional( + Schema.NullOr( + Schema.Struct({ + login: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + }), + ), + }), + ), + ), + }), + }), + ), + }), + ), + ), + }), + }), + }), +}); + +/** Where a connection carries on from, which is what every paged read below follows. */ +function RawPageInfoSchemaFields() { + return Schema.Struct({ + hasNextPage: Schema.optional(Schema.Boolean), + endCursor: Schema.optional(Schema.NullOr(Schema.String)), + }); +} +const RawPageInfoSchema = RawPageInfoSchemaFields(); + +/** + * What GitHub says the viewer may do with a pull request. Both are optional so that an install + * that answers without them still delivers the conversation they travel with; an absent field + * reads as granted, which is what an unknown permission is. + */ +const RawViewerFieldsSchema = Schema.Struct({ + viewerCanUpdate: Schema.optional(Schema.Boolean), + viewerDidAuthor: Schema.optional(Schema.Boolean), +}); + +const RawThreadCommentsSchema = Schema.Struct({ + totalCount: Schema.optional(Schema.Int), + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array(RawCommentSchema), +}); + +/** `gh pr view --json` cannot reach review threads, so they come from the GraphQL API. */ +const RawReviewThreadsSchema = Schema.Struct({ + data: Schema.Struct({ + // Rides along in the same request: GitHub names who reacted but never says whether that is + // the reader, so the comparison is made here rather than paid for with a request of its own. + viewer: Schema.optional( + Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + reviewThreads: Schema.Struct({ + totalCount: Schema.optional(Schema.Int), + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + isResolved: Schema.optional(Schema.Boolean), + isOutdated: Schema.optional(Schema.Boolean), + path: Schema.optional(Schema.NullOr(Schema.String)), + /** Null once the thread's line has left the diff, which `isOutdated` reports. */ + line: Schema.optional(Schema.NullOr(Schema.Int)), + diffSide: Schema.optional(Schema.NullOr(Schema.String)), + comments: RawThreadCommentsSchema, + }), + ), + }), + ...RawViewerFieldsSchema.fields, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + reactionGroups: RawReactionGroupsSchema, + comments: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawActorSchema)), + lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)), + reactionGroups: RawReactionGroupsSchema, + }), + ), + }), + ), + ), + /** + * Reviews for their reactions and actor identity: the words and the verdict arrive with + * `gh pr view --json reviews`, which reports no reaction of any kind. + */ + reviews: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawActorSchema)), + lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)), + reactionGroups: RawReactionGroupsSchema, + }), + ), + }), + ), + ), + reviewRequests: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + // Null for a team, which is a request nobody in particular owns. + requestedReviewer: Schema.optional(Schema.NullOr(RawActorSchema)), + }), + ), + }), + ), + ), + latestReviews: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawLatestReviewSchema) })), + ), + reviewDismissals: Schema.optional( + Schema.NullOr( + Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array( + Schema.Struct({ + dismissalMessage: Schema.optional(Schema.NullOr(Schema.String)), + review: Schema.optional( + Schema.NullOr( + Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)) }), + ), + ), + }), + ), + }), + ), + ), + commits: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + commit: Schema.Struct({ + oid: Schema.String, + messageHeadline: Schema.optional(Schema.NullOr(Schema.String)), + committedDate: Schema.optional(Schema.NullOr(Schema.String)), + additions: Schema.optional(Schema.Int), + deletions: Schema.optional(Schema.Int), + parents: Schema.optional( + Schema.NullOr(Schema.Struct({ totalCount: Schema.optional(Schema.Int) })), + ), + authors: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), + user: Schema.optional( + Schema.NullOr( + Schema.Struct({ + login: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + }), + ), + }), + ), + ), + }), + }), + ), + }), + ), + ), + }), + }), + }), +}); + +/** Requested together, so a response missing any of them fails rather than defaulting open: + * guessing `true` would offer a merge method the repository forbids. */ +const RawRepositoryAccessSchema = Schema.Struct({ + mergeCommitAllowed: Schema.Boolean, + squashMergeAllowed: Schema.Boolean, + rebaseMergeAllowed: Schema.Boolean, + /** + * ADMIN, MAINTAIN, WRITE, TRIAGE, READ or NONE. Optional rather than required, unlike the + * three above: an install that does not report it leaves the viewer's standing unknown, which + * is answered by granting rather than by failing the whole detail read. + */ + viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawCoreSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + ...RawRepositoryAccessSchema.fields, + pullRequest: Schema.Struct({ + ...RawDetailSchema.fields, + ...RawViewerFieldsSchema.fields, + viewerCanUpdateBranch: Schema.Boolean, + baseRef: Schema.NullOr( + Schema.Struct({ + compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })), + }), + ), + reviewRequests: Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ requestedReviewer: Schema.NullOr(RawReviewRequestSchema) }), + ), + }), + labels: Schema.Struct({ nodes: Schema.Array(RawLabelSchema) }), + commits: Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + commit: Schema.Struct({ + statusCheckRollup: Schema.NullOr( + Schema.Struct({ + contexts: Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + ...RawCheckSchema.fields, + checkSuite: Schema.optional( + Schema.NullOr( + Schema.Struct({ + workflowRun: Schema.NullOr( + Schema.Struct({ + workflow: Schema.NullOr(Schema.Struct({ name: Schema.String })), + }), + ), + }), + ), + ), + }), + ), + pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean }), + }), + }), + ), + }), + }), + ), + }), + }), + }), + }), +}); +const decodeCore = decodeJsonResult(RawCoreSchema); + +const RawPullRequestFileSchema = Schema.Struct({ + filename: Schema.String, + status: Schema.optional(Schema.NullOr(Schema.String)), + /** Only on a rename, where it names the file the hunks are counted against. */ + previous_filename: Schema.optional(Schema.NullOr(Schema.String)), + /** Absent for a binary file, and for one whose diff GitHub considers too large. */ + patch: Schema.optional(Schema.NullOr(Schema.String)), + /** Whether anything was withheld is the difference between a binary file and a pure rename. */ + additions: Schema.optional(Schema.NullOr(Schema.Int)), + deletions: Schema.optional(Schema.NullOr(Schema.Int)), +}); + +/** Resolves a listing's authors to avatars, which no `gh` JSON field carries. */ +export const ACTOR_AVATARS_GRAPHQL_QUERY = `query($ids: [ID!]!) { + nodes(ids: $ids) { + ... on User { login avatarUrl } + ... on Bot { login avatarUrl } + } +}`; + +const RawActorAvatarsSchema = Schema.Struct({ + data: Schema.Struct({ + nodes: Schema.Array(Schema.NullOr(RawActorSchema)), + }), +}); + +const decodeActorAvatars = decodeJsonResult(RawActorAvatarsSchema); + +export function decodeActorAvatarsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeActorAvatars(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const avatarsByLogin = new Map(); + for (const node of decoded.success.data.nodes) { + const login = trimmed(node?.login); + const avatarUrl = trimmed(node?.avatarUrl); + if (login !== null && avatarUrl !== null) avatarsByLogin.set(login, avatarUrl); + } + return Result.succeed(avatarsByLogin); +} + +/** + * Pull refs let the comparison share the detail read without first resolving a fork branch. + * `isRequired` is asked for on github.com only: an older Enterprise server may not know it, and + * an unknown field fails the whole read. + */ +function checkContextNodesSelection(host: string): string { + const required = + host.toLowerCase() === "github.com" ? " isRequired(pullRequestNumber: $number)" : ""; + return `nodes { + __typename + ... on StatusContext { context state targetUrl createdAt description${required} } + ... on CheckRun { + name status conclusion startedAt completedAt detailsUrl${required} + checkSuite { workflowRun { workflow { name } } } + } + }`; +} + +export const pullRequestCoreGraphQlQuery = (host: string) => { + return `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { + repository(owner: $owner, name: $name) { + mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission + pullRequest(number: $number) { + number title url body state isDraft mergeable reviewDecision + additions deletions changedFiles createdAt updatedAt mergedAt closedAt + headRefName baseRefName headRefOid isCrossRepository + headRepositoryOwner { login } + author { login avatarUrl ... on User { id name } } + autoMergeRequest { mergeMethod } + viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch + baseRef { compare(headRef: $headRef) { behindBy } } + reviewRequests(first: 100) { + nodes { requestedReviewer { ... on User { login name } ... on Bot { login } ... on Team { slug name } } } + } + labels(first: 100) { nodes { name color } } + commits(last: 1) { + nodes { commit { statusCheckRollup { contexts(first: 100) { + ${checkContextNodesSelection(host)} + pageInfo { hasNextPage } + } } } } + } + } + } +}`; +}; + +export const PULL_REQUEST_PREVIEW_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + number title url state isDraft createdAt + author { login avatarUrl ... on User { name } } + } + } +}`; + +const decodePullRequestPreview = decodeJsonResult( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + number: Schema.Int, + title: Schema.String, + url: Schema.String, + state: Schema.String, + isDraft: Schema.Boolean, + createdAt: Schema.String, + author: Schema.NullOr(RawActorSchema), + }), + }), + }), + }), +); + +export function decodePullRequestPreviewJson( + raw: string, +): Result.Result, DecodeFailure> { + return Result.map(decodePullRequestPreview(raw), ({ data }) => ({ + ...data.repository.pullRequest, + author: toActor(data.repository.pullRequest.author), + state: toState(data.repository.pullRequest), + })); +} + +/** + * The conversation a pull request carries outside its review threads: who opened it, its issue + * comments, its reviews and its newest commits. The two remark connections page independently, so + * each is switched on only while it has more to give; the first read asks for everything. + */ +export const PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $head: Boolean!, $withComments: Boolean!, $commentsAfter: String, $withReviews: Boolean!, $reviewsAfter: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + author @include(if: $head) { __typename login avatarUrl ... on User { name } } + commits(last: 100) @include(if: $head) { + nodes { commit { oid messageHeadline committedDate authors(first: 10) { nodes { name email user { login } } } } } + } + comments(first: 100, after: $commentsAfter) @include(if: $withComments) { + pageInfo { hasNextPage endCursor } + nodes { id body createdAt lastEditedAt url author { __typename login avatarUrl ... on User { name } } } + } + reviews(first: 100, after: $reviewsAfter) @include(if: $withReviews) { + pageInfo { hasNextPage endCursor } + nodes { id body state submittedAt lastEditedAt url author { __typename login avatarUrl ... on User { name } } } + } + } + } +}`; + +/** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */ +const GRAPHQL_PAGE_SIZE = 100; + +/** + * The ceiling on `search`, which refuses anything larger with EXCESSIVE_PAGINATION (measured: + * `first: 101` is an error, `first: 100` is not). + */ +export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; + +/** + * Every repository of a host in one read, which is what makes a listing one request rather than + * one process per repository. + * + * `additions` and `deletions` are deliberately absent: measured over twelve repositories at a + * hundred rows, this query answers in ~4.0s with them left out and ~7.1s with them in, for two + * numbers at the end of a row. They are read afterwards, by `buildPullRequestStatsGraphQlQuery`. + * + * The row count is written into the document rather than sent as a variable because every + * variable here travels as a string — and it is this module's own number, clamped by the caller, + * never a reader's. + * + * `first` on the two inner connections is a bound rather than a page: a pull request with more + * than twenty labels shows twenty, and one that has asked more than twenty people for a review + * is already past what a row can say. + */ +export function pullRequestSearchGraphQlQuery( + rows: number, + includeStacks = false, + includeStats = false, +): string { + return `query($q: String!, $after: String) { + search(query: $q, type: ISSUE, first: ${pageRows(rows)}, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + ... on PullRequest { + ${pullRequestRowSelection(includeStacks, includeStats)} + } + } + } +}`; +} + +/** + * A repository's pull requests without search, newest created first: the order `gh pr list` lists + * in when it does not search, for a repository GitHub's search index does not cover. + */ +export function pullRequestListGraphQlQuery(rows: number, includeStacks = false): string { + return `query($owner: String!, $name: String!, $states: [PullRequestState!], $after: String) { + repository(owner: $owner, name: $name) { + pullRequests(first: ${pageRows(rows)}, after: $after, states: $states, orderBy: { field: CREATED_AT, direction: DESC }) { + pageInfo { hasNextPage endCursor } + nodes { ${pullRequestRowSelection(includeStacks, true)} } + } + } +}`; +} + +function pageRows(rows: number): number { + return Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS); +} + +/** One listing row, the same whether it came from a search or from a repository's own list. */ +function pullRequestRowSelection(includeStacks: boolean, includeStats: boolean): string { + return `${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""} + number + title + url + author { __typename login avatarUrl ... on User { name } } + headRefName + baseRefName + state + isDraft + mergeable + reviewDecision + latestReviews(first: 20) { nodes { state author { login } } } + ${includeStats ? "additions deletions" : ""} + createdAt + updatedAt + mergedAt + repository { nameWithOwner } + reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } ... on Team { slug } } } } + labels(first: 20) { nodes { name color } } + commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }`; +} + +/** + * One page of review threads with their comments, and the people on the review. `$cursor` is + * null for the first page and the last page's `endCursor` after that, so a pull request with + * more threads than one page holds is walked rather than cut off at the first fifty. + * + * Only ten comments ride with each thread. A hundred threads times a hundred comments made + * GitHub reserve 10,000 nested rows and charge 104 points; unfinished threads are paged from + * their own cursor below. + * + * Reviewers come from here rather than from `gh pr view --json reviewRequests` for two reasons: + * that field holds only requests still outstanding, so anyone who has already reviewed drops off + * it, and neither it nor any other `gh` JSON field carries an avatar. A reviewer can be a person + * or an app, and both are asked for by name because they are different GraphQL types. + * + * `viewerCanUpdate` and `viewerDidAuthor` ride along here for the same reason: they belong to the + * pull request this query is already standing on, so what the reader may do with it arrives with + * the conversation rather than costing a request of its own. + * + * Commits are asked for with `last` rather than `first`: `gh pr view --json commits` pages from + * the start, so a pull request with more than a hundred commits loses the newest ones from its + * view entirely. This query gives back the newest hundred, which is what a reader scoping a diff + * wants, and stands in for the `gh` list wherever it came back non-empty. + */ +export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) { + viewer { login } + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { + id + isResolved + isOutdated + path + line + diffSide + comments(first: 10) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { id author { __typename login avatarUrl } body createdAt lastEditedAt url ${REACTION_GROUPS_FIELDS} } + } + } + } + viewerCanUpdate + viewerDidAuthor + author { __typename login avatarUrl } + ${REACTION_GROUPS_FIELDS} + comments(first: ${GRAPHQL_PAGE_SIZE}) { + nodes { id lastEditedAt author { __typename login avatarUrl } ${REACTION_GROUPS_FIELDS} } + } + reviews(first: ${GRAPHQL_PAGE_SIZE}) { nodes { id lastEditedAt author { __typename login avatarUrl } ${REACTION_GROUPS_FIELDS} } } + reviewRequests(first: 50) { + nodes { + requestedReviewer { + ... on User { login name avatarUrl } + ... on Bot { __typename login avatarUrl } + } + } + } + latestReviews(first: 50) { + nodes { state author { __typename login avatarUrl } } + } + reviewDismissals: timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}) { + pageInfo { hasNextPage endCursor } + nodes { ... on ReviewDismissedEvent { dismissalMessage review { id } } } + } + commits(last: ${GRAPHQL_PAGE_SIZE}) { + nodes { + commit { + oid + messageHeadline + committedDate + additions + deletions + parents(first: 1) { totalCount } + authors(first: 3) { nodes { name avatarUrl user { login } } } + } + } + } + } + } +}`; + +/** + * The rest of one thread's conversation. GraphQL pages a connection nested inside another only + * from the inner node itself, so a thread longer than a page is followed on its own — a request + * GitHub makes necessary, and one no ordinary pull request ever provokes. + */ +export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $threadId: ID!, $cursor: String) { + viewer { login } + repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } } + node(id: $threadId) { + ... on PullRequestReviewThread { + pullRequest { id } + comments(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { id author { __typename login avatarUrl } body createdAt lastEditedAt url ${REACTION_GROUPS_FIELDS} } + } + } + } +}`; + +const RawReviewThreadCommentsSchema = Schema.Struct({ + data: Schema.Struct({ + viewer: Schema.optional( + Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + repository: Schema.NullOr( + Schema.Struct({ pullRequest: Schema.NullOr(Schema.Struct({ id: Schema.String })) }), + ), + /** Null for an id that names nothing the viewer can read, which is not a thread to page. */ + node: Schema.NullOr( + Schema.Struct({ + pullRequest: Schema.optional(Schema.Struct({ id: Schema.String })), + comments: Schema.optional(RawThreadCommentsSchema), + }), + ), + }), +}); + +export const REVIEW_THREAD_REPLY_GRAPHQL_MUTATION = `mutation($threadId: ID!, $body: String!) { + addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { + comment { id } + } +}`; + +/** + * The pull request's own node id, which is what a reaction on its description is addressed by. + * Read only when one is being written: the conversation carries an id for every remark in it, and + * the pull request is the one subject nothing in it names. + */ +export const PULL_REQUEST_NODE_ID_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } } +}`; + +const RawPullRequestNodeIdSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ id: Schema.String }), + }), + }), +}); + +const decodePullRequestNodeId = decodeJsonResult(RawPullRequestNodeIdSchema); + +export function decodePullRequestNodeIdJson(raw: string): Result.Result { + const decoded = decodePullRequestNodeId(raw); + return Result.isSuccess(decoded) + ? Result.succeed(decoded.success.data.repository.pullRequest.id) + : Result.fail(decoded.failure); +} + +/** + * Where a client-given reaction subject actually hangs: the pull request itself, or the pull + * request an issue comment, a review comment, or a review belongs to. Read before a mutation + * reaches it, so a subject named for one pull request cannot react on another's behalf. + */ +export const REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $subjectId: ID!) { + repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } } + node(id: $subjectId) { + id + ... on IssueComment { pullRequest { id } } + ... on PullRequestReviewComment { pullRequest { id } } + ... on PullRequestReview { pullRequest { id } } + } +}`; + +const RawReactionSubjectScopeSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ pullRequest: Schema.NullOr(Schema.Struct({ id: Schema.String })) }), + ), + node: Schema.NullOr( + Schema.Struct({ + id: Schema.String, + pullRequest: Schema.optional(Schema.Struct({ id: Schema.String })), + }), + ), + }), +}); + +const decodeReactionSubjectScope = decodeJsonResult(RawReactionSubjectScopeSchema); + +/** + * True when the subject named is the pull request itself, or hangs off it — false for anything + * else, including a subject or a pull request this host could not find. + */ +export function decodeReactionSubjectScopeJson(raw: string): Result.Result { + const decoded = decodeReactionSubjectScope(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const expected = decoded.success.data.repository?.pullRequest?.id ?? null; + const node = decoded.success.data.node; + const actual = node === null ? null : (node.pullRequest?.id ?? node.id); + return Result.succeed(expected !== null && actual !== null && expected === actual); +} + +export const ADD_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) { + addReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } } +}`; + +export const REMOVE_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) { + removeReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } } +}`; + +export const RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) { + resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } +}`; + +export const UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) { + unresolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } +}`; + +/** + * Rewrites the pull request's own words. Both are nullable so that one document serves a change + * to the title, to the description, or to the two together: a variable the request does not send + * puts no entry in the input at all, which leaves that field as it was rather than clearing it. + */ +export const UPDATE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $title: String, $body: String) { + updatePullRequest(input: { pullRequestId: $pullRequestId, title: $title, body: $body }) { + pullRequest { id } + } +}`; + +/** Creates a new pull request that reverses a merged pull request. */ +export const REVERT_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { + revertPullRequest(input: { pullRequestId: $pullRequestId }) { + revertPullRequest { id } + } +}`; + +/** + * The two comment mutations name their comment differently. The variable is spelled the same in + * both, so a rewrite sends one set of variables whichever kind of remark it is. + */ +export const UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { + updateIssueComment(input: { id: $commentId, body: $body }) { issueComment { id } } +}`; + +export const UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { + updatePullRequestReviewComment(input: { pullRequestReviewCommentId: $commentId, body: $body }) { + pullRequestReviewComment { id } + } +}`; + +/** The body of `POST /repos/{owner}/{repo}/pulls/{number}/reviews`, which sends a review whole. */ +const ReviewSubmissionSchema = Schema.Struct({ + event: Schema.Literals(["COMMENT", "APPROVE", "REQUEST_CHANGES"]), + body: Schema.String, + comments: Schema.Array( + Schema.Struct({ + path: Schema.String, + line: Schema.Int, + side: Schema.Literals(["LEFT", "RIGHT"]), + body: Schema.String, + }), + ), +}); + +const REVIEW_EVENTS: Record = { + comment: "COMMENT", + approve: "APPROVE", + "request-changes": "REQUEST_CHANGES", +}; + +/** + * The dismissal events past the page the thread read carries. A pull request rarely has any: + * this is followed only while the embedded page reports more. + */ +export const REVIEW_DISMISSALS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { ... on ReviewDismissedEvent { dismissalMessage review { id } } } + } + } + } +}`; + +function gitHubReviewPosition(position: PullRequestReviewPosition): { + readonly line: number; + readonly side: "LEFT" | "RIGHT"; +} { + switch (position.kind) { + case "added": + return { line: position.newLine, side: "RIGHT" }; + case "deleted": + return { line: position.oldLine, side: "LEFT" }; + case "context": + return position.side === "left" + ? { line: position.oldLine, side: "LEFT" } + : { line: position.newLine, side: "RIGHT" }; + } +} + +/** The whole review as one request body, which is how GitHub keeps it invisible until sent. */ +export function buildReviewSubmission(input: { + readonly verdict: PullRequestReviewVerdict; + readonly body: string; + readonly comments: ReadonlyArray; +}): typeof ReviewSubmissionSchema.Type { + return { + event: REVIEW_EVENTS[input.verdict], + body: input.body, + comments: input.comments.map((comment) => ({ + path: comment.path, + ...gitHubReviewPosition(comment.position), + body: comment.body, + })), + }; +} + +export interface GitHubPullRequestListItem { + readonly stack?: PullRequestStackMembership; + /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */ + readonly authorId: string | null; + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly mergeability: PullRequestMergeability; + /** Null where GitHub has no verdict to summarise, which includes a draft nobody has reviewed. */ + readonly reviewDecision: PullRequestReviewDecision | null; + readonly additions: number; + readonly deletions: number; + readonly createdAt: string; + readonly updatedAt: string; + readonly reviewRequestLogins: ReadonlyArray; + /** At least one outstanding request targets a team rather than an individual login. */ + readonly hasTeamReviewRequest: boolean; + readonly labels: ReadonlyArray; + /** Null where the head commit reported no checks, which is not the same as passing none. */ + readonly checksState: PullRequestChecksState | null; +} + +export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { + /** True only when GitHub says the head belongs to another repository. */ + readonly isCrossRepository?: boolean; + /** The owner of the head branch's repository; null where `gh` did not say. */ + readonly headRepositoryOwner: string | null; + readonly headSha?: string | null; + readonly body: string; + readonly changedFiles: number; + readonly mergedAt: string | null; + readonly closedAt: string | null; + readonly checks: ReadonlyArray; + /** Absent where `gh` did not answer for auto-merge at all, which is not the same as off. */ + readonly autoMergeEnabled?: boolean; + /** Absent where auto-merge is off or GitHub did not report the stored strategy. */ + readonly autoMergeMethod?: PullRequestMergeMethod; +} + +export interface GitHubWorkflowRunApproval { + readonly id: number; + readonly name: string; + readonly url: string | null; +} + +export interface GitHubPullRequestHead { + readonly number: number; + readonly headSha: string; + readonly isCrossRepository?: boolean; + readonly headRepositoryOwner: string | null; +} + +export interface GitHubPullRequestActivity { + readonly author: PullRequestActor | null; + readonly comments: ReadonlyArray; + readonly commits: ReadonlyArray; +} + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +/** + * Null once a connection has nothing further, which is what ends every walk below. GitHub sends + * an `endCursor` on a page that is also the last one, so the flag is what decides, not the + * cursor's presence. + */ +function nextCursorOf( + pageInfo: Schema.Schema.Type | undefined, +): string | null { + return pageInfo?.hasNextPage === true ? trimmed(pageInfo.endCursor) : null; +} + +/** + * The viewer's standing on one pull request. The two halves take opposite defaults on purpose. + * + * Updating is a permission, so an install that does not report it grants it and lets the host's + * own refusal explain anything that fails. Authorship is not a permission but a fact about who + * wrote the thing, and it is read to decide what an author may do to their own change — so an + * unknown answer is "not the author", which grants nothing it should not. + */ +function toPullRequestViewerFields( + raw: Schema.Schema.Type | null | undefined, +): { readonly canUpdate: boolean; readonly didAuthor: boolean } { + return { canUpdate: raw?.viewerCanUpdate !== false, didAuthor: raw?.viewerDidAuthor === true }; +} + +function toActor(raw: Schema.Schema.Type | null | undefined) { + const login = trimmed(raw?.login); + return login === null + ? null + : { + login, + name: trimmed(raw?.name), + avatarUrl: trimmed(raw?.avatarUrl), + ...(raw?.__typename === "Bot" || raw?.is_bot === true ? { isBot: true } : {}), + }; +} + +function toCommitActor( + raw: NonNullable["authors"]>[number], +): PullRequestActor | null { + // An email-linked GitHub account has a login; an unlinked signature only has a name or email. + // Keep that signature visible instead of silently turning a co-authored commit into one author. + const login = trimmed(raw.login) ?? trimmed(raw.name) ?? trimmed(raw.email); + return login === null ? null : { login, name: trimmed(raw.name), avatarUrl: null }; +} + +/** An author off the GraphQL commits connection, which names an account by `user.login` where + * `gh pr view --json commits` names it by a flat `login` copied off the signature. */ +function toGraphqlCommitActor(raw: { + readonly name?: string | null | undefined; + readonly avatarUrl?: string | null | undefined; + readonly user?: { readonly login?: string | null | undefined } | null | undefined; +}): PullRequestActor | null { + const login = trimmed(raw.user?.login) ?? trimmed(raw.name); + return login === null + ? null + : { login, name: trimmed(raw.name), avatarUrl: trimmed(raw.avatarUrl) }; +} + +function toState(raw: { + readonly state?: string | null | undefined; + readonly mergedAt?: string | null | undefined; +}): PullRequestState { + if (trimmed(raw.mergedAt) !== null) return "merged"; + const state = raw.state?.trim().toUpperCase(); + if (state === "MERGED") return "merged"; + if (state === "CLOSED") return "closed"; + return "open"; +} + +function toMergeability(value: string | null | undefined): PullRequestMergeability { + switch (value?.trim().toUpperCase()) { + case "MERGEABLE": + return "mergeable"; + case "CONFLICTING": + return "conflicting"; + default: + return "unknown"; + } +} + +function toMergeMethod(value: string | null | undefined): PullRequestMergeMethod | undefined { + switch (value?.trim().toUpperCase()) { + case "MERGE": + return "merge"; + case "SQUASH": + return "squash"; + case "REBASE": + return "rebase"; + default: + return undefined; + } +} + +/** + * GitHub's own `reviewDecision` counts only reviews that satisfy the branch rules, so an + * approval from an app (a review bot) or from anyone without the required permission leaves it + * empty. The reviewers still said something, and a row should show it: when GitHub reports no + * verdict, the latest review per reviewer decides, changes requested outranking approval. + */ +function toReviewDecisionWithReviews( + value: string | null | undefined, + // `gh pr list` hands the reviews as an array; the GraphQL reads hand a connection. + latestReviews: + | ReadonlyArray> + | { readonly nodes: ReadonlyArray> } + | null + | undefined, +): PullRequestReviewDecision | null { + const summarized = toReviewDecision(value); + if (summarized === "approved" || summarized === "changes-requested") return summarized; + const reviews = + latestReviews === null || latestReviews === undefined + ? [] + : "nodes" in latestReviews + ? latestReviews.nodes + : latestReviews; + const states = new Set(reviews.map((review) => review.state?.trim().toUpperCase() ?? "")); + if (states.has("CHANGES_REQUESTED")) return "changes-requested"; + if (states.has("APPROVED")) return "approved"; + return summarized; +} + +function toReviewDecision(value: string | null | undefined): PullRequestReviewDecision | null { + switch (value?.trim().toUpperCase()) { + case "APPROVED": + return "approved"; + case "CHANGES_REQUESTED": + return "changes-requested"; + case "REVIEW_REQUIRED": + return "review-required"; + default: + return null; + } +} + +function toLabels( + raw: ReadonlyArray> | undefined, +): ReadonlyArray { + return (raw ?? []).flatMap((label) => { + const name = trimmed(label.name); + return name === null ? [] : [{ name, color: trimmed(label.color) }]; + }); +} + +/** + * User review requests only. Team requests are tracked separately because a slug cannot be + * compared with the viewer's login. + */ +function toReviewRequestLogins( + raw: ReadonlyArray> | undefined, +): ReadonlyArray { + return (raw ?? []).flatMap((request) => { + const login = trimmed(request.login); + return login === null ? [] : [login]; + }); +} + +function hasTeamReviewRequest( + raw: ReadonlyArray> | undefined, +): boolean { + return (raw ?? []).some( + (request) => + trimmed(request.login) === null && + (trimmed(request.slug) !== null || trimmed(request.name) !== null), + ); +} + +function toCheckStatus(raw: Schema.Schema.Type): PullRequestCheckStatus { + // Commit statuses report a single `state`; check runs report `status` plus a `conclusion` + // that only exists once the run has completed. + const status = raw.status?.trim().toUpperCase(); + if (status !== undefined && status !== "COMPLETED" && status !== "") { + return "pending"; + } + switch ((raw.conclusion ?? raw.state)?.trim().toUpperCase()) { + case "SUCCESS": + return "success"; + case "ACTION_REQUIRED": + return "action-required"; + case "FAILURE": + case "ERROR": + case "TIMED_OUT": + case "STARTUP_FAILURE": + return "failure"; + case "CANCELLED": + return "cancelled"; + case "SKIPPED": + return "skipped"; + case "PENDING": + case "EXPECTED": + return "pending"; + default: + return "neutral"; + } +} + +/** What GitHub writes where a run has not reached that moment yet, which is not a time. */ +const UNSET_TIMESTAMP = "0001-01-01T00:00:00Z"; + +function realTimestamp(value: string | null | undefined): string | null { + const at = trimmed(value); + return at === null || at === UNSET_TIMESTAMP ? null : at; +} + +/** Only a row the rollup gives no name of any kind, which is not a check anyone can show. */ +function isNamelessCheck(raw: Schema.Schema.Type): boolean { + return trimmed(raw.name) === null && trimmed(raw.context) === null; +} + +/** + * The rollup as the deduper reads it: a check, the workflow that owns it, and when the run last + * had something to say. A queued run reports a completion time it has not reached, so the start + * stands in for it rather than sorting the newest run to the bottom. + */ +function toCheckEntries( + raw: ReadonlyArray> | null | undefined, +): ReadonlyArray<{ + readonly check: PullRequestCheck; + readonly workflowName: string | null; + readonly at: string | null; +}> { + return (raw ?? []).flatMap((check) => { + const name = trimmed(check.name) ?? trimmed(check.context); + if (name === null) return []; + return [ + { + check: { + name, + status: toCheckStatus(check), + description: trimmed(check.description), + url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), + ...(typeof check.isRequired === "boolean" ? { required: check.isRequired } : {}), + }, + workflowName: trimmed(check.workflowName), + at: realTimestamp(check.completedAt) ?? realTimestamp(check.startedAt), + }, + ]; + }); +} + +/** + * The one word a listing row has space for. A failure outranks anything still running, the way + * GitHub's own indicator reads: a run that has already gone red will not go green by finishing. + * + * Null rather than "passing" for a head commit with no checks at all, so a repository that runs + * none shows nothing instead of a green tick it never earned. A cancelled run is a failure, as + * GitHub's own rollup and the client's detail rollup both read it; skipped and neutral count + * towards neither, so the row and the detail header never disagree about one head commit. + * + * Counted off the deduped checks rather than the raw rollup, so the word and the list under it + * cannot disagree: the run a re-run replaced is not a verdict twice. A row with no name at all is + * counted as it comes, since the cross-repository search dresses GitHub's own rollup enum as one + * nameless row, and nothing nameless can collide with anything. + */ +function rollupChecksState( + raw: ReadonlyArray> | null | undefined, +): PullRequestChecksState | null { + const statuses = [ + ...toChecks(raw).map((check) => check.status), + ...(raw ?? []).filter(isNamelessCheck).map((check) => toCheckStatus(check)), + ]; + if (statuses.length === 0) return null; + if (statuses.includes("failure") || statuses.includes("cancelled")) return "failing"; + if (statuses.includes("pending") || statuses.includes("action-required")) return "pending"; + return statuses.includes("success") ? "passing" : null; +} + +function toChecks( + raw: ReadonlyArray> | null | undefined, +): ReadonlyArray { + return dedupeChecks(toCheckEntries(raw)); +} + +/** The states that are a verdict in themselves, rather than a wrapper around line comments. */ +function isReviewVerdict(reviewState: string | null): boolean { + switch (reviewState?.toUpperCase()) { + case "APPROVED": + case "CHANGES_REQUESTED": + case "DISMISSED": + return true; + default: + return false; + } +} + +function toComments(raw: { + readonly comments?: ReadonlyArray> | undefined; + readonly reviews?: ReadonlyArray> | undefined; +}): ReadonlyArray { + const issueComments = (raw.comments ?? []).map((comment): PullRequestComment => ({ + id: comment.id, + kind: "issue-comment", + author: toActor(comment.author), + body: comment.body ?? "", + createdAt: comment.createdAt, + editedAt: comment.lastEditedAt ?? null, + url: trimmed(comment.url), + path: null, + reviewState: null, + })); + // A review with no body is kept only when its state is the event itself — an approval, a + // request for changes, a dismissal. GitHub also opens a bodiless `COMMENTED` review as the + // container for line comments, and those comments are read from the review threads, so + // keeping the container too would show a row with a name and nothing under it. + const reviews = (raw.reviews ?? []).flatMap((review): ReadonlyArray => { + const submittedAt = trimmed(review.submittedAt); + const reviewState = trimmed(review.state); + if ( + submittedAt === null || + ((review.body ?? "").trim().length === 0 && !isReviewVerdict(reviewState)) + ) { + return []; + } + return [ + { + id: review.id, + kind: "review", + author: toActor(review.author), + body: review.body ?? "", + createdAt: submittedAt, + editedAt: review.lastEditedAt ?? null, + url: trimmed(review.url), + path: null, + reviewState, + }, + ]; + }); + return [...issueComments, ...reviews].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ); +} + +function toCommits( + commits: ReadonlyArray> | undefined, +): ReadonlyArray { + return (commits ?? []).map((commit) => ({ + oid: commit.oid, + messageHeadline: commit.messageHeadline ?? "", + committedDate: commit.committedDate, + authors: (commit.authors ?? []).flatMap((author) => { + const actor = toCommitActor(author); + return actor === null ? [] : [actor]; + }), + })); +} + +function toListItem(raw: Schema.Schema.Type): GitHubPullRequestListItem { + return { + authorId: trimmed(raw.author?.id), + number: raw.number, + title: raw.title, + url: raw.url, + author: toActor(raw.author), + headBranch: raw.headRefName, + baseBranch: raw.baseRefName, + state: toState(raw), + isDraft: raw.isDraft ?? false, + mergeability: toMergeability(raw.mergeable), + reviewDecision: toReviewDecisionWithReviews(raw.reviewDecision, raw.latestReviews), + additions: raw.additions ?? 0, + deletions: raw.deletions ?? 0, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + reviewRequestLogins: toReviewRequestLogins(raw.reviewRequests), + hasTeamReviewRequest: hasTeamReviewRequest(raw.reviewRequests), + labels: toLabels(raw.labels), + checksState: rollupChecksState(raw.statusCheckRollup), + }; +} + +function toDetail(raw: Schema.Schema.Type): GitHubPullRequestDetail { + const autoMergeMethod = toMergeMethod(raw.autoMergeRequest?.mergeMethod); + return { + ...toListItem(raw), + ...(typeof raw.isCrossRepository === "boolean" + ? { isCrossRepository: raw.isCrossRepository } + : {}), + headRepositoryOwner: trimmed(raw.headRepositoryOwner?.login), + headSha: trimmed(raw.headRefOid), + body: raw.body ?? "", + changedFiles: raw.changedFiles ?? 0, + mergedAt: trimmed(raw.mergedAt), + closedAt: trimmed(raw.closedAt), + checks: toChecks(raw.statusCheckRollup), + // A JSON null is GitHub saying "nobody armed this"; a missing key is GitHub not saying, and + // the difference survives here rather than being flattened into false. + ...(raw.autoMergeRequest === undefined + ? {} + : { autoMergeEnabled: raw.autoMergeRequest !== null }), + ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }), + }; +} + +const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeSearch = decodeJsonResult(RawSearchSchema); +const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema); +const decodeStats = decodeJsonResult(RawStatsSchema); +const decodeDetail = decodeJsonResult(RawDetailSchema); +const decodeWorkflowRuns = decodeJsonResult(RawWorkflowRunsSchema); +const decodePullRequestHeads = decodeJsonResult(RawPullRequestHeadsSchema); +const decodeActivity = decodeJsonResult(RawActivitySchema); +const decodeRepositoryPullRequests = decodeJsonResult(RawRepositoryPullRequestsSchema); +const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema); +const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema); +const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema); + +type DecodeFailure = Cause.Cause; + +export interface GitHubPullRequestListBatch { + readonly items: ReadonlyArray; + /** Rows GitHub returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; + /** Where the next page starts, or null once GitHub has handed over every row. */ + readonly endCursor: string | null; +} + +/** + * One page of a repository's own pull request list. Malformed rows are skipped rather than + * failing the page: one unexpected pull request must not blank the whole list. A repository the + * viewer cannot see answers as an empty page, which is what `gh pr list` printed for it too. + */ +export function decodePullRequestListJson( + raw: string, +): Result.Result { + const decoded = decodeRepositoryPullRequests(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const connection = decoded.success.data.repository?.pullRequests; + const nodes = connection?.nodes ?? []; + const items: GitHubPullRequestListItem[] = []; + for (const entry of nodes) { + const row = toRow(entry); + if (row !== null) items.push(row.item); + } + return Result.succeed({ + items, + rawCount: nodes.length, + endCursor: + connection?.pageInfo?.hasNextPage === true ? trimmed(connection.pageInfo.endCursor) : null, + }); +} + +/** + * A row as GraphQL answers it: reviewers and labels arrive as connections, and the checks as + * GitHub's one-word rollup rather than the whole check list. Null for a node that is not a pull + * request, or one whose fields no longer decode. + */ +function toRow( + entry: unknown, +): { readonly item: GitHubPullRequestListItem; readonly repository: string | null } | null { + const decodedNode = decodeSearchItem(entry); + if (!Exit.isSuccess(decodedNode)) return null; + const node = decodedNode.value; + const stack = toStackMembership(node); + const reviewRequests = (node.reviewRequests?.nodes ?? []).flatMap( + (request): ReadonlyArray<{ readonly login?: string; readonly slug?: string }> => { + const reviewer = request?.requestedReviewer; + const login = trimmed(reviewer?.login); + if (login !== null) return [{ login }]; + const slug = trimmed(reviewer?.slug); + return slug === null ? [] : [{ slug }]; + }, + ); + return { + item: { + ...toListItem({ + ...node, + latestReviews: (node.latestReviews?.nodes ?? []).flatMap((review) => + review === null ? [] : [review], + ), + reviewRequests, + labels: (node.labels?.nodes ?? []).flatMap((label) => (label === null ? [] : [label])), + // The rollup arrives as one enum. Dressed as a single check here so it is read the same + // way the detail's checks are. + statusCheckRollup: (node.commits?.nodes ?? []).flatMap((commitNode) => { + const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); + return state === null ? [] : [{ state }]; + }), + }), + ...(stack === undefined ? {} : { stack }), + }, + repository: trimmed(node.repository?.nameWithOwner), + }; +} + +export interface GitHubPullRequestSearchItem extends GitHubPullRequestListItem { + /** `owner/name` as GitHub spells it, which is how a row from a search finds its repository. */ + readonly repository: string; +} + +export interface GitHubPullRequestSearchBatch { + readonly items: ReadonlyArray; + /** Rows the search returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; + /** More rows than this slice asked for, which is truncation for every repository in it. */ + readonly hasNextPage: boolean; + /** Where the next page of the same search starts. */ + readonly endCursor: string | null; +} + +/** + * A search answers with the same row a repository's list does, each naming its repository. + * + * Rows that are not pull requests decode as empty and are skipped, the way a malformed listing + * row is — `is:pr` already excludes them, and one surprise must not blank a whole host. + */ +export function decodePullRequestSearchJson( + raw: string, +): Result.Result { + const decoded = decodeSearch(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const search = decoded.success.data.search; + const nodes = search.nodes ?? []; + const items: GitHubPullRequestSearchItem[] = []; + for (const entry of nodes) { + const row = toRow(entry); + if (row === null || row.repository === null) continue; + items.push({ ...row.item, repository: row.repository }); + } + return Result.succeed({ + items, + rawCount: nodes.length, + hasNextPage: search.pageInfo?.hasNextPage ?? false, + endCursor: search.pageInfo?.hasNextPage === true ? trimmed(search.pageInfo.endCursor) : null, + }); +} + +function toStackMembership( + raw: Schema.Schema.Type, +): PullRequestStackMembership | undefined { + return raw.stack && raw.stackEntry + ? { + number: raw.stack.number, + size: raw.stack.size, + base: raw.stack.baseRefName, + position: raw.stackEntry.position, + } + : undefined; +} + +/** What GitHub allows in an owner or repository name. */ +const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/; + +/** + * The owner and name of an `owner/name` selector, or null for one GitHub cannot name. A batch is + * one document, and a single unanswerable alias fails all of it, so these are refused up front. + */ +function repositoryParts( + repository: string, +): { readonly owner: string; readonly name: string } | null { + const [owner, name, ...rest] = repository.trim().split("/"); + if (rest.length > 0 || owner === undefined || name === undefined) return null; + return REPOSITORY_PART.test(owner) && REPOSITORY_PART.test(name) ? { owner, name } : null; +} + +const isPullRequestNumber = (number: number) => Number.isSafeInteger(number) && number > 0; + +/** One aliased `repository { pullRequest { selection } }` per change request, on one host. */ +function aliasedPullRequestsDocument( + name: string, + alias: string, + changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, + selection: string, +): GraphQlDocument | null { + const addressed = changeRequests.map((changeRequest) => ({ + parts: repositoryParts(changeRequest.repository), + number: changeRequest.number, + })); + if (addressed.some(({ parts, number }) => parts === null || !isPullRequestNumber(number))) { + return null; + } + return aliasedGraphQlDocument({ + operation: "query", + name, + alias, + items: addressed, + variables: ({ parts, number }) => ({ + owner: ["String!", parts!.owner], + name: ["String!", parts!.name], + number: ["Int!", number], + }), + field: ({ owner, name, number }) => + `repository(owner: ${owner}, name: ${name}) { pullRequest(number: ${number}) { ${selection} } }`, + }); +} + +/** + * The line counts for rows a listing already handed over, as one aliased lookup each. + * + * Aliases rather than `nodes(ids:)` because the caller asks in the terms the page holds — a + * repository and a number — and never sees a node id. Null for a selector GitHub cannot name, + * which the caller reports rather than sends, and for an empty request. + */ +export function buildPullRequestStatsGraphQlQuery( + changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, +): GraphQlDocument | null { + return aliasedPullRequestsDocument( + "PullRequestStats", + "s", + changeRequests, + "additions deletions", + ); +} + +/** Stack membership for the visible rows of a per-repository listing. */ +export function buildPullRequestStackMembershipsGraphQlQuery( + repository: string, + numbers: ReadonlyArray, +): GraphQlDocument | null { + return aliasedPullRequestsDocument( + "PullRequestStackMemberships", + "s", + numbers.map((number) => ({ repository, number })), + STACK_MEMBERSHIP_SELECTION, + ); +} + +const decodeStackMemberships = decodeJsonResult(RawStackMembershipsSchema); + +export function decodePullRequestStackMembershipsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeStackMemberships(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const memberships = new Map(); + for (const [alias, value] of Object.entries(decoded.success.data)) { + const index = /^s(\d+)$/.exec(alias)?.[1]; + if (index === undefined || value?.pullRequest == null) continue; + const stack = toStackMembership(value.pullRequest); + if (stack !== undefined) memberships.set(Number(index), stack); + } + return Result.succeed(memberships); +} + +/** + * The counts by the position they were asked in. A repository or a pull request GitHub answered + * nothing for is simply absent, which leaves the row with whatever it already had. + */ +export function decodePullRequestStatsJson( + raw: string, +): Result.Result< + ReadonlyMap, + DecodeFailure +> { + const decoded = decodeStats(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const stats = new Map(); + for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { + const index = /^s(\d+)$/.exec(alias)?.[1]; + const pullRequest = value?.pullRequest; + if (index === undefined || pullRequest == null) continue; + stats.set(Number(index), { + additions: pullRequest.additions ?? 0, + deletions: pullRequest.deletions ?? 0, + }); + } + return Result.succeed(stats); +} + +/** + * The fields a linked thread keeps current, for many pull requests in one aliased read. Same + * shape as the search row where the two overlap: the checks arrive as GitHub's one-word rollup + * rather than the whole check list `gh pr view` hands back, which is what keeps a batch cheap. + */ +const STACK_MEMBERSHIP_SELECTION = "stack { number size baseRefName } stackEntry { position }"; + +const PULL_REQUEST_SUMMARY_SELECTION = + "number title url state isDraft mergeable reviewDecision additions deletions changedFiles " + + "updatedAt mergedAt closedAt headRefName baseRefName " + + "author { __typename login avatarUrl ... on User { name } } " + + "latestReviews(first: 20) { nodes { state author { login } } } " + + "commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }"; + +/** Summaries for pull requests anywhere on one host, one aliased lookup each; null when unsafe. */ +export function buildPullRequestSummariesGraphQlQuery( + changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, + includeStacks = false, +): GraphQlDocument | null { + return aliasedPullRequestsDocument( + "PullRequestSummaries", + "s", + changeRequests, + `${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""}`, + ); +} + +/** One pull request's summary, aliased the way the batch is so the same decoder reads it. */ +export function pullRequestSummaryGraphQlQuery(includeStacks = false): string { + return `query($owner: String!, $name: String!, $number: Int!) { + s0: repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""} } } +}`; +} + +const RawSummarySchema = Schema.Struct({ + ...RawSearchItemSchema.fields, + changedFiles: Schema.optional(Schema.NullOr(Schema.Int)), + additions: Schema.optional(Schema.NullOr(Schema.Int)), + deletions: Schema.optional(Schema.NullOr(Schema.Int)), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.optional(Schema.String), +}); +const decodeSummaries = decodeJsonResult( + Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr(Schema.Struct({ pullRequest: Schema.optional(Schema.Unknown) })), + ), + ), + ), + }), +); +const decodeSummaryEntry = Schema.decodeUnknownExit(RawSummarySchema); + +export interface GitHubPullRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly closedAt: string | null; + readonly mergedAt: string | null; + readonly updatedAt: string; + readonly author: PullRequestActor | null; + readonly additions: number; + readonly deletions: number; + readonly changedFiles: number; + readonly reviewDecision: PullRequestReviewDecision | null; + readonly checksState: PullRequestChecksState | null; + readonly mergeability: PullRequestMergeability; + /** Null when GitHub says the pull request is in no stack; absent when the read did not ask. */ + readonly stack?: PullRequestStackMembership | null; +} + +/** + * Summaries by the position they were asked in. A pull request GitHub answered nothing for, or + * one whose fields no longer decode, is absent rather than failing the rest of the batch. + */ +export function decodePullRequestSummariesJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeSummaries(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const summaries = new Map(); + for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { + const index = /^s(\d+)$/.exec(alias)?.[1]; + if (index === undefined || value?.pullRequest == null) continue; + const entry = decodeSummaryEntry(value.pullRequest); + if (!Exit.isSuccess(entry)) continue; + const pr = entry.value; + summaries.set(Number(index), { + number: pr.number, + title: pr.title, + url: pr.url, + headBranch: pr.headRefName, + baseBranch: pr.baseRefName, + state: toState(pr), + isDraft: pr.isDraft ?? false, + closedAt: trimmed(pr.closedAt), + mergedAt: trimmed(pr.mergedAt), + updatedAt: pr.updatedAt, + author: toActor(pr.author), + additions: pr.additions ?? 0, + deletions: pr.deletions ?? 0, + changedFiles: pr.changedFiles ?? 0, + reviewDecision: toReviewDecisionWithReviews( + pr.reviewDecision, + (pr.latestReviews?.nodes ?? []).flatMap((review) => (review === null ? [] : [review])), + ), + // One enum for the head commit, dressed as a single check like the search row's. + checksState: rollupChecksState( + (pr.commits?.nodes ?? []).flatMap((commitNode) => { + const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); + return state === null ? [] : [{ state }]; + }), + ), + mergeability: toMergeability(pr.mergeable), + ...(pr.stack === undefined ? {} : { stack: toStackMembership(pr) ?? null }), + }); + } + return Result.succeed(summaries); +} + +const WATCH_FINGERPRINT_EDITS = "totalCount nodes { lastEditedAt }"; +const WATCH_FINGERPRINT_CHECK_COUNTS = + "checkRunCountsByState { state count } statusContextCountsByState { state count }"; + +/** + * What a pull request watch needs to notice, priced by GitHub at one point for twenty-five pull + * requests, where the detail and activity reads it gates cost sixteen for one. Counts and the + * newest edit cover new comments, reviews (a reply in a review thread is a review), threads, and + * a bot rewriting its summary; check counts by state move whenever a check starts or finishes. + * Comments come most recently updated first, since an edit moves `updatedAt`, so an edit to an + * old comment on a long pull request is still in the page. Reviews have no such order: an edit + * to a review older than the last hundred waits for the watch's periodic full read, as do edits + * to comments inside review threads, which cost a point per pull request to ask for. + */ +const PULL_REQUEST_WATCH_FINGERPRINT_SELECTION = + "state mergeable headRefOid " + + `comments(first: 100, orderBy: { field: UPDATED_AT, direction: DESC }) { ${WATCH_FINGERPRINT_EDITS} } ` + + `reviews(last: 100) { ${WATCH_FINGERPRINT_EDITS} } ` + + "reviewThreads { totalCount } " + + `commits(last: 1) { nodes { commit { statusCheckRollup { contexts { ${WATCH_FINGERPRINT_CHECK_COUNTS} } } } } }`; + +/** Watch fingerprints for pull requests on one host, one aliased lookup each; null when unsafe. */ +export function buildPullRequestWatchFingerprintsGraphQlQuery( + changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, +): GraphQlDocument | null { + return aliasedPullRequestsDocument( + "PullRequestWatchFingerprints", + "w", + changeRequests, + PULL_REQUEST_WATCH_FINGERPRINT_SELECTION, + ); +} + +/** + * Two parts, so a watch reads only what moved: `status` (state, mergeability, head, and checks) + * needs the detail read, and `remarks` also needs the far costlier activity read. + */ +export interface GitHubPullRequestWatchFingerprint { + readonly status: string; + readonly remarks: string; +} + +const RawEditedSchema = Schema.Struct({ + totalCount: Schema.Int, + nodes: Schema.Array(Schema.NullOr(Schema.Struct({ lastEditedAt: Schema.NullOr(Schema.String) }))), +}); +const RawStateCountsSchema = Schema.NullOr( + Schema.Array(Schema.Struct({ state: Schema.String, count: Schema.Int })), +); +const decodeWatchFingerprintEntry = Schema.decodeUnknownExit( + Schema.Struct({ + state: Schema.String, + mergeable: Schema.NullOr(Schema.String), + headRefOid: Schema.String, + comments: RawEditedSchema, + reviews: RawEditedSchema, + reviewThreads: Schema.Struct({ totalCount: Schema.Int }), + commits: Schema.Struct({ + nodes: Schema.Array( + Schema.NullOr( + Schema.Struct({ + commit: Schema.Struct({ + statusCheckRollup: Schema.NullOr( + Schema.Struct({ + contexts: Schema.Struct({ + checkRunCountsByState: RawStateCountsSchema, + statusContextCountsByState: RawStateCountsSchema, + }), + }), + ), + }), + }), + ), + ), + }), + }), +); + +// An edit only ever moves `lastEditedAt` forward, so the newest one stands for them all. +const newestEdit = (connection: typeof RawEditedSchema.Type) => + connection.nodes.reduce( + (newest, node) => + node?.lastEditedAt != null && node.lastEditedAt > newest ? node.lastEditedAt : newest, + "", + ); + +const stateCounts = (counts: typeof RawStateCountsSchema.Type) => + (counts ?? []) + .filter(({ count }) => count > 0) + .map(({ state, count }) => `${state}:${count}`) + .toSorted() + .join(","); + +/** Fingerprints by alias index; a pull request GitHub returned nothing for is left out. */ +export function decodePullRequestWatchFingerprintsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeSummaries(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const fingerprints = new Map(); + for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { + const index = /^w(\d+)$/.exec(alias)?.[1]; + if (index === undefined || value?.pullRequest == null) continue; + const entry = decodeWatchFingerprintEntry(value.pullRequest); + if (!Exit.isSuccess(entry)) continue; + const pr = entry.value; + const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts; + fingerprints.set(Number(index), { + status: [ + pr.state, + pr.mergeable ?? "", + pr.headRefOid, + stateCounts(contexts?.checkRunCountsByState ?? null), + stateCounts(contexts?.statusContextCountsByState ?? null), + ].join(" "), + remarks: [ + pr.comments.totalCount, + newestEdit(pr.comments), + pr.reviews.totalCount, + newestEdit(pr.reviews), + pr.reviewThreads.totalCount, + ].join(" "), + }); + } + return Result.succeed(fingerprints); +} + +export interface GitHubPullRequestCore extends GitHubPullRequestDetail { + readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess; + readonly comparison: GitHubBaseComparison | null; + readonly checksTruncated: boolean; +} + +export function decodePullRequestCoreJson( + raw: string, +): Result.Result { + const decoded = decodeCore(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const repository = decoded.success.data.repository; + const pr = repository.pullRequest; + const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts; + return Result.succeed({ + ...toDetail({ + ...pr, + reviewRequests: pr.reviewRequests.nodes.flatMap(({ requestedReviewer }) => + requestedReviewer === null ? [] : [requestedReviewer], + ), + labels: pr.labels.nodes, + statusCheckRollup: toCheckContexts(contexts?.nodes ?? []), + }), + viewerAccess: { + canWrite: toCanWrite(repository.viewerPermission), + canTriage: toCanTriage(repository.viewerPermission), + ...toPullRequestViewerFields(pr), + mergeCapabilities: { + merge: repository.mergeCommitAllowed, + squash: repository.squashMergeAllowed, + rebase: repository.rebaseMergeAllowed, + }, + }, + comparison: + pr.state !== "OPEN" || pr.baseRef?.compare == null + ? null + : { + behindBy: pr.baseRef.compare.behindBy, + viewerCanUpdate: pr.viewerCanUpdateBranch, + }, + checksTruncated: contexts?.pageInfo.hasNextPage === true, + }); +} + +export function decodePullRequestDetailJson( + raw: string, +): Result.Result { + const decoded = decodeDetail(raw); + return Result.isSuccess(decoded) + ? Result.succeed(toDetail(decoded.success)) + : Result.fail(decoded.failure); +} + +export interface GitHubWorkflowRunPage { + readonly runs: ReadonlyArray; + /** Runs on the page, counted before decoding, which is what decides whether to page on. */ + readonly rawCount: number; +} + +/** One page of `actions/runs`. */ +export function decodeWorkflowRunsJson( + raw: string, +): Result.Result { + const decoded = decodeWorkflowRuns(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + return Result.succeed({ + runs: decoded.success.workflow_runs.map((run) => ({ + id: run.id, + name: trimmed(run.name) ?? `Workflow run ${run.id}`, + url: trimmed(run.html_url), + })), + rawCount: decoded.success.workflow_runs.length, + }); +} + +/** Open pull requests whose head branch has one name, from whichever repository it lives in. */ +export const PULL_REQUEST_HEADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $head: String!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequests(first: 100, after: $after, states: [OPEN], headRefName: $head) { + pageInfo { hasNextPage endCursor } + nodes { number headRefOid isCrossRepository headRepositoryOwner { login } } + } + } +}`; + +export function decodePullRequestHeadsJson(raw: string): Result.Result< + { + readonly heads: ReadonlyArray; + readonly nextCursor: string | null; + }, + DecodeFailure +> { + const decoded = decodePullRequestHeads(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const connection = decoded.success.data.repository?.pullRequests; + return Result.succeed({ + heads: (connection?.nodes ?? []).map((pullRequest) => ({ + number: pullRequest.number, + headSha: pullRequest.headRefOid, + ...(typeof pullRequest.isCrossRepository === "boolean" + ? { isCrossRepository: pullRequest.isCrossRepository } + : {}), + headRepositoryOwner: trimmed(pullRequest.headRepositoryOwner?.login), + })), + nextCursor: nextCursorOf(connection?.pageInfo), + }); +} + +export interface GitHubPullRequestActivityPage { + /** Only on the first page, which is the one that asks for them. */ + readonly author?: PullRequestActor | null; + readonly commits?: ReadonlyArray; + /** Issue comments and reviews, each list unsorted. */ + readonly remarks: ReadonlyArray; + readonly nextCommentsCursor: string | null; + readonly nextReviewsCursor: string | null; +} + +/** One page of `PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY`. */ +export function decodePullRequestActivityJson( + raw: string, +): Result.Result { + const decoded = decodeActivity(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const pr = decoded.success.data.repository.pullRequest; + return Result.succeed({ + ...(pr.author === undefined ? {} : { author: toActor(pr.author) }), + ...(pr.commits === undefined + ? {} + : { + commits: toCommits( + (pr.commits?.nodes ?? []).map(({ commit }) => ({ + ...commit, + authors: (commit.authors?.nodes ?? []).map((author) => ({ + ...author, + login: author.user?.login ?? null, + })), + })), + ), + }), + remarks: toComments({ + comments: pr.comments?.nodes ?? [], + reviews: pr.reviews?.nodes ?? [], + }), + nextCommentsCursor: nextCursorOf(pr.comments?.pageInfo), + nextReviewsCursor: nextCursorOf(pr.reviews?.pageInfo), + }); +} + +/** Every check context of the head commit, a page at a time, for a rollup past one page. */ +export const pullRequestCheckContextsGraphQlQuery = (host: string) => + `query($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + headRefOid + commits(last: 1) { + nodes { commit { statusCheckRollup { contexts(first: 100, after: $after) { + ${checkContextNodesSelection(host)} + pageInfo { hasNextPage endCursor } + } } } } + } + } + } +}`; + +const RawCheckContextNodeSchema = Schema.Struct({ + ...RawCheckSchema.fields, + checkSuite: Schema.optional( + Schema.NullOr( + Schema.Struct({ + workflowRun: Schema.NullOr( + Schema.Struct({ workflow: Schema.NullOr(Schema.Struct({ name: Schema.String })) }), + ), + }), + ), + ), +}); + +const decodeCheckContexts = decodeJsonResult( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + headRefOid: Schema.String, + commits: Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + commit: Schema.Struct({ + statusCheckRollup: Schema.NullOr( + Schema.Struct({ + contexts: Schema.Struct({ + nodes: Schema.Array(RawCheckContextNodeSchema), + pageInfo: RawPageInfoSchema, + }), + }), + ), + }), + }), + ), + }), + }), + }), + }), + }), +); + +/** A check context as the rollup reports it; opaque outside this module. */ +export type GitHubCheckContext = Schema.Schema.Type; + +function toCheckContexts( + nodes: ReadonlyArray>, +): ReadonlyArray { + return nodes.map((check) => ({ + ...check, + workflowName: check.checkSuite?.workflowRun?.workflow?.name ?? null, + })); +} + +export function decodePullRequestCheckContextsJson(raw: string): Result.Result< + { + readonly headSha: string; + readonly contexts: ReadonlyArray; + readonly nextCursor: string | null; + }, + DecodeFailure +> { + const decoded = decodeCheckContexts(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const pr = decoded.success.data.repository.pullRequest; + const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts; + return Result.succeed({ + headSha: pr.headRefOid, + contexts: toCheckContexts(contexts?.nodes ?? []), + nextCursor: nextCursorOf(contexts?.pageInfo), + }); +} + +/** The checks and their one-word verdict, from every context of the head commit. */ +export function pullRequestChecksFromContexts(contexts: ReadonlyArray): { + readonly checks: ReadonlyArray; + readonly checksState: PullRequestChecksState | null; +} { + return { checks: toChecks(contexts), checksState: rollupChecksState(contexts) }; +} + +export interface GitHubReviewThreadComments { + readonly comments: ReadonlyArray; + /** Dismissal reasons by the dismissed review's node id, read off the timeline. */ + readonly dismissalsByReviewId: ReadonlyMap; + /** Whole conversations, kept anchored so the diff can pin them to their line. */ + readonly reviewThreads: ReadonlyArray; + /** The host's own count of the conversation, which a bounded read can fall short of. */ + readonly commentCount: number; + readonly truncated: boolean; + readonly reviewThreadsTruncated: boolean; + /** The pull request's own reactions, which sit on its description. */ + readonly reactions: ReadonlyArray; + /** Reactions by node id, for the comments and reviews the `gh` JSON read carries no reaction on. */ + readonly reactionsById: ReadonlyMap>; + readonly editedAtById: ReadonlyMap; + /** + * Everyone on the review: those still asked and those who have already answered. Whoever has + * reviewed is no longer an outstanding request, so asking only for requests reports nobody on + * a pull request that has in fact been reviewed. + */ + readonly reviewers: ReadonlyArray; + /** + * Avatars by login, for the actors `gh pr view --json` reports without one — which is all of + * them, since no `gh` JSON field carries an avatar. Collected from everyone this query names, + * so an app's avatar arrives the same way a person's does. + */ + readonly avatarsByLogin: ReadonlyMap; + readonly botLogins: ReadonlySet; + /** Per-commit line counts carried by the same bounded pull-request query. */ + readonly commitStats: ReadonlyMap< + string, + { readonly additions: number; readonly deletions: number } + >; + /** + * The newest hundred commits, oldest to newest, off the same query's `commits(last: ...)`. + * Empty wherever the read never happened (an install too old for the field, a degraded page), + * which the caller reads as "keep the `gh pr view` list" rather than as "this pull request has + * no commits". + */ + readonly commits: ReadonlyArray; + /** What GitHub says the reader may do with this pull request, read off the same response. */ + readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean }; +} + +/** One thread as this page found it, with what it takes to finish reading it. */ +export interface GitHubReviewThreadEntry { + readonly thread: PullRequestReviewThread; + /** How many comments GitHub says the thread holds, read or not. */ + readonly commentCount: number; + /** Where the rest of this thread's comments carry on from, or null once it is whole. */ + readonly nextCommentCursor: string | null; +} + +export interface GitHubReviewThreadPage { + readonly threads: ReadonlyArray; + /** Where the next page of threads starts, or null once the host has handed them all over. */ + readonly nextCursor: string | null; + /** The pull request's own reactions, which sit on its description. */ + readonly reactions: ReadonlyArray; + /** + * Reactions by node id, for the conversation comments and reviews `gh pr view --json` answers + * for without any. Only ids with a reaction are here; the rest carry none. + */ + readonly reactionsById: ReadonlyMap>; + readonly editedAtById: ReadonlyMap; + readonly reviewers: ReadonlyArray; + readonly avatarsByLogin: ReadonlyMap; + readonly botLogins: ReadonlySet; + readonly commitStats: ReadonlyMap< + string, + { readonly additions: number; readonly deletions: number } + >; + readonly commits: ReadonlyArray; + readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean }; + /** Dismissal reasons by the dismissed review's node id, which the review itself never carries. */ + readonly dismissalsByReviewId: ReadonlyMap; + /** Where the rest of the dismissal events start, or null once this page carried them all. */ + readonly nextDismissalCursor: string | null; +} + +/** + * The threads as one flat conversation, which is what the timeline reads. Every comment of + * every thread, resolved or not: a resolved conversation is still what was said, and a reply is + * as much of it as the remark it answers. + */ +export function reviewThreadConversation( + threads: ReadonlyArray, +): ReadonlyArray { + return threads.flatMap((thread) => + thread.comments.map((comment): PullRequestComment => ({ + id: comment.id, + kind: "review-comment", + author: comment.author, + body: comment.body, + createdAt: comment.createdAt, + editedAt: comment.editedAt ?? null, + url: comment.url, + path: thread.path, + reviewState: null, + reactions: comment.reactions ?? [], + })), + ); +} + +/** One page of review threads. Following the cursors it hands back is the caller's job. */ +function toDismissalEntries( + nodes: + | ReadonlyArray<{ + readonly dismissalMessage?: string | null | undefined; + readonly review?: { readonly id?: string | null | undefined } | null | undefined; + }> + | undefined, +): Map { + const entries = new Map(); + for (const node of nodes ?? []) { + const reviewId = trimmed(node.review?.id); + const message = trimmed(node.dismissalMessage); + if (reviewId !== null && message !== null) entries.set(reviewId, message); + } + return entries; +} + +const RawReviewDismissalsSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + timelineItems: Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array( + Schema.Struct({ + dismissalMessage: Schema.optional(Schema.NullOr(Schema.String)), + review: Schema.optional( + Schema.NullOr(Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + }), + ), + }), + }), + }), + }), +}); + +const decodeReviewDismissals = decodeJsonResult(RawReviewDismissalsSchema); + +/** One further page of dismissal events, in the shape the thread read's own page carries. */ +export function decodeReviewDismissalsJson(raw: string): Result.Result< + { + readonly dismissalsByReviewId: ReadonlyMap; + readonly nextCursor: string | null; + }, + DecodeFailure +> { + const decoded = decodeReviewDismissals(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items = decoded.success.data.repository.pullRequest.timelineItems; + return Result.succeed({ + dismissalsByReviewId: toDismissalEntries(items.nodes), + nextCursor: nextCursorOf(items.pageInfo), + }); +} + +export function decodeReviewThreadsJson( + raw: string, +): Result.Result { + const decoded = decodeReviewThreads(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const viewer = trimmed(decoded.success.data.viewer?.login); + const threads = decoded.success.data.repository.pullRequest.reviewThreads; + const entries = threads.nodes.flatMap((thread): ReadonlyArray => { + const path = trimmed(thread.path); + const id = trimmed(thread.id); + if (path === null || id === null || thread.comments.nodes.length === 0) return []; + return [ + { + thread: { + id, + path, + // Null once the thread's line has left the diff, which is exactly when GitHub reports + // it outdated. Such a thread is listed rather than pinned to a line it no longer has. + line: + thread.line !== null && thread.line !== undefined && thread.line > 0 + ? thread.line + : null, + side: thread.diffSide?.toUpperCase() === "LEFT" ? "left" : "right", + isResolved: thread.isResolved === true, + isOutdated: thread.isOutdated === true, + comments: thread.comments.nodes.map((comment) => ({ + id: comment.id, + author: toActor(comment.author), + body: comment.body ?? "", + createdAt: comment.createdAt, + editedAt: comment.lastEditedAt ?? null, + url: trimmed(comment.url), + reactions: toReactions(comment.reactionGroups, viewer), + })), + }, + commentCount: thread.comments.totalCount ?? thread.comments.nodes.length, + nextCommentCursor: nextCursorOf(thread.comments.pageInfo), + }, + ]; + }); + const pullRequest = decoded.success.data.repository.pullRequest; + const avatarsByLogin = new Map(); + const botLogins = new Set(); + for (const raw of [ + pullRequest.author, + ...(pullRequest.comments?.nodes ?? []).map((node) => node.author), + ...(pullRequest.reviews?.nodes ?? []).map((node) => node.author), + ...(pullRequest.reviewRequests?.nodes ?? []).map((node) => node.requestedReviewer), + ...(pullRequest.latestReviews?.nodes ?? []).map((node) => node.author), + ...threads.nodes.flatMap((thread) => thread.comments.nodes.map((comment) => comment.author)), + ]) { + const login = trimmed(raw?.login); + const avatarUrl = trimmed(raw?.avatarUrl); + if (login !== null && avatarUrl !== null) avatarsByLogin.set(login, avatarUrl); + if (login !== null && toActor(raw)?.isBot) botLogins.add(login); + } + const reviewers = new Map(); + for (const raw of [ + ...(pullRequest.reviewRequests?.nodes ?? []).map((node) => node.requestedReviewer), + ...(pullRequest.latestReviews?.nodes ?? []).map((node) => node.author), + ]) { + const actor = toActor(raw); + // Keyed by login, so someone who was asked and then answered appears once. + if (actor !== null && !reviewers.has(actor.login)) reviewers.set(actor.login, actor); + } + const commitStats = new Map(); + const commits: PullRequestCommit[] = []; + for (const node of pullRequest.commits?.nodes ?? []) { + const commit = node.commit; + const oid = trimmed(commit.oid); + if (oid === null) continue; + // GitHub measures a merge commit against its first parent, so merging the base into the head + // reports every upstream change as if it belonged to the pull request. There is no useful + // per-commit stat to show for that integration commit without another comparison request. + if ( + (commit.parents?.totalCount ?? 1) <= 1 && + commit.additions !== undefined && + commit.deletions !== undefined + ) { + commitStats.set(oid, { + additions: Math.max(0, commit.additions), + deletions: Math.max(0, commit.deletions), + }); + } + const committedDate = trimmed(commit.committedDate); + if (committedDate === null) continue; + commits.push({ + oid, + messageHeadline: commit.messageHeadline ?? "", + committedDate, + authors: (commit.authors?.nodes ?? []).flatMap((author) => { + const actor = toGraphqlCommitActor(author); + return actor === null ? [] : [actor]; + }), + }); + } + const reactionsById = new Map>(); + const editedAtById = new Map(); + for (const node of [ + ...(pullRequest.comments?.nodes ?? []), + ...(pullRequest.reviews?.nodes ?? []), + ]) { + const id = trimmed(node.id); + if (id === null) continue; + const editedAt = trimmed(node.lastEditedAt); + if (editedAt !== null) editedAtById.set(id, editedAt); + const reactions = toReactions(node.reactionGroups, viewer); + if (reactions.length > 0) reactionsById.set(id, reactions); + } + return Result.succeed({ + threads: entries, + nextCursor: nextCursorOf(threads.pageInfo), + reactions: toReactions(pullRequest.reactionGroups, viewer), + reactionsById, + editedAtById, + reviewers: [...reviewers.values()], + avatarsByLogin, + botLogins, + commitStats, + commits, + viewer: toPullRequestViewerFields(pullRequest), + dismissalsByReviewId: toDismissalEntries(pullRequest.reviewDismissals?.nodes), + nextDismissalCursor: nextCursorOf(pullRequest.reviewDismissals?.pageInfo), + }); +} + +/** The rest of one thread's comments, in the shape the first page already delivered them. */ +export function decodeReviewThreadCommentsJson(raw: string): Result.Result< + { + readonly belongsToPullRequest: boolean; + readonly comments: ReadonlyArray; + readonly nextCursor: string | null; + }, + DecodeFailure +> { + const decoded = decodeReviewThreadComments(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const viewer = trimmed(decoded.success.data.viewer?.login); + const comments = decoded.success.data.node?.comments; + return Result.succeed({ + belongsToPullRequest: + decoded.success.data.repository?.pullRequest?.id !== undefined && + decoded.success.data.repository?.pullRequest?.id === + decoded.success.data.node?.pullRequest?.id, + comments: (comments?.nodes ?? []).map((comment) => ({ + id: comment.id, + author: toActor(comment.author), + body: comment.body ?? "", + createdAt: comment.createdAt, + editedAt: comment.lastEditedAt ?? null, + url: trimmed(comment.url), + reactions: toReactions(comment.reactionGroups, viewer), + })), + nextCursor: nextCursorOf(comments?.pageInfo), + }); +} + +/** Repository settings returned alongside the pull request viewer permissions. */ +export interface GitHubRepositoryAccess { + readonly mergeCapabilities: PullRequestMergeCapabilities; + readonly canWrite: boolean; +} + +/** + * Whether the viewer's role on the repository is one that can push, which is what merging needs. + * TRIAGE and READ are not: a triager moves issues about and neither of them lands a commit. + * + * An install that reports no permission at all does not count as write. This is the exception to + * "an unknown permission is granted": write is what merging and closing somebody else's change + * need, and offering those to a reader who cannot use them wastes the press and reads as the app + * being wrong. Everything softer — commenting, reviewing, resolving — keeps the granting default, + * because being unable to say something is the worse failure there. + */ +function toCanWrite(viewerPermission: string | null | undefined): boolean { + switch (viewerPermission?.trim().toUpperCase()) { + case "ADMIN": + case "MAINTAIN": + case "WRITE": + return true; + default: + return false; + } +} + +/** Triage is the least role GitHub lets label a pull request; it is not a write. */ +function toCanTriage(viewerPermission: string | null | undefined): boolean { + return viewerPermission?.trim().toUpperCase() === "TRIAGE" || toCanWrite(viewerPermission); +} + +/** + * Who a review may be asked of, and who it has already been asked of, in one read. + * + * `assignableUsers` is the list GitHub's own reviewer picker is built from — everyone with access + * to the repository — rather than `collaborators`, which the REST API refuses to anyone without + * push access and which would therefore be empty for exactly the reader most likely to be looking. + * + * Teams are asked for only where one has already been requested, so a request to a team can be + * taken back. The teams a repository could newly be sent to live on the owning organization and + * need `read:org`, which a repository-scoped token need not carry — and a query GitHub refuses + * fails whole, taking the people down with the teams. + */ +export interface GitHubBaseComparison { + /** Null where the host could not compare, which the page reads as "unknown". */ + readonly behindBy: number | null; + readonly viewerCanUpdate: boolean; +} + +export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + assignableUsers(first: ${GRAPHQL_PAGE_SIZE}) { + pageInfo { hasNextPage } + nodes { login name avatarUrl } + } + pullRequest(number: $number) { + author { login } + reviewRequests(first: ${GRAPHQL_PAGE_SIZE}) { + nodes { + requestedReviewer { + ... on User { login name avatarUrl } + ... on Team { slug name avatarUrl } + ... on Bot { login avatarUrl } + } + } + } + } + } +}`; + +/** A team answers with a slug where a user answers with a login, and nothing else differs. */ +const RawRequestedReviewerSchema = Schema.Struct({ + ...RawActorSchema.fields, + slug: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawReviewerCandidatesSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + assignableUsers: Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array(Schema.NullOr(RawActorSchema)), + }), + /** Null for a number that names no pull request the viewer can see. */ + pullRequest: Schema.NullOr( + Schema.Struct({ + author: Schema.optional(Schema.NullOr(RawActorSchema)), + reviewRequests: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + requestedReviewer: Schema.optional(Schema.NullOr(RawRequestedReviewerSchema)), + }), + ), + }), + ), + ), + }), + ), + }), + }), +}); + +const decodeReviewerCandidates = decodeJsonResult(RawReviewerCandidatesSchema); + +/** + * The people this pull request may be sent to, with whoever is already on it marked. The author is + * dropped rather than shown as an unusable row: GitHub refuses a review request from the person + * who opened the pull request, so offering them is offering a failure. + * + * Whoever has been asked leads the list even where GitHub does not count them assignable — an + * outside collaborator, an app — because a request that cannot be seen cannot be taken back. + */ +export function decodeReviewerCandidatesJson( + raw: string, +): Result.Result { + const decoded = decodeReviewerCandidates(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const repository = decoded.success.data.repository; + const pullRequest = repository.pullRequest; + const author = trimmed(pullRequest?.author?.login); + const candidates = new Map(); + for (const node of pullRequest?.reviewRequests?.nodes ?? []) { + const raw = node.requestedReviewer; + const slug = trimmed(raw?.slug); + const id = slug ?? trimmed(raw?.login); + if (id === null) continue; + candidates.set(`${slug === null ? "user" : "team"} ${id}`, { + id, + kind: slug === null ? "user" : "team", + login: id, + name: trimmed(raw?.name), + avatarUrl: trimmed(raw?.avatarUrl), + isRequested: true, + }); + } + for (const node of repository.assignableUsers.nodes) { + const login = trimmed(node?.login); + if (login === null || login === author || candidates.has(`user ${login}`)) continue; + candidates.set(`user ${login}`, { + id: login, + kind: "user", + login, + name: trimmed(node?.name), + avatarUrl: trimmed(node?.avatarUrl), + isRequested: false, + }); + } + return Result.succeed({ + candidates: [...candidates.values()], + truncated: repository.assignableUsers.pageInfo?.hasNextPage === true, + }); +} + +/** + * The body of `POST`/`DELETE /repos/{owner}/{repo}/pulls/{number}/requested_reviewers`, which + * takes people and teams in two lists of its own. The same body serves both methods, because + * GitHub takes a request back from exactly whoever it was made of. + */ +const ReviewerRequestSchema = Schema.Struct({ + reviewers: Schema.Array(Schema.String), + team_reviewers: Schema.Array(Schema.String), +}); + +export function buildReviewerRequest( + reviewers: ReadonlyArray<{ readonly id: string; readonly kind: PullRequestReviewerKind }>, +): typeof ReviewerRequestSchema.Type { + return { + reviewers: reviewers.flatMap((reviewer) => (reviewer.kind === "user" ? [reviewer.id] : [])), + team_reviewers: reviewers.flatMap((reviewer) => + reviewer.kind === "team" ? [reviewer.id] : [], + ), + }; +} + +export const LABEL_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + labels(first: ${GRAPHQL_PAGE_SIZE}, orderBy: { field: NAME, direction: ASC }) { + pageInfo { hasNextPage } + nodes { name color description } + } + pullRequest(number: $number) { + labels(first: ${GRAPHQL_PAGE_SIZE}) { nodes { name } } + } + } +}`; + +const RawLabelCandidatesSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + labels: Schema.optional( + Schema.NullOr( + Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array( + Schema.NullOr( + Schema.Struct({ + ...RawLabelSchema.fields, + description: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + }), + ), + ), + /** Null for a number that names no pull request the viewer can see. */ + pullRequest: Schema.NullOr( + Schema.Struct({ + labels: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLabelSchema)) })), + ), + }), + ), + }), + }), +}); + +const decodeLabelCandidates = decodeJsonResult(RawLabelCandidatesSchema); + +/** + * The repository's labels, with the ones already on this pull request marked. A label the pull + * request wears that the repository no longer defines — deleted since, or past the page — leads + * the list anyway, because a label that cannot be seen cannot be taken off. + */ +export function decodeLabelCandidatesJson( + raw: string, +): Result.Result { + const decoded = decodeLabelCandidates(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const repository = decoded.success.data.repository; + const applied = new Set( + (repository.pullRequest?.labels?.nodes ?? []).flatMap((label) => { + const name = trimmed(label?.name); + return name === null ? [] : [name]; + }), + ); + const candidates = new Map(); + for (const node of repository.labels?.nodes ?? []) { + const name = trimmed(node?.name); + if (name === null) continue; + candidates.set(name, { + name, + color: trimmed(node?.color), + description: trimmed(node?.description), + isApplied: applied.has(name), + }); + } + const missing = [...applied].filter((name) => !candidates.has(name)); + return Result.succeed({ + candidates: [ + ...missing.map((name) => ({ name, color: null, description: null, isApplied: true })), + ...candidates.values(), + ], + truncated: repository.labels?.pageInfo?.hasNextPage === true, + }); +} + +/** The body of `POST /repos/{owner}/{repo}/issues/{number}/labels`, which adds to what is there. */ +export function buildLabelRequest(labels: ReadonlyArray): { + readonly labels: ReadonlyArray; +} { + return { labels }; +} + +/** + * Everything GitHub says about what the signed-in account may do here. `canWrite` is about the + * repository, the other two about this pull request in particular — which is why an author with + * only read access can still be told apart from a passer-by. + */ +export interface GitHubViewerAccess { + readonly canWrite: boolean; + /** + * The viewer's role reaches triage, which is the least that may label. Everyone who can write + * can triage; a triager is the one role that can label without being able to merge. + */ + readonly canTriage: boolean; + /** GitHub's own `viewerCanUpdate`, true for the author as well as for anyone with write. */ + readonly canUpdate: boolean; + readonly didAuthor: boolean; + /** + * GitHub's own `viewerCanUpdateBranch`, read with the base comparison rather than here: it is + * false for a branch that is already current, so it answers "may update, and there is + * something to update" at once. Absent where the comparison was not read. + */ + readonly canUpdateBranch?: boolean; +} + +/** Core detail and write checks share one read of permissions and merge settings. */ +export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission + pullRequest(number: $number) { viewerCanUpdate viewerDidAuthor } + } +}`; + +const RawViewerPermissionsSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + ...RawRepositoryAccessSchema.fields, + /** Null for a number that names no pull request the viewer can see. */ + pullRequest: Schema.NullOr(RawViewerFieldsSchema), + }), + }), +}); + +const decodeViewerPermissions = decodeJsonResult(RawViewerPermissionsSchema); + +export function decodeViewerPermissionsJson( + raw: string, +): Result.Result { + const decoded = decodeViewerPermissions(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const repository = decoded.success.data.repository; + return Result.succeed({ + mergeCapabilities: { + merge: repository.mergeCommitAllowed, + squash: repository.squashMergeAllowed, + rebase: repository.rebaseMergeAllowed, + }, + canWrite: toCanWrite(repository.viewerPermission), + canTriage: toCanTriage(repository.viewerPermission), + ...toPullRequestViewerFields(repository.pullRequest), + }); +} + +export interface GitHubPullRequestFilesPatch { + readonly patch: string; + /** At least one file's hunks were withheld by GitHub, so they are missing from the patch. */ + readonly truncated: boolean; + /** Files GitHub returned, counted before decoding, so the caller can page. */ + readonly rawCount: number; + /** GitHub's own counts for the files whose hunks it withheld. */ + readonly omittedFileStats: ReadonlyArray; +} + +/** + * The files API returns hunks per file with no `diff --git` header, so the unified patch every + * diff viewer expects is assembled here. This decodes one page; walking pages is the caller's + * job, which is why the raw file count comes back with the patch. + */ +export function decodePullRequestFilesJson( + raw: string, +): Result.Result { + return Result.map(decodeUnknownList(raw), toFilesPatch); +} + +/** + * The files of one commit, which the commit endpoint lists and pages the same way, only wrapped + * in an object. An empty commit carries no `files` at all, which is a commit with nothing in it. + */ +export function decodeCommitFilesJson( + raw: string, +): Result.Result { + return Result.map(decodeCommitFiles(raw), (commit) => toFilesPatch(commit.files ?? [])); +} + +const decodeCommitFiles = decodeJsonResult( + Schema.Struct({ files: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))) }), +); + +function toFilesPatch(entries: ReadonlyArray): GitHubPullRequestFilesPatch { + const sections: string[] = []; + const omittedFileStats: PullRequestOmittedFileStat[] = []; + let truncated = false; + for (const entry of entries) { + const file = decodeFileEntry(entry); + if (Exit.isFailure(file)) continue; + const value = file.value; + const hunks = value.patch ?? ""; + const status = value.status?.trim().toLowerCase(); + if (hunks.length === 0) { + // A file with no hunks is still a file that changed: a pure rename has none to give, and + // a binary one has none that can be shown. Both are listed, and only the second is a hole + // in the patch — leaving them out entirely would drop them from the change altogether. + const additions = value.additions ?? 0; + const deletions = value.deletions ?? 0; + if (additions + deletions > 0) { + truncated = true; + omittedFileStats.push({ path: value.filename, additions, deletions }); + } + } + // A rename counts its hunks against the old path, which is the only place it is named. + const oldPath = + status === "renamed" ? value.previous_filename || value.filename : value.filename; + const header = [ + `diff --git ${quoteGitPatchPath(`a/${oldPath}`)} ${quoteGitPatchPath(`b/${value.filename}`)}`, + // The files API reports no file mode, so the ordinary one stands in: the viewer reads + // these lines as "added" and "removed" rather than for the mode they carry. + ...(status === "added" ? ["new file mode 100644"] : []), + ...(status === "removed" ? ["deleted file mode 100644"] : []), + ...(status === "renamed" + ? [ + `rename from ${quoteGitPatchPath(oldPath)}`, + `rename to ${quoteGitPatchPath(value.filename)}`, + ] + : []), + `--- ${status === "added" ? "/dev/null" : quoteGitPatchPath(`a/${oldPath}`)}`, + `+++ ${status === "removed" ? "/dev/null" : quoteGitPatchPath(`b/${value.filename}`)}`, + ].join("\n"); + sections.push(hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.replace(/\n?$/, "\n")}`); + } + return { + patch: sections.join(""), + truncated, + rawCount: entries.length, + omittedFileStats, + }; +} + +/** + * Which files of a pull request the signed-in account has cleared. GraphQL only, since the REST + * files endpoint the patch is read from carries no viewed state, so this is a second read rather + * than a wider version of the first. + */ +export const PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + files(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { path viewerViewedState } + } + } + } +}`; + +const RawPullRequestFilesViewedSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ + pullRequest: Schema.NullOr( + Schema.Struct({ + files: Schema.Struct({ + pageInfo: Schema.Struct({ + hasNextPage: Schema.Boolean, + endCursor: Schema.NullOr(Schema.String), + }), + nodes: Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + path: Schema.String, + // Decoded as a plain string and narrowed below: a GitHub release that adds + // a fourth state must not fail the whole page. + viewerViewedState: Schema.String, + }), + ), + ), + ), + }), + }), + ), + }), + ), + }), +}); + +const decodePullRequestFilesViewed = decodeJsonResult(RawPullRequestFilesViewedSchema); + +export interface GitHubPullRequestFilesViewedPage { + readonly files: ReadonlyArray<{ + readonly path: string; + readonly state: PullRequestFileViewedState; + }>; + /** Where the next page carries on, or null once the host has no more to give. */ + readonly nextCursor: string | null; +} + +/** Anything this host does not name is treated as unread, which is the state that asks for least. */ +function toFileViewedState(raw: string): PullRequestFileViewedState { + switch (raw.trim().toUpperCase()) { + case "VIEWED": + return "viewed"; + case "DISMISSED": + return "dismissed"; + default: + return "unviewed"; + } +} + +export function decodePullRequestFilesViewedJson( + raw: string, +): Result.Result { + const decoded = decodePullRequestFilesViewed(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const files = decoded.success.data.repository?.pullRequest?.files; + if (files === undefined) return Result.succeed({ files: [], nextCursor: null }); + return Result.succeed({ + files: (files.nodes ?? []).flatMap((node) => + node === null || node.path.length === 0 + ? [] + : [{ path: node.path, state: toFileViewedState(node.viewerViewedState) }], + ), + nextCursor: files.pageInfo.hasNextPage ? files.pageInfo.endCursor : null, + }); +} + +/** + * One document that clears and restores as many files as the reader ticked, rather than one + * request each. GitHub has no bulk form of `markFileAsViewed`/`unmarkFileAsViewed`, which each + * take a single path, so the batching is done with aliases; top-level mutation fields run in + * write order, so the last word about a path is the one that sticks. + */ +export function buildSetFilesViewedGraphQlMutation( + pullRequestId: string, + files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>, +): GraphQlDocument | null { + return aliasedGraphQlDocument({ + operation: "mutation", + alias: "f", + items: files, + shared: { pullRequestId: ["ID!", pullRequestId] }, + variables: (file) => ({ path: ["String!", file.path] }), + field: ({ path }, file) => + `${file.viewed ? "markFileAsViewed" : "unmarkFileAsViewed"}(input: { pullRequestId: $pullRequestId, path: ${path} }) { clientMutationId }`, + }); +} + +/** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ +const RawStackPullRequestSchema = Schema.Struct({ + title: Schema.optional(Schema.String), + draft: Schema.optional(Schema.Boolean), + number: Schema.Int, + head: Schema.Struct({ ref: Schema.String, sha: Schema.optional(Schema.String) }), + state: Schema.optional(Schema.NullOr(Schema.String)), + merged_at: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** + * A stack as `GET /repos/{owner}/{repo}/stacks` answers it, in a public preview whose shape may + * still move. Only what a stack is made of is required — where it lives, what it stands on, and + * its pull requests — and `base` is accepted both as the ref object the preview sends today and + * as the bare branch name it started out as. + */ +const RawStackSchema = Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.Union([Schema.Int, Schema.String]))), + number: Schema.Int, + node_id: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.String, + html_url: Schema.optional(Schema.NullOr(Schema.String)), + base: Schema.Union([Schema.String, Schema.Struct({ ref: Schema.String })]), + pull_requests: Schema.Array(RawStackPullRequestSchema), +}); + +const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema)); + +export interface GitHubPullRequestStackLayer { + readonly title?: string; + readonly isDraft?: boolean; + readonly headSha?: string; + readonly number: number; + readonly headBranch: string; + readonly state: PullRequestState; +} + +export interface GitHubPullRequestStack { + readonly id: string; + readonly number: number; + readonly url: string; + readonly base: string; + /** Bottom to top, which is the order GitHub lists them in. */ + readonly layers: ReadonlyArray; +} + +/** + * The first stack of a `?pull_request=` listing, or null for an empty one: a pull request is in + * at most one stack, so the array is GitHub's way of saying "none" rather than a page. + */ +export function decodePullRequestStacksJson( + raw: string, +): Result.Result { + const decoded = decodeStacks(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const stack = decoded.success[0]; + if (stack === undefined) return Result.succeed(null); + return Result.succeed({ + id: stack.id == null ? (trimmed(stack.node_id) ?? String(stack.number)) : String(stack.id), + number: stack.number, + // The page a person opens where the preview reports one; the API URL is what it always has. + url: trimmed(stack.html_url) ?? stack.url, + base: typeof stack.base === "string" ? stack.base : stack.base.ref, + layers: stack.pull_requests.map((pullRequest) => ({ + ...(pullRequest.title === undefined ? {} : { title: pullRequest.title }), + ...(pullRequest.draft === undefined ? {} : { isDraft: pullRequest.draft }), + ...(pullRequest.head.sha === undefined ? {} : { headSha: pullRequest.head.sha }), + number: pullRequest.number, + headBranch: pullRequest.head.ref, + state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }), + })), + }); +} diff --git a/packages/source-control-github/src/server/gitHubPullRequests.ts b/packages/source-control-github/src/server/gitHubPullRequests.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c89bee35fa28d9a209be59b2355432c8e1046ca --- /dev/null +++ b/packages/source-control-github/src/server/gitHubPullRequests.ts @@ -0,0 +1,145 @@ +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +export interface NormalizedGitHubPullRequestRecord { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + /** The head commit, when the read asked for `headRefOid`. */ + readonly headSha?: string; + readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + readonly updatedAt: Option.Option; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +const GitHubPullRequestSchema = Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + baseRefName: TrimmedNonEmptyString, + headRefName: TrimmedNonEmptyString, + headRefOid: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.Boolean), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), + updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), + isCrossRepository: Schema.optional(Schema.Boolean), + // gh < 2.47 exports headRepository as {id, name} only; nameWithOwner was + // added later. Both fields stay optional so a version-drifted gh CLI can + // never fail the decode and silently drop the PR from the list. + headRepository: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nameWithOwner: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + headRepositoryOwner: Schema.optional( + Schema.NullOr( + Schema.Struct({ + login: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), +}); + +function trimOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeGitHubPullRequestState(input: { + state?: string | null | undefined; + mergedAt?: string | null | undefined; +}): "open" | "closed" | "merged" { + const normalizedState = input.state?.trim().toUpperCase(); + if ( + (typeof input.mergedAt === "string" && input.mergedAt.trim().length > 0) || + normalizedState === "MERGED" + ) { + return "merged"; + } + if (normalizedState === "CLOSED") { + return "closed"; + } + return "open"; +} + +function normalizeGitHubPullRequestRecord( + raw: Schema.Schema.Type, +): NormalizedGitHubPullRequestRecord { + const explicitNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); + const headRepositoryName = trimOptionalString(raw.headRepository?.name); + const headRepositoryOwnerLogin = + trimOptionalString(raw.headRepositoryOwner?.login) ?? + (explicitNameWithOwner?.includes("/") ? (explicitNameWithOwner.split("/")[0] ?? null) : null); + const headRepositoryNameWithOwner = + explicitNameWithOwner ?? + (headRepositoryOwnerLogin && headRepositoryName + ? `${headRepositoryOwnerLogin}/${headRepositoryName}` + : null); + const headSha = trimOptionalString(raw.headRefOid); + + return { + number: raw.number, + title: raw.title, + url: raw.url, + baseRefName: raw.baseRefName, + headRefName: raw.headRefName, + ...(headSha ? { headSha } : {}), + state: normalizeGitHubPullRequestState(raw), + ...(raw.isDraft === true ? { isDraft: true } : {}), + closedAt: raw.closedAt ?? null, + mergedAt: raw.mergedAt ?? null, + updatedAt: raw.updatedAt ?? Option.none(), + ...(typeof raw.isCrossRepository === "boolean" + ? { isCrossRepository: raw.isCrossRepository } + : {}), + ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), + }; +} + +const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); + +/** + * Pull request rows in `gh --json` or GraphQL node shape. A row that does not decode is + * skipped, so one malformed pull request cannot hide the rest. + */ +export function decodeGitHubPullRequestEntries( + entries: ReadonlyArray, +): ReadonlyArray { + const pullRequests: NormalizedGitHubPullRequestRecord[] = []; + for (const entry of entries) { + const decodedEntry = decodeGitHubPullRequestEntry(entry); + if (Exit.isSuccess(decodedEntry)) { + pullRequests.push(normalizeGitHubPullRequestRecord(decodedEntry.value)); + } + } + return pullRequests; +} + +export function decodeGitHubPullRequestListJson( + raw: string, +): Result.Result< + ReadonlyArray, + Cause.Cause +> { + return Result.map(decodeGitHubPullRequestList(raw), decodeGitHubPullRequestEntries); +} diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b33f0c38e40636ec2b1df0d30883f97207df971 --- /dev/null +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts @@ -0,0 +1,87 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + gitHubApiHostForRemote, + selectGitHubBaseRepository, +} from "./gitHubRepositoryResolution.ts"; + +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 }) => + 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("gitHubApiHostForRemote", () => { + it("reads an SSH alias through github.com and a real host as itself", () => { + assert.strictEqual(gitHubApiHostForRemote("git@github:acme/web.git"), "github.com"); + assert.strictEqual( + gitHubApiHostForRemote("git@github.example.com:a/b.git"), + "github.example.com", + ); + assert.strictEqual(gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null); + }); +}); diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts new file mode 100644 index 0000000000000000000000000000000000000000..21f68b4cdb20fd845d998ae10e0fb3f4d8f07860 --- /dev/null +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts @@ -0,0 +1,192 @@ +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { + detectSourceControlProviderFromRemoteUrl, + isSshRemoteUrl, +} from "@t3tools/shared/sourceControl"; + +/** 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; +} + +/** The fetch remotes in `git remote -v` output, by name. */ +export function parseFetchRemotes( + remotes: string, +): ReadonlyArray<{ readonly name: string; readonly url: string }> { + return remotes + .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]! })); +} + +/** + * 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 remote of parseFetchRemotes(input.remotes)) { + const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/"); + if (remoteHost !== host || !owner || !name || rest.length > 0) return null; + repositories.set(remote.name, { 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; +} + +/** + * The repository `gh` would act on in a checkout, from its remotes: the remote `gh` would pick, + * else the best-ranked remote on the host. `host` is the API host it settled on, which a caller + * names when no repository was found there. GH_REPO is the caller's to check first. + */ +export function resolveGitHubRepository(input: { + readonly remotes: string; + readonly resolved: string; + /** The checkout's GitHub API host, when the caller already knows it. */ + readonly hostHint: string | undefined; + readonly defaultHost: string; +}): { readonly host: string; readonly locator: GitHubRepositoryLocator | null } { + const fetchRemotes = parseFetchRemotes(input.remotes).map((remote) => ({ + ...remote, + host: gitHubApiHostForRemote(remote.url), + })); + const host = + (input.hostHint === undefined ? undefined : apiHostForHint(input.hostHint)) ?? + fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? + fetchRemotes.find((remote) => remote.host !== null)?.host ?? + input.defaultHost; + const selected = selectGitHubBaseRepository({ + remotes: input.remotes, + resolved: input.resolved, + host, + }); + if (selected !== null) return { host, locator: { 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 || normalizeGitRemoteUrl(remote.url).split("/")[0] === 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, locator: { host, owner, name } }; + } + return { host, locator: null }; +} + +/** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ +export 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. + */ +export 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; +} diff --git a/packages/source-control-github/src/server/githubGraphQl.test.ts b/packages/source-control-github/src/server/githubGraphQl.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ab8f93544933c9a86c147d7069b8a10cd8b7409 --- /dev/null +++ b/packages/source-control-github/src/server/githubGraphQl.test.ts @@ -0,0 +1,105 @@ +import { assert, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { aliasedGraphQlDocument, readGraphQlPages } from "./githubGraphQl.ts"; + +describe("aliasedGraphQlDocument", () => { + it("keeps every value out of the document, each under its alias's own variable", () => { + const document = aliasedGraphQlDocument({ + operation: "query", + name: "Lookups", + alias: "s", + items: ['acme") { x } #', "web"], + shared: { owner: ["String!", "acme"] }, + variables: (repo) => ({ name: ["String!", repo] }), + field: ({ name }) => `repository(owner: $owner, name: ${name}) { id }`, + }); + assert.isNotNull(document); + expect(document!.query).toBe( + "query Lookups($owner: String!, $s0_name: String!, $s1_name: String!) {\n" + + " s0: repository(owner: $owner, name: $s0_name) { id }\n" + + " s1: repository(owner: $owner, name: $s1_name) { id }\n}", + ); + expect(document!.variables).toEqual({ + owner: "acme", + s0_name: 'acme") { x } #', + s1_name: "web", + }); + }); + + it("aliases by key and nests inside a shared parent when asked", () => { + const document = aliasedGraphQlDocument({ + operation: "query", + alias: "pr", + key: (number) => number, + items: [2, 3], + variables: (number) => ({ number: ["Int!", number] }), + field: ({ number }) => `pullRequest(number: ${number}) { id }`, + within: (fields) => `repository(owner: "a", name: "b") {\n${fields}\n}`, + }); + expect(document?.query).toContain(" pr2: pullRequest(number: $pr2_number) { id }"); + expect(document?.query).toContain(" pr3: pullRequest(number: $pr3_number) { id }"); + expect(document?.variables).toEqual({ pr2_number: 2, pr3_number: 3 }); + }); + + it("asks for nothing when there is nothing to ask", () => { + expect( + aliasedGraphQlDocument({ + operation: "mutation", + alias: "f", + items: [], + variables: () => ({}), + field: () => "x", + }), + ).toBeNull(); + }); +}); + +describe("readGraphQlPages", () => { + const pages = (cursors: ReadonlyArray) => { + const asked: Array = []; + const read = (after: string | null) => + Effect.sync(() => { + asked.push(after); + return { next: cursors[asked.length - 1] ?? null }; + }); + return { asked, read }; + }; + + it.effect("follows the cursor until GitHub has no more", () => + Effect.gen(function* () { + const { asked, read } = pages(["a", "b", null]); + const result = yield* readGraphQlPages(read, { nextCursor: (page) => page.next }); + expect(asked).toEqual([null, "a", "b"]); + expect(result.truncated).toBe(false); + }), + ); + + it.effect("stops as truncated at the page limit, at `until`, or on a repeated cursor", () => + Effect.gen(function* () { + const limited = pages(["a", "b", "c"]); + const atLimit = yield* readGraphQlPages(limited.read, { + nextCursor: (page) => page.next, + maxPages: 2, + }); + expect(limited.asked).toEqual([null, "a"]); + expect(atLimit.truncated).toBe(true); + + const enough = pages(["a", "b", "c"]); + const satisfied = yield* readGraphQlPages(enough.read, { + nextCursor: (page) => page.next, + until: (read) => read.length === 1, + }); + expect(enough.asked).toEqual([null]); + expect(satisfied.truncated).toBe(true); + + const stuck = pages(["a", "a", "a"]); + const repeated = yield* readGraphQlPages(stuck.read, { + nextCursor: (page) => page.next, + from: "start", + }); + expect(stuck.asked).toEqual(["start", "a"]); + expect(repeated.truncated).toBe(true); + }), + ); +}); diff --git a/packages/source-control-github/src/server/githubGraphQl.ts b/packages/source-control-github/src/server/githubGraphQl.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b1e3ca65844c5c8656a153f0c07e71e583cdc95 --- /dev/null +++ b/packages/source-control-github/src/server/githubGraphQl.ts @@ -0,0 +1,100 @@ +import * as Effect from "effect/Effect"; + +/** GraphQL variables by name, each with the type it is declared as and the value it carries. */ +export type GraphQlVariables = Readonly>; + +export interface GraphQlDocument { + readonly query: string; + readonly variables: Readonly>; +} + +/** + * One document asking the same field for many items, each under its own alias: the prefix and + * the item's position (`s0`, `s1`, …), or its `key` when the answer is easier read by one. GitHub + * has no bulk form of most lookups, so aliases are how a batch travels in one request. + * + * Every value travels as a variable renamed per alias (`$s0_owner`), and `field` is handed those + * placeholders rather than the values: nothing an item holds is written into the document. + * `shared` variables are declared once and referenced by their own name; `within` nests the aliased + * fields inside one parent selection, such as a single repository. + * + * Null for no items, since a document with no selection is not a document. + */ +export function aliasedGraphQlDocument(input: { + readonly operation: "query" | "mutation"; + /** The operation's name, which tells one batch from another in logs and recorded traffic. */ + readonly name?: string; + readonly alias: string; + readonly key?: (item: Item, index: number) => number; + readonly items: ReadonlyArray; + readonly variables: (item: Item) => Variables; + readonly field: ( + placeholders: { readonly [Name in keyof Variables]: string }, + item: Item, + ) => string; + readonly shared?: GraphQlVariables; + readonly within?: (fields: string) => string; +}): GraphQlDocument | null { + if (input.items.length === 0) return null; + const declarations: string[] = []; + const variables: Record = {}; + for (const [name, [type, value]] of Object.entries(input.shared ?? {})) { + declarations.push(`$${name}: ${type}`); + variables[name] = value; + } + const fields = input.items.map((item, index) => { + const alias = `${input.alias}${input.key?.(item, index) ?? index}`; + const placeholders: Record = {}; + for (const [name, [type, value]] of Object.entries(input.variables(item))) { + const variable = `${alias}_${name}`; + declarations.push(`$${variable}: ${type}`); + variables[variable] = value; + placeholders[name] = `$${variable}`; + } + return ` ${alias}: ${input.field(placeholders as { readonly [Name in keyof Variables]: string }, item)}`; + }); + const selection = fields.join("\n"); + const parameters = declarations.length === 0 ? "" : `(${declarations.join(", ")})`; + return { + query: `${input.operation}${input.name === undefined ? "" : ` ${input.name}`}${parameters} {\n${input.within?.(selection) ?? selection}\n}`, + variables, + }; +} + +/** + * The pages of a GraphQL connection, read one after another from `from` (the first page by + * default) until GitHub has no more, `until` is satisfied, or `maxPages` have been read. `read` is + * handed the pages so far, for reads that size or check the next page by what has already arrived. + * + * `truncated` says pages remained when reading stopped. A cursor GitHub hands back a second time + * would page forever, so it stops the read as truncated too. + */ +export const readGraphQlPages = ( + read: (after: string | null, pages: ReadonlyArray) => Effect.Effect, + options: { + readonly nextCursor: (page: NoInfer) => string | null; + readonly from?: string | null; + readonly maxPages?: number; + readonly until?: (pages: ReadonlyArray>) => boolean; + }, +): Effect.Effect<{ readonly pages: ReadonlyArray; readonly truncated: boolean }, E, R> => + Effect.gen(function* () { + const pages: Page[] = []; + const seen = new Set(); + let after = options.from ?? null; + while (true) { + const page: Page = yield* read(after, pages); + pages.push(page); + const next = options.nextCursor(page); + if (next === null) return { pages, truncated: false }; + if ( + seen.has(next) || + pages.length >= (options.maxPages ?? Infinity) || + options.until?.(pages) === true + ) { + return { pages, truncated: true }; + } + seen.add(next); + after = next; + } + }); diff --git a/packages/source-control-github/src/server/githubStackActions.test.ts b/packages/source-control-github/src/server/githubStackActions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..846d88332a60b93d94688f2c031c826e16060302 --- /dev/null +++ b/packages/source-control-github/src/server/githubStackActions.test.ts @@ -0,0 +1,395 @@ +import { expect, it } from "@effect/vitest"; +import * as Layer from "effect/Layer"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as FileSystem from "effect/FileSystem"; +import * as Redacted from "effect/Redacted"; +import { ChildProcessSpawner } from "effect/process"; +import * as GitHubApi from "./GitHubApi.ts"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; +import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; + +/** + * One request as the fake saw it, flattened to words: the path or document, then each variable + * or body field as `name=value`, so an assertion reads as "this request carried that value". + */ +type Call = ReadonlyArray; +type Send = ( + call: Call, + kind: "graphql" | "rest", +) => Effect.Effect; + +/** Every git command the cascade ran, in order; none actually runs. */ +let gitCalls: Array> = []; +const fakeGit = Layer.mergeAll( + TestSourceControlHost.layer({ + process: { + run: (input) => + Effect.sync(() => { + gitCalls.push(input.args); + const stdout = + input.args[0] === "rev-parse" || input.args[0] === "merge-base" ? "new-sha\n" : ""; + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }, + }), + FileSystem.layerNoop({ makeTempDirectoryScoped: () => Effect.succeed("/tmp/scratch") }), +); + +const runGitHubStackAction = (send: Send, input: Parameters[0]) => + runStackAction(input).pipe( + Effect.provide( + Layer.mergeAll( + fakeGit, + Layer.mock(GitHubApi.GitHubApi)({ + credential: () => Effect.succeed({ token: Redacted.make("token"), fingerprint: "fp" }), + graphql: (request) => { + // GitHub refuses a document that declares a variable it never uses. + const declared = [...request.query.matchAll(/\$(\w+)\s*:/g)].map((match) => match[1]!); + const unused = declared.filter( + (name) => !new RegExp(`\\$${name}(?!\\w)(?!\\s*:)`).test(request.query), + ); + if (unused.length > 0) { + return Effect.die(new Error(`Variables declared but not used: ${unused.join(", ")}`)); + } + return send(words(request.query, request.variables), "graphql"); + }, + rest: (request) => + send(words(request.path, request.body), "rest").pipe( + Effect.map((body) => ({ + status: 200, + headers: {}, + body, + truncated: false, + invalidUtf8: false, + })), + ), + }), + ), + ), + ); + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +function words(head: string, fields: unknown): Call { + return [ + head, + ...Object.entries((fields ?? {}) as Record).map( + ([key, value]) => `${key}=${typeof value === "string" ? value : encodeJson(value)}`, + ), + ]; +} + +const isMutation = (call: Call) => call[0]!.startsWith("mutation"); + +const stack = [ + { + number: 50, + url: "https://api.github.com/repos/acme/web/stacks/50", + base: { ref: "main" }, + pull_requests: [ + { + number: 1, + title: "Base", + head: { ref: "base", sha: "aaa" }, + state: "closed", + merged_at: "2026-01-01T00:00:00Z", + }, + { + number: 2, + title: "Middle", + head: { ref: "middle", sha: "bbb" }, + state: "open", + draft: false, + }, + { number: 3, title: "Top", head: { ref: "top", sha: "ccc" }, state: "open", draft: false }, + ], + }, +]; +const input = { + cwd: "/repo", + repository: "acme/web", + host: "github.com", + number: 3, + stackNumber: 50, + expectedStackHeads: [ + { number: 2, headSha: "bbb" }, + { number: 3, headSha: "ccc" }, + ], + action: "merge" as const, +}; +const access = { + data: { + repository: { + pr2: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, + pr3: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, + }, + }, +}; + +const branch = (number: number, headRefOid: string, behindBy = 1, processed: string[] = []) => ({ + data: { + processed: processed.map((headRefOid) => ({ headRefOid })), + repository: { + pullRequest: { id: `PR_${number}`, headRefOid, baseRef: { compare: { behindBy } } }, + }, + }, +}); +const rebased = { + data: { updatePullRequestBranch: { pullRequest: { headRefOid: "rebased-sha" } } }, +}; +const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased]; + +function fake(responses: readonly unknown[]) { + const calls: Call[] = []; + const execute: Send = (call) => + Effect.sync(() => { + calls.push(call); + const value = responses[calls.length - 1]; + if (value === undefined) throw new Error("Unexpected GitHub request"); + return encodeJson(value); + }); + return { execute, calls }; +} + +it.effect("submits one atomic merge with the reviewed head and respects the merge queue", () => + Effect.gen(function* () { + const api = fake([stack, { status: "enqueued", details: {} }]); + yield* runGitHubStackAction(api.execute, { ...input, mergeMethod: "squash" }); + expect(api.calls).toHaveLength(2); + expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); + expect(api.calls[1]).toContain("sha=ccc"); + expect(api.calls[1]).toContain("merge_action=default"); + expect(api.calls[1]).toContain("merge_method=squash"); + }), +); + +it.effect("merges through the selected layer without including later draft layers", () => + Effect.gen(function* () { + const fiveLayers = [ + { + ...stack[0], + pull_requests: Array.from({ length: 5 }, (_, index) => ({ + number: index + 1, + head: { ref: `layer-${index + 1}`, sha: `sha-${index + 1}` }, + state: "open", + draft: index >= 3, + })), + }, + ]; + const api = fake([fiveLayers, { status: "merged", details: {} }]); + yield* runGitHubStackAction(api.execute, { + ...input, + number: 3, + expectedStackHeads: [1, 2, 3].map((number) => ({ number, headSha: `sha-${number}` })), + }); + expect(api.calls).toHaveLength(2); + expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); + expect(api.calls[1]).toContain("sha=sha-3"); + expect(api.calls[1]).toContain("merge_action=default"); + }), +); + +it.effect("rejects stale reviewed heads below a selected middle layer", () => + Effect.gen(function* () { + const api = fake([stack]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + number: 2, + expectedStackHeads: [{ number: 2, headSha: "old-head" }], + }).pipe(Effect.result); + expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); + expect(api.calls).toHaveLength(1); + }), +); + +it.effect("does not merge from an already merged layer", () => + Effect.gen(function* () { + const api = fake([stack]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + number: 1, + expectedStackHeads: [], + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackUnsupportedError" }, + }); + expect(api.calls).toHaveLength(1); + }), +); + +it.effect("polls an accepted merge and reports a later rule rejection", () => + Effect.gen(function* () { + const api = fake([ + stack, + { status: "pending", details: { uuid: "operation" } }, + { status: "failed", details: { message: "Required checks have not passed" } }, + ]); + const fiber = yield* runGitHubStackAction(api.execute, input).pipe( + Effect.result, + Effect.forkChild, + ); + yield* TestClock.adjust("1 second"); + const result = yield* Fiber.join(fiber); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackMergeRejectedError" }, + }); + expect(api.calls[2]).toContain("repos/acme/web/pulls/3/merge-async/operation"); + }), +); + +it.effect("retains stack identity and a rejection response without a message", () => + Effect.gen(function* () { + const rejection = { status: "failed", details: {} }; + const api = fake([stack, rejection]); + const result = yield* runGitHubStackAction(api.execute, input).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "GitHubStackMergeRejectedError", + repository: input.repository, + number: input.number, + stackNumber: input.stackNumber, + cause: rejection, + }, + }); + }), +); + +it.effect("refuses a changed stack before performing any mutation", () => + Effect.gen(function* () { + const api = fake([stack]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + expectedStackHeads: [ + { number: 2, headSha: "old" }, + { number: 3, headSha: "ccc" }, + ], + }).pipe(Effect.result); + expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); + expect(api.calls).toHaveLength(1); + }), +); + +it.effect("refuses the entire rebase before mutation when a later fork denies write access", () => + Effect.gen(function* () { + const api = fake([ + stack, + { + data: { + repository: { + ...access.data.repository, + pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false }, + }, + }, + }, + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackPermissionError" }, + }); + expect(api.calls).toHaveLength(2); + }), +); + +it.effect("allows a fork that explicitly permits maintainer updates", () => + Effect.gen(function* () { + const api = fake([ + stack, + { + data: { + repository: { + ...access.data.repository, + pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: true }, + }, + }, + }, + ...rebaseResponses, + ]); + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); + expect(gitCalls.filter((args) => args[0] === "push")).toHaveLength(2); + }), +); + +it.effect("bounds polling and reports a still-running merge without claiming success", () => + Effect.gen(function* () { + const api = fake([ + stack, + ...Array.from({ length: 40 }, () => ({ status: "pending", details: { uuid: "operation" } })), + ]); + const fiber = yield* runGitHubStackAction(api.execute, input).pipe( + Effect.result, + Effect.forkChild, + ); + yield* TestClock.adjust("6 minutes"); + expect(yield* Fiber.join(fiber)).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackMergePendingError" }, + }); + expect(api.calls.length).toBeLessThan(40); + }), +); + +it.effect("rebases the open layers bottom to top in a scratch clone, each onto the one below", () => + Effect.gen(function* () { + gitCalls = []; + const api = fake([stack, access]); + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); + // GitHub is only read; every change is a git push with a lease on the reviewed head. + expect(api.calls.some(isMutation)).toBe(false); + const rebases = gitCalls.filter((args) => args[0] === "rebase"); + expect(rebases).toEqual([ + ["rebase", "--quiet", "--onto", "origin/main", "new-sha"], + ["rebase", "--quiet", "--onto", "new-sha", "bbb"], + ]); + expect(gitCalls.filter((args) => args[0] === "push")).toEqual([ + [ + "push", + "--quiet", + "--force-with-lease=refs/heads/middle:bbb", + "origin", + "new-sha:refs/heads/middle", + ], + [ + "push", + "--quiet", + "--force-with-lease=refs/heads/top:ccc", + "origin", + "new-sha:refs/heads/top", + ], + ]); + }), +); + +it.effect("checks every layer's write access before any git runs", () => + Effect.gen(function* () { + gitCalls = []; + const api = fake([ + stack, + { + data: { + repository: { + ...access.data.repository, + pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false }, + }, + }, + }, + ]); + yield* Effect.flip(runGitHubStackAction(api.execute, { ...input, action: "update-branch" })); + expect(gitCalls).toEqual([]); + }), +); diff --git a/packages/source-control-github/src/server/githubStackActions.ts b/packages/source-control-github/src/server/githubStackActions.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6d90711620f29deeec0450901ed4b964fcc1260 --- /dev/null +++ b/packages/source-control-github/src/server/githubStackActions.ts @@ -0,0 +1,288 @@ +import type { + PullRequestAction, + PullRequestMergeMethod, + PullRequestStackHead, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import * as GitHubApi from "./GitHubApi.ts"; +import { aliasedGraphQlDocument } from "./githubGraphQl.ts"; +import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; +import { cascadeRebaseStack } from "./githubStackRebase.ts"; + +const stackErrorIdentity = { + repository: Schema.String, + number: Schema.Int, + stackNumber: Schema.Int, +}; + +export class GitHubStackChangedError extends Schema.TaggedError()( + "GitHubStackChangedError", + { ...stackErrorIdentity, completed: Schema.Int }, +) { + override get message(): string { + return this.completed > 0 + ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.` + : "The stack changed. Refresh it before trying again."; + } +} + +export class GitHubStackUnsupportedError extends Schema.TaggedError()( + "GitHubStackUnsupportedError", + stackErrorIdentity, +) { + override get message(): string { + return "This operation is not supported for this stack."; + } +} + +export class GitHubStackResponseInvalidError extends Schema.TaggedError()( + "GitHubStackResponseInvalidError", + { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) }, +) { + override get message(): string { + return "GitHub returned an unreadable stack operation response."; + } +} + +export class GitHubStackMergeRejectedError extends Schema.TaggedError()( + "GitHubStackMergeRejectedError", + { ...stackErrorIdentity, cause: Schema.Defect() }, +) { + override get message(): string { + return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements."; + } +} + +export class GitHubStackMergePendingError extends Schema.TaggedError()( + "GitHubStackMergePendingError", + stackErrorIdentity, +) { + override get message(): string { + return "The merge is still running on GitHub. Check its status there before submitting another request."; + } +} + +export class GitHubStackPermissionError extends Schema.TaggedError()( + "GitHubStackPermissionError", + stackErrorIdentity, +) { + override get message(): string { + return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying."; + } +} + +export class GitHubStackRebaseFailedError extends Schema.TaggedError()( + "GitHubStackRebaseFailedError", + { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() }, +) { + override get message(): string { + // A conflict or a refused push already says which layer and what to do about it. + return this.cause instanceof Error && this.cause.message !== "" + ? this.cause.message + : `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`; + } +} + +export type GitHubStackActionError = + | GitHubStackChangedError + | GitHubStackUnsupportedError + | GitHubStackResponseInvalidError + | GitHubStackMergeRejectedError + | GitHubStackMergePendingError + | GitHubStackPermissionError + | GitHubStackRebaseFailedError; + +const MergeResponse = Schema.Struct({ + status: Schema.Literals(["pending", "merged", "enqueued", "failed"]), + details: Schema.Struct({ + uuid: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + }), +}); + +const decodeBranchAccess = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr( + Schema.Struct({ + headRepository: Schema.NullOr( + Schema.Struct({ viewerPermission: Schema.NullOr(Schema.String) }), + ), + maintainerCanModify: Schema.Boolean, + }), + ), + ), + ), + }), + }), + ), +); + +const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse)); + +/** Remote-only updates: a stack rebase works in a scratch clone, never the environment's checkout. */ +export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* (input: { + cwd: string; + repository: string; + host: string; + number: number; + stackNumber: number; + expectedStackHeads?: ReadonlyArray; + action: PullRequestAction; + mergeMethod?: PullRequestMergeMethod; +}) { + const api = yield* GitHubApi.GitHubApi; + const identity = { + repository: input.repository, + number: input.number, + stackNumber: input.stackNumber, + }; + if (input.action !== "merge" && input.action !== "update-branch") + return yield* new GitHubStackUnsupportedError({ ...identity }); + const endpoint = `repos/${input.repository}`; + const read = yield* api.rest({ + host: input.host, + operation: "runGitHubStackAction", + path: `${endpoint}/stacks?pull_request=${input.number}`, + }); + const decoded = decodePullRequestStacksJson(read.body); + if (Result.isFailure(decoded)) + return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure }); + const stack = decoded.success; + const targetIndex = stack?.layers.findIndex((layer) => layer.number === input.number) ?? -1; + const target = stack?.layers[targetIndex]; + if ( + stack?.number !== input.stackNumber || + target === undefined || + (input.action === "update-branch" && targetIndex !== stack.layers.length - 1) + ) { + return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); + } + const affectedLayers = + input.action === "merge" ? stack.layers.slice(0, targetIndex + 1) : stack.layers; + const open = affectedLayers.filter((layer) => layer.state !== "merged"); + if (input.action === "merge" && target.state !== "open") + return yield* new GitHubStackUnsupportedError({ ...identity }); + if ( + !input.expectedStackHeads || + input.expectedStackHeads.length !== open.length || + new Set(input.expectedStackHeads.map((layer) => layer.number)).size !== open.length || + open.some( + (layer) => + !layer.headSha || + !input.expectedStackHeads?.some( + (expected) => expected.number === layer.number && expected.headSha === layer.headSha, + ), + ) + ) { + return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); + } + if (open.length === 0 || open.some((layer) => layer.state !== "open")) + return yield* new GitHubStackUnsupportedError({ ...identity }); + if (input.action === "update-branch") { + const [owner, name] = input.repository.split("/"); + const permissions = yield* api.graphql({ + host: input.host, + operation: "runGitHubStackAction", + allowReserve: true, + // `open` is non-empty here, so there is always a document. + ...aliasedGraphQlDocument({ + operation: "query", + alias: "pr", + key: (layer) => layer.number, + items: open, + shared: { owner: ["String!", owner], name: ["String!", name] }, + variables: (layer) => ({ number: ["Int!", layer.number] }), + field: ({ number }) => + `pullRequest(number: ${number}) { headRepository { viewerPermission } maintainerCanModify }`, + within: (fields) => `repository(owner: $owner, name: $name) {\n${fields}\n}`, + })!, + }); + const access = yield* decodeBranchAccess(permissions).pipe( + Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), + ); + // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent + // will make it stale. Check branch write access separately before touching any layer. + if ( + open.some((layer) => { + const pr = access.data.repository?.[`pr${layer.number}`]; + return ( + !pr?.headRepository || + (!pr.maintainerCanModify && + !["ADMIN", "MAINTAIN", "WRITE"].includes(pr.headRepository.viewerPermission ?? "")) + ); + }) + ) + return yield* new GitHubStackPermissionError({ ...identity }); + // GitHub's own "Rebase stack" has no API, and its per-PR "update branch" replays the old + // copy of every lower layer into the one above it. The cascade moves each layer's own commits. + yield* cascadeRebaseStack({ + host: input.host, + repository: input.repository, + base: stack.base, + layers: open.map((layer) => ({ + number: layer.number, + headBranch: layer.headBranch, + headSha: layer.headSha!, + })), + }).pipe( + Effect.mapError((cause) => + cause._tag === "GitHubStackRebaseConflictError" || + cause._tag === "GitHubStackRebaseGitError" + ? new GitHubStackRebaseFailedError({ + ...identity, + number: cause.number, + completed: cause.completed, + cause, + }) + : cause, + ), + ); + return; + } + if (open.some((layer) => layer.isDraft)) + return yield* new GitHubStackUnsupportedError({ ...identity }); + const decode = (raw: string) => + decodeMergeResponse(raw).pipe( + Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), + ); + const request = yield* api.rest({ + host: input.host, + operation: "runGitHubStackAction", + method: "PUT", + path: `${endpoint}/pulls/${input.number}/merge-async`, + body: { + merge_method: input.mergeMethod ?? "merge", + merge_action: "default", + sha: target.headSha, + }, + }); + let result = yield* decode(request.body); + const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000; + for ( + let attempt = 0; + result.status === "pending" && (yield* Clock.currentTimeMillis) < deadline; + attempt++ + ) { + const uuid = result.details.uuid; + if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity }); + yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000)); + const poll = yield* api.rest({ + host: input.host, + operation: "runGitHubStackAction", + path: `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`, + }); + result = yield* decode(poll.body); + } + if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity }); + if (result.status === "failed") + return yield* new GitHubStackMergeRejectedError({ ...identity, cause: result }); +}); diff --git a/packages/source-control-github/src/server/githubStackRebase.ts b/packages/source-control-github/src/server/githubStackRebase.ts new file mode 100644 index 0000000000000000000000000000000000000000..a1dffa05d9765d7c418192139f79dd9bfcd62e8e --- /dev/null +++ b/packages/source-control-github/src/server/githubStackRebase.ts @@ -0,0 +1,155 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as Base64 from "effect/encoding/Base64"; + +import * as GitHubApi from "./GitHubApi.ts"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; + +/** One open layer of a stack, bottom to top, with the head the reader reviewed. */ +export interface CascadeLayer { + readonly number: number; + readonly headBranch: string; + readonly headSha: string; +} + +/** A layer's rebase hit a conflict; the layers below it are already rebased on GitHub. */ +export class GitHubStackRebaseConflictError extends Schema.TaggedError()( + "GitHubStackRebaseConflictError", + { number: Schema.Int, completed: Schema.Int }, +) { + override get message(): string { + return `PR #${this.number} conflicts with the layer below it. ${this.completed} layers were rebased; resolve #${this.number} with \`gh stack rebase\` and push.`; + } +} + +/** Git failed for a reason other than a conflict: the fetch, or a push GitHub refused. */ +export class GitHubStackRebaseGitError extends Schema.TaggedError()( + "GitHubStackRebaseGitError", + { step: Schema.String, number: Schema.Int, completed: Schema.Int, cause: Schema.Defect() }, +) { + override get message(): string { + return `Stack rebase stopped at PR #${this.number} while ${this.step}. ${this.completed} layers were rebased.`; + } +} + +/** The git URL of a repository on a GitHub host, which is also where its token is good. */ +function remoteUrl(host: string, repository: string): string { + return `https://${host.trim().toLowerCase()}/${repository}.git`; +} + +/** + * GitHub's own "Rebase stack": every open layer, bottom to top, onto the new head of the layer + * below it (the bottom one onto the stack's base). Each layer moves only its own commits — + * `git rebase --onto ` — so a rebased lower layer is never replayed into + * the one above it, which is what GitHub's per-PR "update branch" does and why it conflicts. + * + * It works in a throwaway clone, so the environment's checkout never moves, and pushes each + * layer with a lease on the head that was reviewed, so a push that landed meanwhile is refused + * rather than overwritten. The stacks API has no rebase endpoint to call instead. + */ +export const cascadeRebaseStack = Effect.fn("cascadeRebaseStack")(function* (input: { + readonly host: string; + readonly repository: string; + readonly base: string; + readonly layers: ReadonlyArray; + /** Where to fetch and push; the repository's own URL on its host unless a test points elsewhere. */ + readonly remote?: string; +}) { + const api = yield* GitHubApi.GitHubApi; + const { process } = yield* SourceControlHost.SourceControlHost; + const fileSystem = yield* FileSystem.FileSystem; + const { token } = yield* api.credential(input.host); + const first = input.layers[0]; + if (first === undefined) return 0; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-stack-rebase-" }).pipe( + Effect.mapError( + (cause) => + new GitHubStackRebaseGitError({ + step: "preparing", + number: first.number, + completed: 0, + cause, + }), + ), + ); + // The token rides in a header for this process only: never in the URL, argv, or git config. + const remote = input.remote ?? remoteUrl(input.host, input.repository); + const authorization = `AUTHORIZATION: basic ${Base64.encode(`x-access-token:${Redacted.value(token)}`)}`; + const env = { + GIT_TERMINAL_PROMPT: "0", + GIT_CONFIG_COUNT: "3", + GIT_CONFIG_KEY_0: `http.${remote}.extraheader`, + GIT_CONFIG_VALUE_0: authorization, + GIT_CONFIG_KEY_1: "user.name", + GIT_CONFIG_VALUE_1: "T3 Code", + GIT_CONFIG_KEY_2: "user.email", + GIT_CONFIG_VALUE_2: "noreply@t3.codes", + }; + const git = (args: ReadonlyArray, allowNonZeroExit = false) => + process.run({ + operation: "cascadeRebaseStack", + command: "git", + args, + cwd: directory, + env, + timeoutMs: 120_000, + ...(allowNonZeroExit ? { allowNonZeroExit: true } : {}), + }); + const failed = (step: string, number: number, completed: number) => (cause: unknown) => + new GitHubStackRebaseGitError({ step, number, completed, cause }); + + yield* git(["init", "--quiet"]).pipe(Effect.mapError(failed("preparing", first.number, 0))); + yield* git(["remote", "add", "origin", remote]).pipe( + Effect.mapError(failed("preparing", first.number, 0)), + ); + yield* git([ + "fetch", + "--quiet", + "--no-tags", + "origin", + `+refs/heads/${input.base}:refs/remotes/origin/${input.base}`, + ...input.layers.map( + (layer) => `+refs/heads/${layer.headBranch}:refs/remotes/origin/${layer.headBranch}`, + ), + ]).pipe(Effect.mapError(failed("fetching", first.number, 0))); + + let parentOld = `origin/${input.base}`; + let parentNew = `origin/${input.base}`; + for (const [index, layer] of input.layers.entries()) { + // The bottom layer's old parent is where it forked from the base; above that it is the + // reviewed head of the layer below, which is exactly the commits this layer must not replay. + const upstream = + index === 0 + ? (yield* git(["merge-base", parentOld, layer.headSha]).pipe( + Effect.mapError(failed("reading the fork point", layer.number, index)), + )).stdout.trim() + : parentOld; + yield* git(["checkout", "--quiet", "--detach", layer.headSha]).pipe( + Effect.mapError(failed("checking out", layer.number, index)), + ); + const rebase = yield* git(["rebase", "--quiet", "--onto", parentNew, upstream], true).pipe( + Effect.mapError(failed("rebasing", layer.number, index)), + ); + if (rebase.exitCode !== 0) { + yield* git(["rebase", "--abort"], true).pipe(Effect.ignore); + return yield* new GitHubStackRebaseConflictError({ number: layer.number, completed: index }); + } + const rebased = (yield* git(["rev-parse", "HEAD"]).pipe( + Effect.mapError(failed("rebasing", layer.number, index)), + )).stdout.trim(); + if (rebased !== layer.headSha) { + yield* git([ + "push", + "--quiet", + `--force-with-lease=refs/heads/${layer.headBranch}:${layer.headSha}`, + "origin", + `${rebased}:refs/heads/${layer.headBranch}`, + ]).pipe(Effect.mapError(failed("pushing", layer.number, index))); + } + parentOld = layer.headSha; + parentNew = rebased; + } + return input.layers.length; +}, Effect.scoped); diff --git a/packages/source-control-github/tsconfig.json b/packages/source-control-github/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..374bac55202ddcad695ede1c567ec219a61e9c48 --- /dev/null +++ b/packages/source-control-github/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/source-control-testing/package.json b/packages/source-control-testing/package.json index 66426fe0337726e3bd85873dfc27b3ec250d0806..60c1bbcf43bf6243f797d628e488a50dbb09df0c 100644 --- a/packages/source-control-testing/package.json +++ b/packages/source-control-testing/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*", "@t3tools/source-control-core": "workspace:*", "effect": "catalog:" }, diff --git a/packages/source-control-testing/src/TestSourceControlHost.ts b/packages/source-control-testing/src/TestSourceControlHost.ts index eee1a96f13a97fd9632bb755e430174b4ee950d5..a4cb30db734147998293a2026b2c8988eb2b4bd6 100644 --- a/packages/source-control-testing/src/TestSourceControlHost.ts +++ b/packages/source-control-testing/src/TestSourceControlHost.ts @@ -1,15 +1,23 @@ /** - * A `SourceControlHost.SourceControlHost` for provider tests. Settings are fixed unless the test - * supplies its own. Process runs and git operations go to what the test supplies; anything else - * dies, so an unexpected CLI or git call is visible. + * A `SourceControlHost.SourceControlHost` for provider tests. Settings start from the defaults + * or what the test supplies, and `TestSourceControlHostSettings` patches them mid-test the way a + * client's settings write would. Process runs and git operations go to what the test supplies; + * anything else dies, so an unexpected CLI or git call is visible. * * @module source-control-testing/TestSourceControlHost */ -import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + type ServerSettings, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as Ref from "effect/Ref"; export interface TestSourceControlHostOptions { readonly settings?: ServerSettings; @@ -17,12 +25,20 @@ export interface TestSourceControlHostOptions { readonly git?: Partial; } +/** Lets a test change the settings its `layer` reports. */ +export class TestSourceControlHostSettings extends Context.Service< + TestSourceControlHostSettings, + { readonly update: (patch: ServerSettingsPatch) => Effect.Effect } +>()("@t3tools/source-control-testing/TestSourceControlHost/TestSourceControlHostSettings") {} + type Git = SourceControlHost.SourceControlHost["Service"]["git"]; const unexpectedGit = (operation: string) => () => Effect.die(`Unexpected git ${operation} in a test that supplied none.`); const failingGit: Git = { + execute: unexpectedGit("execute"), + resolveCommit: unexpectedGit("resolveCommit"), remotes: unexpectedGit("remotes"), readConfigValue: unexpectedGit("readConfigValue"), resolvePrimaryRemoteName: unexpectedGit("resolvePrimaryRemoteName"), @@ -36,17 +52,25 @@ const failingGit: Git = { export const layer = ( options: TestSourceControlHostOptions = {}, -): Layer.Layer => - Layer.succeed( - SourceControlHost.SourceControlHost, - SourceControlHost.SourceControlHost.of({ - settings: { get: Effect.succeed(options.settings ?? DEFAULT_SERVER_SETTINGS) }, - process: { - run: - options.process?.run ?? - ((input) => Effect.die(`Unexpected ${input.command} run in ${input.operation}.`)), - }, - git: { ...failingGit, ...options.git }, +): Layer.Layer => + Layer.effectContext( + Effect.gen(function* () { + const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); + const host = SourceControlHost.SourceControlHost.of({ + settings: { get: Ref.get(settings) }, + process: { + run: + options.process?.run ?? + ((input) => Effect.die(`Unexpected ${input.command} run in ${input.operation}.`)), + }, + git: { ...failingGit, ...options.git }, + }); + return Context.make(SourceControlHost.SourceControlHost, host).pipe( + Context.add(TestSourceControlHostSettings, { + update: (patch) => + Ref.update(settings, (current) => applyServerSettingsPatch(current, patch)), + }), + ); }), ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ad28290d9852de72f412b8fad85fa7bac544658..cdee534c2d30e15876e88b2faf2ca3f8fc1525b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -660,6 +660,9 @@ importers: '@t3tools/source-control-forgejo': specifier: workspace:* version: link:../../packages/source-control-forgejo + '@t3tools/source-control-github': + specifier: workspace:* + version: link:../../packages/source-control-github '@t3tools/source-control-gitlab': specifier: workspace:* version: link:../../packages/source-control-gitlab @@ -1640,6 +1643,40 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/source-control-github: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + '@t3tools/source-control-core': + specifier: workspace:* + version: link:../source-control-core + effect: + specifier: 4.0.2 + version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) + devDependencies: + '@effect/platform-node': + specifier: 4.0.2 + version: 4.0.2(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.2 + version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/source-control-testing': + specifier: workspace:* + version: link:../source-control-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/source-control-gitlab: dependencies: '@t3tools/contracts': @@ -1673,6 +1710,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../contracts + '@t3tools/shared': + specifier: workspace:* + version: link:../shared '@t3tools/source-control-core': specifier: workspace:* version: link:../source-control-core diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 96b1df41e1ea8cfa972590fa4871cee13f17b318..7bc6da4817163bfabefe0f9161daebd1073d999e 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -41,6 +41,7 @@ const workspaceFiles = [ "packages/source-control-bitbucket/package.json", "packages/source-control-core/package.json", "packages/source-control-forgejo/package.json", + "packages/source-control-github/package.json", "packages/source-control-gitlab/package.json", "packages/source-control-testing/package.json", "packages/effect-acp/package.json",