diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index a8d2f9d986d1c2e470bf2fdfcde77bd30599e80e..b91196f28f84c80432304dc114daa8cababd9334 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -23,7 +23,7 @@ import { } from "../src/textGeneration/ThreadTitleContext.ts"; import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; -import * as GitHubRepositoryApi from "../src/sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; @@ -152,7 +152,7 @@ await Effect.runPromise( SourceControlProviderRegistry.layer.pipe( Layer.provide( Layer.mergeAll( - GitHubRepositoryApi.layer, + GitHubApi.layerWithDependencies, GitLabCli.layer, ForgejoCli.layer, AzureDevOpsCli.layer, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index f30af2c23c5b5554f3415f9660e68974e4a31c80..1ce25b4106be2b2e818acae07bb4a7e5e2e2dde2 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -24,6 +24,7 @@ import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/process"; import { expect } from "vite-plus/test"; import type { + ChangeRequest, GitActionProgressEvent, GitManagerServiceError, GitPreparePullRequestThreadInput, @@ -37,12 +38,11 @@ import { ProjectId, ProviderDriverKind, ProviderInstanceId, + SourceControlProviderError as SourceControlProviderFailure, TextGenerationError, ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; @@ -53,7 +53,6 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; -import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; import { ForgejoPullRequestSchema, @@ -92,7 +91,10 @@ interface FakeGhScenario { headRepositoryOwnerLogin?: string | null; }; repositoryCloneUrls?: Record; - failWith?: GitHubRepositoryApi.GitHubRepositoryApiError; + /** The detail the provider fails with, the way a GitHub failure reaches GitManager. */ + failWith?: string; + /** The raw failure behind `failWith`, which must stay out of anything shown or logged. */ + failCause?: unknown; /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */ failAfterCalls?: number; } @@ -111,9 +113,12 @@ type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"]; type FakePullRequest = NonNullable; -function normalizeFakePullRequestSummary( - raw: unknown, -): GitHubRepositoryApi.GitHubPullRequestSummary | null { +type FakePullRequestSummary = Omit & { + readonly state?: ChangeRequest["state"]; + readonly updatedAt?: string; +}; + +function normalizeFakePullRequestSummary(raw: unknown): FakePullRequestSummary | null { if (!raw || typeof raw !== "object") { return null; } @@ -383,8 +388,12 @@ function createTextGeneration( }; } -function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { - service: GitHubRepositoryApi.GitHubRepositoryApi["Service"]; +/** + * A GitHub source control provider over a fake `gh`. The fake still speaks gh's command shapes, + * which is what `ghCalls` records and the tests assert on; the provider methods translate to them. + */ +function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): { + service: SourceControlProvider["Service"]; ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; @@ -396,16 +405,20 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { ); const ghCalls: string[] = []; - // The fake still speaks in gh's command shapes; the service methods below translate to them. + const fail = (cwd: string, detail: string, cause?: unknown, operation = "fakeGh") => + new SourceControlProviderFailure({ provider: "github", operation, cwd, detail, cause }); + + /** `operation` is the provider method the call answers, which a failure reports. */ const execute = (input: { readonly cwd: string; readonly args: ReadonlyArray; - }): Effect.Effect => { + readonly operation: string; + }): Effect.Effect => { const args = [...input.args]; ghCalls.push(args.join(" ")); if (scenario.failWith && ghCalls.length > (scenario.failAfterCalls ?? 0)) { - return Effect.fail(scenario.failWith); + return Effect.fail(fail(input.cwd, scenario.failWith, scenario.failCause, input.operation)); } if (args[0] === "pr" && args[1] === "list") { @@ -487,13 +500,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { } return fakeGhOutput(""); }, - catch: (error) => - GitHubRepositoryApi.isGitHubRepositoryApiError(error) - ? error - : new GitHubRepositoryApi.GitHubRepositoryCommandError({ - cwd: input.cwd, - cause: error, - }), + catch: (error) => fail(input.cwd, "GitHub request failed.", error), }); } @@ -502,12 +509,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) { const cloneUrls = scenario.repositoryCloneUrls?.[repository]; if (!cloneUrls) { - return Effect.fail( - new GitHubRepositoryApi.GitHubRepositoryCommandError({ - cwd: input.cwd, - cause: new Error(`Unexpected repository lookup: ${repository}`), - }), - ); + return Effect.fail(fail(input.cwd, `Unexpected repository lookup: ${repository}`)); } return Effect.succeed( fakeGhOutput( @@ -522,73 +524,86 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { return Effect.succeed(fakeGhOutput(`${scenario.defaultBranch ?? "main"}\n`)); } - return Effect.fail( - new GitHubRepositoryApi.GitHubRepositoryCommandError({ - cwd: input.cwd, - cause: new Error(`Unexpected gh command: ${args.join(" ")}`), - }), - ); + return Effect.fail(fail(input.cwd, `Unexpected gh command: ${args.join(" ")}`)); }; + /** What GitHubSourceControlProvider makes of a pull request it read. */ + const toChangeRequest = (summary: FakePullRequestSummary): ChangeRequest => ({ + ...summary, + provider: "github", + state: summary.state ?? "open", + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, + updatedAt: + summary.updatedAt === undefined + ? Option.none() + : Option.some(DateTime.makeUnsafe(summary.updatedAt)), + }); + return { service: { - // The fake answers the CLI shape, so batched lookups read it the way the fallback does. - listPullRequestsByHead: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - "--head", - input.headSelector, - "--state", - input.state, - "--limit", - String(input.limit), - "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }).pipe( - Effect.map((result) => { - const raw = result.stdout.trim(); - if (raw.length === 0) return []; - const decoded = decodeGitHubPullRequestListJson(raw); - return Result.isSuccess(decoded) ? decoded.success : []; - }), - ), - listOpenPullRequests: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - "--head", - input.headSelector, - "--state", - "open", - "--limit", - String(input.limit ?? 1), - "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }).pipe( - Effect.map((result) => JSON.parse(result.stdout) as unknown[]), - Effect.map((raw) => - raw - .map((entry) => normalizeFakePullRequestSummary(entry)) - .filter( - (entry): entry is GitHubRepositoryApi.GitHubPullRequestSummary => entry !== null, + kind: "github", + listChangeRequests: (input) => + input.state === "open" + ? execute({ + operation: "listChangeRequests", + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + "open", + "--limit", + String(input.limit ?? 1), + "--json", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe( + Effect.map((result) => JSON.parse(result.stdout) as unknown[]), + Effect.map((raw) => + raw + .map((entry) => normalizeFakePullRequestSummary(entry)) + .filter((entry): entry is FakePullRequestSummary => entry !== null) + .map(toChangeRequest), ), - ), - ), - createPullRequest: (input) => + ) + : // The fake answers the CLI shape, so batched lookups read it the way the fallback does. + execute({ + operation: "listChangeRequests", + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + input.state, + "--limit", + String(input.limit ?? 20), + "--json", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe( + Effect.map((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) return []; + const decoded = decodeGitHubPullRequestListJson(raw); + return Result.isSuccess(decoded) + ? decoded.success.map((record) => ({ provider: "github" as const, ...record })) + : []; + }), + ), + createChangeRequest: (input) => execute({ + operation: "createChangeRequest", cwd: input.cwd, args: [ "pr", "create", "--base", - input.baseBranch, + input.baseRefName, "--head", input.headSelector, "--title", @@ -599,6 +614,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { }).pipe(Effect.asVoid), getDefaultBranch: (input) => execute({ + operation: "getDefaultBranch", cwd: input.cwd, args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], }).pipe( @@ -607,8 +623,9 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { return value.length > 0 ? value : null; }), ), - getPullRequest: (input) => + getChangeRequest: (input) => execute({ + operation: "getChangeRequest", cwd: input.cwd, args: [ "pr", @@ -618,24 +635,21 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( - Effect.map( - (result) => JSON.parse(result.stdout) as GitHubRepositoryApi.GitHubPullRequestSummary, + Effect.map((result) => + toChangeRequest(JSON.parse(result.stdout) as FakePullRequestSummary), ), ), getRepositoryCloneUrls: (input) => execute({ + operation: "getRepositoryCloneUrls", cwd: input.cwd, args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], }).pipe(Effect.map((result) => JSON.parse(result.stdout))), createRepository: (input) => - Effect.fail( - new GitHubRepositoryApi.GitHubRepositoryCommandError({ - cwd: input.cwd, - cause: new Error(`Unexpected repository create: ${input.repository}`), - }), - ), - checkoutPullRequest: (input) => + Effect.fail(fail(input.cwd, `Unexpected repository create: ${input.repository}`)), + checkoutChangeRequest: (input) => execute({ + operation: "checkoutChangeRequest", cwd: input.cwd, args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], }).pipe(Effect.asVoid), @@ -693,7 +707,7 @@ function makeManager(input?: { ProjectionStore.ProjectionStoreV2 | ProjectStore.ProjectStoreV2 >; }) { - const { service: gitHubRepositoryApi, ghCalls } = createGitHubRepositoryApiWithFakeGh( + const { service: fakeGitHubProvider, ghCalls } = createGitHubProviderWithFakeGh( input?.ghScenario, ); const textGeneration = createTextGeneration(input?.textGeneration); @@ -729,10 +743,7 @@ function makeManager(input?: { ); const layerSourceControlRegistry = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, - (input?.sourceControlProvider === undefined - ? GitHubSourceControlProvider.make - : Effect.succeed(input.sourceControlProvider) - ).pipe( + Effect.succeed(input?.sourceControlProvider ?? fakeGitHubProvider).pipe( Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ resolveLink: (input) => provider.resolveLink?.(input), @@ -742,12 +753,6 @@ function makeManager(input?: { discover: Effect.succeed([]), }), ), - Effect.provide( - Layer.merge( - Layer.succeed(GitHubRepositoryApi.GitHubRepositoryApi, gitHubRepositoryApi), - Layer.mock(GitHubApi.GitHubApi)({}), - ), - ), ), ); @@ -1300,10 +1305,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", }, }); yield* manager.remoteStatus({ cwd: repoDir }); @@ -1782,10 +1785,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("gh is not available on PATH"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", }, }); @@ -2642,10 +2643,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }, ]), }, - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2718,10 +2717,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("gh is not available on PATH"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", }, }); @@ -2743,10 +2740,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value"; const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryRateLimitError({ - cwd: repoDir, - cause: new Error(upstreamCause), - }), + failWith: "GitHub API rate limit exceeded. Requests resume when the limit resets.", + failCause: new Error(upstreamCause), }, }); const logs: Array<{ message: string; annotations: Record }> = []; @@ -2799,10 +2794,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2841,10 +2834,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2888,10 +2879,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2926,10 +2915,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2967,10 +2954,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -4611,10 +4596,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("gh is not available on PATH"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", }, }); @@ -4640,10 +4623,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryAuthenticationError({ - cwd: repoDir, - cause: new Error("gh is not authenticated"), - }), + failWith: "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.", }, }); diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index c1e0cff2658f615e792f6707ae24b69521d26176..cd88d939777688e4c8f18b9ec4e15fcad8ff2616 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -345,7 +345,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), ), ), - // Wide enough that a sweep's GitHub branch lookups reach GitHubRepositoryApi together and share one + // Wide enough that a sweep's GitHub branch lookups reach the GitHub provider together and share one // GraphQL document, instead of one `gh pr list` per branch. { concurrency: 32, discard: true }, ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts index 64259e337e922dd71a4eb56e2008f0371572f415..20fc1dd88526677c4d8b13fdf4eb5927fac625a2 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts @@ -12,7 +12,7 @@ import * as Redacted from "effect/Redacted"; import { HttpClient, HttpClientResponse } from "effect/http"; -import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.ts index 59281964df4a6097b226e0dcafd775e6cc76e199..5e35729154604a3cfe5efd1f6865a1179ac2bd45 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.ts @@ -42,7 +42,6 @@ import { type PullRequestPreview, } from "@t3tools/contracts"; -import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import { readGraphQlPages } from "../sourceControl/githubGraphQl.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -1524,7 +1523,7 @@ export const make = Effect.gen(function* () { const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { const { owner, name } = parseRepositorySelector(input.repository); - return AllowGitHubReserve.pipe( + return GitHubApi.AllowGitHubReserve.pipe( Effect.flatMap((allowReserve) => graphqlRead({ allowReserve, diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index f00cbfaf5d3246a4aa9598c7508560f385a9415e..df4f7da4cd2cf0767fad80c76550d432ef7055f6 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -7,7 +7,6 @@ import type { PullRequestReaction } from "@t3tools/contracts"; import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts"; import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; @@ -726,7 +725,7 @@ describe("getViewerPermissions", () => { Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => - GitHubRepositoryApi.AllowGitHubReserve.pipe( + GitHubApi.AllowGitHubReserve.pipe( Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))), Effect.flatMap(() => detail), ), diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 5e6f2420729949a87dd1e1762659c6c6232708d5..f2672dbe991fde29f668c872b4a9487523104601 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -7,7 +7,7 @@ import type { PullRequestViewerPermissions, } from "@t3tools/contracts"; -import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import { PullRequestProviderError, @@ -526,7 +526,7 @@ export const make = Effect.gen(function* () { // comparison, so one read usually answers what used to take three. When that heavier read // fails, the light access read still answers, withholding only update-branch. return cli.getPullRequestDetail(input).pipe( - Effect.provideService(GitHubRepositoryApi.AllowGitHubReserve, true), + Effect.provideService(GitHubApi.AllowGitHubReserve, true), Effect.map((pullRequest) => gitHubViewerPermissions({ ...pullRequest.viewerAccess, diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index efbe37f991eeecb69fbe623694739c05f5d6ca11..316cd1e7ae92f37d90f5153950dae544a8df8a18 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -6,9 +6,6 @@ import type { SourceControlProviderKind } from "@t3tools/contracts"; import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; -import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; @@ -63,12 +60,8 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( Layer.provide( GitHubPullRequestApi.layer.pipe( Layer.provide( - GitHubApi.layer.pipe( - Layer.provide(GitHubCredentials.layer), - // The same layers GitHubRepositoryApi merges, so both share one budget and one pause per host. - Layer.provide(GitHubGraphQlBudget.layer), - Layer.provide(SourceControlRateLimit.layer), - ), + // Built here too, so a registry built alone still has one budget and pause per host. + GitHubApi.layerWithDependencies, ), ), ), diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 9e2b81d97d5f5545e4755c73bb665541b35831d3..a44bd322adcba8db5f9dc103d380ac3c121a2869 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/GitHubRepositoryApi.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index fdd7d6c7165f4e16ed52ec8e9add1d2694ecb0a0..192b17204aea8a3bc22eaa454e5dc3714973e2fd 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -50,7 +50,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubApi from "./sourceControl/GitHubApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; @@ -270,7 +270,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, - GitHubRepositoryApi.layer, + GitHubApi.layerWithDependencies, GitLabCli.layer, ForgejoCli.layer, ), @@ -565,7 +565,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(layerServerSettings), // The asset route uses the registry's GitHub credential for private PR media. Layer.provideMerge(layerSourceControlProviderRegistry), - Layer.provideMerge(GitHubRepositoryApi.layer), + 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.ts b/apps/server/src/sourceControl/GitHubApi.ts index 01aa1c9ccfcfdfe1336782895f73f6c00d5a2261..0da53e0230b1709ef363c4518035ab4d3c1f8c4c 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -598,3 +598,13 @@ export const make = Effect.gen(function* () { }); export const layer = Layer.effect(GitHubApi, make); + +/** + * The transport with the credential, GraphQL budget and rate-limit pause it reads through. An + * application builds this once so every GitHub reader shares one budget and one pause per host. + */ +export const layerWithDependencies = layer.pipe( + Layer.provideMerge(GitHubCredentials.layer), + Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(SourceControlRateLimit.layer), +); diff --git a/apps/server/src/sourceControl/GitHubCredentials.ts b/apps/server/src/sourceControl/GitHubCredentials.ts index fb043d809c6846ada3409d045dd8ee43d656bea6..d47d57ea7ffef02b16b2b7ba8204a73cea065f53 100644 --- a/apps/server/src/sourceControl/GitHubCredentials.ts +++ b/apps/server/src/sourceControl/GitHubCredentials.ts @@ -81,10 +81,6 @@ export type GitHubCredentialUnavailableError = | GitHubHostDisabledError | GitHubCliFailedError; -export const isGitHubCredentialUnavailableError = Schema.is( - Schema.Union([GitHubCliMissingError, GitHubNotSignedInError, GitHubHostDisabledError]), -); - /** * 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. diff --git a/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts b/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts deleted file mode 100644 index 688216211f1fd15e22c2466fdcd13a3c27f5755f..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts +++ /dev/null @@ -1,651 +0,0 @@ -import { assert, it, describe } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as TestClock from "effect/testing/TestClock"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import { ChildProcessSpawner } from "effect/process"; - -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; -import * as GitHubCredentials from "./GitHubCredentials.ts"; - -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); - -const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({ - exitCode: ChildProcessSpawner.ExitCode(exitCode), - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, -}); - -const remotesOutput = (...entries: ReadonlyArray) => - entries - .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) - .join("\n"); - -const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({ - status, - headers: {}, - body: body === undefined ? "" : encodeJson(body), - truncated: false, - invalidUtf8: false, -}); - -const node = (number: number, headRefName: string, owner = "acme") => ({ - number, - title: `PR ${number}`, - url: `https://github.com/acme/web/pull/${number}`, - baseRefName: "main", - headRefName, - state: "OPEN", - isCrossRepository: owner !== "acme", - updatedAt: "2026-01-02T00:00:00Z", - headRepository: { name: "web", nameWithOwner: `${owner}/web` }, - headRepositoryOwner: { login: owner }, -}); - -/** - * A GitHubRepositoryApi over a mocked GitHubApi, git driver and process. `remotes` is what - * `git remote -v` prints; `git` records every driver call. - */ -function harness(input: { - readonly remotes: string; - readonly api: Partial; - readonly localBranches?: ReadonlyArray; -}) { - const git: Array = []; - const record = - (name: string, value: A) => - (args: unknown) => - Effect.sync(() => { - git.push([name, args]); - return value; - }); - const driver = Layer.mock(GitVcsDriver.GitVcsDriver)({ - execute: (args) => - Effect.sync(() => { - git.push(["execute", args.args]); - return processOutput(""); - }), - resolvePrimaryRemoteName: () => Effect.succeed("origin"), - readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), - ensureRemote: (args) => - Effect.sync(() => { - git.push(["ensureRemote", args]); - return args.preferredName; - }), - fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args), - setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args), - switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never, - listLocalBranchNames: () => Effect.succeed([...(input.localBranches ?? [])]), - resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), - }); - const process = Layer.mock(VcsProcess.VcsProcess)({ - run: (args) => - Effect.succeed( - args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), - ), - }); - const layer = Layer.effect( - GitHubRepositoryApi.GitHubRepositoryApi, - GitHubRepositoryApi.make, - ).pipe( - Layer.provide( - Layer.mergeAll( - driver, - process, - Layer.mock(GitHubApi.GitHubApi)(input.api), - NodeServices.layer, - ), - ), - ); - return { layer, git }; -} - -describe("selectGitHubBaseRepository", () => { - const remotes = (...entries: ReadonlyArray) => - entries - .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) - .join("\n"); - const select = (input: { remotes: string; resolved?: string }) => - GitHubRepositoryApi.selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); - - it("picks the repository gh reads without a prompt", () => { - assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { - owner: "me", - name: "web", - }); - const fork = remotes( - ["origin", "git@github.com:me/web.git"], - ["upstream", "https://github.com/Acme/Web.git"], - ); - assert.deepStrictEqual(select({ remotes: fork }), { owner: "acme", name: "web" }); - assert.deepStrictEqual(select({ remotes: fork, resolved: "remote.origin.gh-resolved base" }), { - owner: "me", - name: "web", - }); - assert.deepStrictEqual( - select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), - { owner: "acme", name: "other" }, - ); - // gh ranks remote names in any case. - assert.deepStrictEqual( - select({ - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["Upstream", "git@github.com:acme/web.git"], - ), - }), - { owner: "acme", name: "web" }, - ); - }); - - it("leaves gh to choose when it might weigh the remotes differently", () => { - for (const input of [ - { remotes: "" }, - { - remotes: remotes(["a", "git@github.com:me/web.git"], ["b", "git@github.com:acme/web.git"]), - }, - { - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["mirror", "git@gitlab.com:me/web.git"], - ), - }, - { remotes: remotes(["origin", "git@github-work:me/web.git"]) }, - { - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["Origin", "git@github.com:acme/web.git"], - ), - }, - { - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["upstream", "git@github.com:acme/web.git"], - ), - resolved: "remote.origin.gh-resolved base\nremote.upstream.gh-resolved base", - }, - ]) { - assert.strictEqual(select(input), null); - } - }); -}); - -describe("GitHubRepositoryApi repository resolution", () => { - it.effect("reads the repository gh would pick from the remotes", () => { - const paths: string[] = []; - const { layer } = harness({ - remotes: remotesOutput( - ["origin", "git@github.com:me/web.git"], - ["upstream", "https://github.com/acme/web.git"], - ), - api: { - rest: (input) => - Effect.sync(() => { - paths.push(`${input.host} ${input.path}`); - return restResponse({ - full_name: "acme/web", - html_url: "https://github.com/acme/web", - ssh_url: "git@github.com:acme/web.git", - default_branch: "trunk", - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); - assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("reads an SSH alias remote through github.com", () => { - const hosts: string[] = []; - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@github:acme/web.git"]), - api: { - rest: (input) => - Effect.sync(() => { - hosts.push(`${input.host} ${input.path}`); - return restResponse({ - full_name: "acme/web", - html_url: "https://github.com/acme/web", - ssh_url: "git@github.com:acme/web.git", - default_branch: "main", - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* gh.getDefaultBranch({ cwd: "/repo" }); - // A provider's host hint for the same alias (`github` here) resolves the same way. - yield* gh.getDefaultBranch({ cwd: "/repo", rateLimitHost: "github" }); - assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); - assert.strictEqual( - GitHubRepositoryApi.gitHubApiHostForRemote("git@github.example.com:a/b.git"), - "github.example.com", - ); - assert.strictEqual( - GitHubRepositoryApi.gitHubApiHostForRemote("git@gitlab.com:a/b.git"), - null, - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails clearly when no remote is on GitHub", () => { - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]), - api: {}, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); - assert.strictEqual(error._tag, "GitHubRepositoryCommandError"); - assert.include(String((error.cause as Error).message), "No GitHub repository"); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubRepositoryApi.listPullRequestsByHead", () => { - const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); - - it.effect("reads a background sweep's staggered heads in one GraphQL document", () => { - const documents: Array = []; - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.sync(() => { - documents.push(input); - return encodeJson({ - data: { - repository: { - h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] }, - h1: { nodes: [] }, - }, - }, - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const lookup = (headSelector: string) => - gh - .listPullRequestsByHead({ - cwd: "/repo", - headSelector, - state: "all", - limit: 100, - rateLimitHost: "github.com", - }) - .pipe(Effect.forkChild); - // Each branch's own git reads come first, so a sweep's lookups arrive spread out. - const firstLookup = yield* lookup("feature/a"); - yield* TestClock.adjust("200 millis"); - const secondLookup = yield* lookup("feature/b"); - yield* TestClock.adjust("300 millis"); - const first = yield* Fiber.join(firstLookup); - const second = yield* Fiber.join(secondLookup); - assert.deepStrictEqual( - first?.map((pr) => pr.number), - [7], - ); - assert.strictEqual(first?.[0]?.headSha, "a".repeat(40)); - assert.deepStrictEqual(second, []); - assert.strictEqual(documents.length, 1); - assert.include(documents[0]!.query, "headRefOid"); - assert.deepStrictEqual(documents[0]!.variables, { - owner: "acme", - name: "web", - h0: "feature/a", - s0: ["OPEN", "CLOSED", "MERGED"], - h1: "feature/b", - s1: ["OPEN", "CLOSED", "MERGED"], - }); - }).pipe(Effect.provide(layer)); - }); - - it.effect("caps a background document at twenty-five heads", () => { - const headCounts: Array = []; - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.sync(() => { - const heads = Object.keys(input.variables ?? {}).filter((key) => /^h\d+$/.test(key)); - headCounts.push(heads.length); - return encodeJson({ - data: { repository: Object.fromEntries(heads.map((key) => [key, { nodes: [] }])) }, - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const lookups = yield* Effect.all( - Array.from({ length: 26 }, (_, index) => - gh.listPullRequestsByHead({ - cwd: "/repo", - headSelector: `feature/${index}`, - state: "all", - limit: 100, - rateLimitHost: "github.com", - }), - ), - { concurrency: "unbounded" }, - ).pipe(Effect.forkChild); - yield* TestClock.adjust("500 millis"); - yield* Fiber.join(lookups); - assert.deepStrictEqual( - headCounts.toSorted((a, b) => a - b), - [1, 25], - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect("matches an owner:branch selector on the head owner", () => { - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.succeed( - encodeJson({ - data: { - repository: { - h0: { - nodes: - input.variables?.h0 === "main" - ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")] - : [], - }, - }, - }, - }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const open = yield* gh - .listOpenPullRequests({ cwd: "/repo", headSelector: "me:main", limit: 1 }) - .pipe(Effect.forkChild); - yield* TestClock.adjust("50 millis"); - assert.deepStrictEqual( - (yield* Fiber.join(open)).map((pr) => pr.number), - [8], - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect("maps API failures onto the errors callers handle", () => { - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.fail( - input.variables?.h0 === "missing" - ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" }) - : new GitHubApi.GitHubApiRateLimitError({ - host: "github.com", - operation: "x", - retryAt: 123, - }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const read = (headSelector: string) => - gh - .listPullRequestsByHead({ cwd: "/repo", headSelector, state: "open", limit: 1 }) - .pipe(Effect.flip, Effect.forkChild); - const missing = yield* read("missing"); - yield* TestClock.adjust("500 millis"); - assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubRepositoryUnavailableError"); - const limited = yield* read("limited"); - yield* TestClock.adjust("500 millis"); - const error = yield* Fiber.join(limited); - assert.strictEqual(error._tag, "GitHubRepositoryRateLimitError"); - assert.propertyVal(error, "retryAt", 123); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubRepositoryApi.getPullRequest", () => { - it.effect("reads a pull request by number, and by URL on its own repository", () => { - const variables: Array = []; - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - api: { - graphql: (input) => - Effect.sync(() => { - variables.push(input.variables); - return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - assert.strictEqual((yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" })).number, 42); - yield* gh.getPullRequest({ - cwd: "/repo", - reference: "https://github.com/other/thing/pull/42", - }); - assert.deepStrictEqual(variables, [ - { owner: "acme", name: "web", number: 42 }, - { owner: "other", name: "thing", number: 42 }, - ]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails a missing pull request as not found", () => { - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - api: { - graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const error = yield* gh.getPullRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); - assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubRepositoryApi writes", () => { - it.effect("creates a cross-repository pull request with an owner:branch head", () => { - const requests: Array = []; - const { layer } = harness({ - remotes: remotesOutput( - ["origin", "git@github.com:me/web.git"], - ["upstream", "git@github.com:acme/web.git"], - ), - api: { - rest: (input) => - Effect.sync(() => { - requests.push(input); - return restResponse({ number: 1 }, 201); - }), - }, - }); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); - yield* fs.writeFileString(bodyFile, "Body"); - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* gh.createPullRequest({ - cwd: "/repo", - baseBranch: "main", - headSelector: "me:feature", - title: "Title", - bodyFile, - }); - assert.strictEqual(requests[0]!.method, "POST"); - assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); - assert.deepStrictEqual(requests[0]!.body, { - base: "main", - head: "me:feature", - title: "Title", - body: "Body", - maintainer_can_modify: true, - }); - }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped); - }); - - it.effect("creates a repository under an organization the viewer is not", () => { - const requests: Array = []; - const { layer } = harness({ - remotes: "", - api: { - rest: (input) => - Effect.sync(() => { - requests.push(`${input.method ?? "GET"} ${input.path}`); - return input.path === "user" - ? restResponse({ login: "me" }) - : restResponse({ - full_name: "acme/new", - html_url: "https://github.com/acme/new", - ssh_url: "git@github.com:acme/new.git", - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const urls = yield* gh.createRepository({ - cwd: "/repo", - repository: "acme/new", - visibility: "private", - }); - assert.deepStrictEqual(urls, { - nameWithOwner: "acme/new", - url: "https://github.com/acme/new", - sshUrl: "git@github.com:acme/new.git", - }); - assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubRepositoryApi.checkoutPullRequest", () => { - const repository = (fullName: string, defaultBranch = "main") => - restResponse({ - full_name: fullName, - html_url: `https://github.com/${fullName}`, - ssh_url: `git@github.com:${fullName}.git`, - default_branch: defaultBranch, - }); - - it.effect("checks a same-repository pull request out from its head branch", () => { - const { layer, git } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - api: { - graphql: () => - Effect.succeed( - encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "5" }); - assert.deepStrictEqual(git, [ - [ - "fetchRemoteTrackingBranch", - { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" }, - ], - ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]], - ["switchRef", { cwd: "/repo", refName: "feature/x" }], - [ - "setBranchUpstream", - { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" }, - ], - ]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("refuses a fork checkout when the base's default branch cannot be read", () => { - const { layer, git } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - localBranches: ["main"], - api: { - graphql: () => - Effect.succeed( - encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), - ), - rest: () => - Effect.fail( - new GitHubApi.GitHubApiRequestError({ - host: "github.com", - operation: "x", - cause: "offline", - }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* Effect.flip(gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true })); - // Nothing touched the local branches: `main` must not be reset to the fork's commit. - assert.deepStrictEqual(git, []); - }).pipe(Effect.provide(layer)); - }); - - it.effect("adds a remote for a fork and names a default-branch head after its owner", () => { - const { layer, git } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - localBranches: ["someone/main"], - api: { - graphql: () => - Effect.succeed( - encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), - ), - rest: (input) => - Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true }); - assert.deepStrictEqual(git, [ - [ - "ensureRemote", - { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" }, - ], - [ - "fetchRemoteTrackingBranch", - { cwd: "/repo", remoteName: "someone", remoteBranch: "main" }, - ], - ["switchRef", { cwd: "/repo", refName: "someone/main" }], - ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]], - [ - "setBranchUpstream", - { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" }, - ], - ]); - }).pipe(Effect.provide(layer)); - }); - - it("names the local branch the way gh pr checkout does", () => { - const name = (headRefName: string, isCrossRepository: boolean) => - GitHubRepositoryApi.pullRequestCheckoutBranchName({ - headRefName, - headOwner: "someone", - isCrossRepository, - defaultBranch: "main", - }); - assert.strictEqual(name("main", true), "someone/main"); - assert.strictEqual(name("feature", true), "feature"); - assert.strictEqual(name("main", false), "main"); - }); -}); diff --git a/apps/server/src/sourceControl/GitHubRepositoryApi.ts b/apps/server/src/sourceControl/GitHubRepositoryApi.ts deleted file mode 100644 index 595d869d72275071fe7078ea1193387b33941007..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/GitHubRepositoryApi.ts +++ /dev/null @@ -1,1086 +0,0 @@ -import * as Context from "effect/Context"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Request from "effect/Request"; -import * as RequestResolver from "effect/RequestResolver"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; - -import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; -import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -import { - detectSourceControlProviderFromRemoteUrl, - isSshRemoteUrl, -} from "@t3tools/shared/sourceControl"; - -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; - -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCredentials from "./GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; -import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; -import { - decodeGitHubPullRequestEntries, - type NormalizedGitHubPullRequestRecord, -} from "./gitHubPullRequests.ts"; - -export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve; - -const gitHubFailureFields = { - cwd: Schema.String, - cause: Schema.Defect(), -} as const; - -export class GitHubRepositoryUnavailableError extends Schema.TaggedError()( - "GitHubRepositoryUnavailableError", - gitHubFailureFields, -) { - override get message(): string { - return "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`."; - } -} - -export class GitHubRepositoryAuthenticationError extends Schema.TaggedError()( - "GitHubRepositoryAuthenticationError", - gitHubFailureFields, -) { - override get message(): string { - // A missing or turned-off credential already says what to do about it. - return GitHubCredentials.isGitHubCredentialUnavailableError(this.cause) - ? this.cause.message - : "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry."; - } -} - -export class GitHubRepositoryRateLimitError extends Schema.TaggedError()( - "GitHubRepositoryRateLimitError", - { ...gitHubFailureFields, retryAt: Schema.optionalKey(Schema.Finite) }, -) { - override get message(): string { - return "GitHub API rate limit exceeded. Requests resume when the limit resets."; - } -} - -export class GitHubPullRequestNotFoundError extends Schema.TaggedError()( - "GitHubPullRequestNotFoundError", - gitHubFailureFields, -) { - override get message(): string { - return "Pull request not found. Check the PR number or URL and try again."; - } -} - -export class GitHubRepositoryCommandError extends Schema.TaggedError()( - "GitHubRepositoryCommandError", - { ...gitHubFailureFields, httpStatus: Schema.optional(Schema.Int) }, -) { - override get message(): string { - // GitHub's own reason ("A pull request already exists…") or the failed step's, when known. - const reason = - this.cause instanceof Error && this.cause.message.trim() !== "" - ? this.cause.message.trim() - : null; - return reason === null ? "GitHub request failed." : reason; - } -} - -const gitHubDecodeFields = { - cwd: Schema.String, - cause: Schema.Defect(), -} as const; - -export class GitHubPullRequestListDecodeError extends Schema.TaggedError()( - "GitHubPullRequestListDecodeError", - gitHubDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid pull request list."; - } -} - -export class GitHubChangeRequestListDecodeError extends Schema.TaggedError()( - "GitHubChangeRequestListDecodeError", - gitHubDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid change request list."; - } -} - -export class GitHubPullRequestDecodeError extends Schema.TaggedError()( - "GitHubPullRequestDecodeError", - gitHubDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid pull request."; - } -} - -export class GitHubRepositoryDecodeError extends Schema.TaggedError()( - "GitHubRepositoryDecodeError", - gitHubDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid repository."; - } -} - -export const GitHubRepositoryApiError = Schema.Union([ - GitHubRepositoryUnavailableError, - GitHubRepositoryAuthenticationError, - GitHubRepositoryRateLimitError, - GitHubPullRequestNotFoundError, - GitHubRepositoryCommandError, - GitHubPullRequestListDecodeError, - GitHubChangeRequestListDecodeError, - GitHubPullRequestDecodeError, - GitHubRepositoryDecodeError, -]); -export type GitHubRepositoryApiError = typeof GitHubRepositoryApiError.Type; - -export const isGitHubRepositoryApiError = Schema.is(GitHubRepositoryApiError); - -/** Maps a GitHub API failure onto the errors callers of this service already handle. */ -function fromGitHubApiError( - cwd: string, - error: GitHubApi.GitHubApiError, -): GitHubRepositoryApiError { - const context = { cwd, cause: error }; - switch (error._tag) { - case "GitHubCliMissingError": - return new GitHubRepositoryUnavailableError(context); - case "GitHubNotSignedInError": - case "GitHubHostDisabledError": - case "GitHubApiAuthenticationError": - return new GitHubRepositoryAuthenticationError(context); - case "GitHubCliFailedError": - return new GitHubRepositoryCommandError(context); - case "GitHubApiRateLimitError": - return new GitHubRepositoryRateLimitError({ - ...context, - ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), - }); - case "SourceControlRateLimitPausedError": - return new GitHubRepositoryRateLimitError({ ...context, retryAt: error.retryAt }); - case "GitHubApiNotFoundError": - return new GitHubPullRequestNotFoundError(context); - case "GitHubApiResponseError": - return new GitHubRepositoryCommandError({ ...context, httpStatus: error.status }); - case "GitHubApiRequestError": - return new GitHubRepositoryCommandError(context); - } -} - -export interface GitHubPullRequestSummary { - readonly number: number; - readonly title: string; - readonly url: string; - readonly baseRefName: string; - readonly headRefName: string; - readonly headSha?: string; - readonly state?: "open" | "closed" | "merged"; - readonly isDraft?: boolean; - readonly closedAt?: string | null; - readonly mergedAt?: string | null; - readonly updatedAt?: string; - readonly isCrossRepository?: boolean; - readonly headRepositoryNameWithOwner?: string | null; - readonly headRepositoryOwnerLogin?: string | null; -} - -function pullRequestSummary(input: NormalizedGitHubPullRequestRecord): GitHubPullRequestSummary { - const { updatedAt, ...summary } = input; - return { - ...summary, - ...(Option.isSome(updatedAt) ? { updatedAt: DateTime.formatIso(updatedAt.value) } : {}), - }; -} - -export interface GitHubRepositoryCloneUrls { - readonly nameWithOwner: string; - readonly url: string; - readonly sshUrl: string; -} - -export class GitHubRepositoryApi extends Context.Service< - GitHubRepositoryApi, - { - readonly listOpenPullRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly limit?: number; - readonly rateLimitHost?: string; - }) => Effect.Effect, GitHubRepositoryApiError>; - - /** - * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the - * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive - * together share one GraphQL document. - */ - readonly listPullRequestsByHead: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit: number; - /** The checkout's GitHub API host. Without it, the host comes from the git remotes. */ - readonly rateLimitHost?: string; - }) => Effect.Effect, GitHubRepositoryApiError>; - - readonly getPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly rateLimitHost?: string; - }) => Effect.Effect; - - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - readonly getDefaultBranch: (input: { - readonly cwd: string; - readonly rateLimitHost?: string; - }) => Effect.Effect; - - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; - } ->()("t3/sourceControl/GitHubRepositoryApi") {} - -/** - * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a - * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin - * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of - * `git config --get-regexp '^remote\..*\.gh-resolved$'`. - * - * Null whenever gh might weigh the remotes differently: a remote on another host or under an - * SSH alias, more than one mark, or several remotes with none of those names. Callers then - * fall back to the provider's remote, then to the best-ranked remote on the host. - */ -export function selectGitHubBaseRepository(input: { - readonly remotes: string; - readonly resolved: string; - readonly host: string; -}): { readonly owner: string; readonly name: string } | null { - const host = input.host.toLowerCase(); - const repositories = new Map(); - for (const line of input.remotes.split("\n")) { - const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()); - if (!match) continue; - const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/"); - if (remoteHost !== host || !owner || !name || rest.length > 0) return null; - repositories.set(match[1]!, { owner, name }); - } - const marks = input.resolved - .split("\n") - .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) - .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); - if (marks.length > 1) return null; - const [mark] = marks; - if (mark) { - if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; - const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); - return owner && name && rest.length === 0 ? { owner, name } : null; - } - // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the - // top place has no defined winner. - const score = (remoteName: string) => - ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; - const ranked = [...repositories.entries()].toSorted( - ([left], [right]) => score(right) - score(left), - ); - const [top, next] = ranked; - return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) - ? top[1] - : null; -} - -const RawRepositorySchema = Schema.Struct({ - full_name: TrimmedNonEmptyString, - html_url: TrimmedNonEmptyString, - ssh_url: TrimmedNonEmptyString, - default_branch: Schema.optional(Schema.NullOr(Schema.String)), -}); -const decodeRawRepository = decodeJsonResult(RawRepositorySchema); - -function repositoryCloneUrls( - raw: Schema.Schema.Type, -): GitHubRepositoryCloneUrls { - return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url }; -} - -const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString })); - -type PullRequestListState = "open" | "closed" | "merged" | "all"; - -/** The pull request fields every read selects, in GraphQL. */ -const PULL_REQUEST_NODE_SELECTION = - "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; -const GRAPHQL_STATES: Record> = { - open: ["OPEN"], - closed: ["CLOSED"], - merged: ["MERGED"], - all: ["OPEN", "CLOSED", "MERGED"], -}; -/** - * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one - * single lookup, but half that keeps each answer near half a second. - */ -const HEAD_LOOKUPS_PER_DOCUMENT = 50; -/** - * How long a head lookup waits for company. Branch discovery reaches GitHub only after each - * branch's own git reads, so lookups started together arrive tens of milliseconds apart. - * A background sweep's lookups spread over up to ~300ms, and every document costs a point no - * matter how few heads it holds, so reads without reserve wait longer. - */ -const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; -const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; -/** - * Background documents fill up under the longer window, and a failed document fails every head - * in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own - * processing limit; twenty-five took ~7s. - */ -const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25; -/** A full document is 5,000 rows of well under 2 KB each. */ -const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; -/** - * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch - * named like a busy default (`main`) is the case this bounds; the owner's own row is near the - * top because the newest come first. - */ -const OWNER_HEAD_SCAN_LIMIT = 100; - -class PullRequestsByHeadRead extends Request.Class< - { - readonly cwd: string; - readonly host: string; - readonly owner: string; - readonly name: string; - readonly headRefName: string; - readonly state: PullRequestListState; - readonly limit: number; - readonly allowReserve: boolean; - }, - ReadonlyArray, - GitHubRepositoryApiError -> {} - -/** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ -function buildPullRequestsByHeadQuery( - lookups: ReadonlyArray>, -): { readonly document: string; readonly variables: Record } { - const variables: Record = {}; - const declarations: string[] = ["$owner: String!", "$name: String!"]; - const selections: string[] = []; - for (const [index, lookup] of lookups.entries()) { - variables[`h${index}`] = lookup.headRefName; - variables[`s${index}`] = GRAPHQL_STATES[lookup.state]; - declarations.push(`$h${index}: String!`, `$s${index}: [PullRequestState!]`); - // `gh pr list` orders the same way, so a head with more matches than the limit keeps the - // same rows. - selections.push( - ` h${index}: pullRequests(headRefName: $h${index}, states: $s${index}, first: ${lookup.limit}, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${PULL_REQUEST_NODE_SELECTION} } }`, - ); - } - return { - document: `query PullRequestsByHead(${declarations.join(", ")}) {\n repository(owner: $owner, name: $name) {\n${selections.join("\n")}\n }\n}`, - variables, - }; -} - -const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} } - } -}`; - -const decodePullRequestsByHead = decodeJsonResult( - Schema.Struct({ - data: Schema.Struct({ - repository: Schema.NullOr( - Schema.Record( - Schema.String, - Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), - ), - ), - }), - }), -); - -const decodePullRequestByNumber = decodeJsonResult( - Schema.Struct({ - data: Schema.Struct({ - repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })), - }), - }), -); - -/** A repository on a GitHub host: the API it is read through, and its owner and name. */ -export interface GitHubRepositoryLocator { - readonly host: string; - readonly owner: string; - readonly name: string; -} - -/** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ -function parseGitHubRepositorySelector( - selector: string, - defaultHost: string, -): GitHubRepositoryLocator | null { - const trimmed = selector.trim().replace(/\.git$/i, ""); - if (/^https?:\/\//i.test(trimmed)) { - try { - const url = new URL(trimmed); - const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean); - return owner && name && rest.length === 0 - ? { host: url.host.toLowerCase(), owner, name } - : null; - } catch { - return null; - } - } - const parts = trimmed.split("/").filter(Boolean); - if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; - if (parts.length === 3) - return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! }; - return null; -} - -/** - * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request - * URL, or a branch name. - */ -function parsePullRequestReference( - reference: string, -): - | { readonly kind: "number"; readonly number: number } - | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number } - | { readonly kind: "branch"; readonly headSelector: string } { - const trimmed = reference.trim(); - const numbered = /^#?([1-9]\d*)$/.exec(trimmed); - if (numbered) return { kind: "number", number: Number(numbered[1]) }; - if (/^https?:\/\//i.test(trimmed)) { - try { - const url = new URL(trimmed); - const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname); - if (match) { - return { - kind: "url", - locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! }, - number: Number(match[3]), - }; - } - } catch { - // Not a URL after all; read it as a branch. - } - } - return { kind: "branch", headSelector: trimmed }; -} - -/** - * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH - * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through - * `github.com`, which is what such an alias almost always stands for (issue #6198). - */ -export function gitHubApiHostForRemote(remoteUrl: string): string | null { - const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); - if (provider === null) return null; - const host = new URL(provider.baseUrl).host.toLowerCase(); - // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. - if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { - return host.includes("github") ? "github.com" : null; - } - return provider.kind === "github" ? host : null; -} - -/** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ -function apiHostForHint(host: string): string { - const normalized = host.toLowerCase(); - return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; -} - -/** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ -export function pullRequestCheckoutBranchName(input: { - readonly headRefName: string; - readonly headOwner: string | null; - readonly isCrossRepository: boolean; - readonly defaultBranch: string | null; -}): string { - // gh prefixes the owner only where the fork's branch would take over the default branch's - // name, which is the one collision every fork pull request from `main` would hit. - return input.isCrossRepository && - input.headOwner !== null && - input.defaultBranch !== null && - input.headRefName === input.defaultBranch - ? `${input.headOwner}/${input.headRefName}` - : input.headRefName; -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const process = yield* VcsProcess.VcsProcess; - const environment = yield* HostProcessEnvironment; - const api = yield* GitHubApi.GitHubApi; - const git = yield* GitVcsDriver.GitVcsDriver; - const fileSystem = yield* FileSystem.FileSystem; - - const gitRead = (cwd: string, args: ReadonlyArray) => - process.run({ - operation: "GitHubRepositoryApi.resolveRepository", - command: "git", - args, - cwd, - allowNonZeroExit: true, - timeoutMs: 5_000, - }); - - const commandFailure = (cwd: string, detail: string) => - new GitHubRepositoryCommandError({ cwd, cause: new Error(detail) }); - - /** - * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else - * the remote the caller resolved the provider from, else the best-ranked GitHub remote. - */ - const resolveRepository = Effect.fn("GitHubRepositoryApi.resolveRepository")(function* (input: { - readonly cwd: string; - readonly host?: string | undefined; - }) { - const envRepository = environment.GH_REPO?.trim(); - const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase(); - if (envRepository) { - const locator = parseGitHubRepositorySelector(envRepository, defaultHost); - if (locator !== null) return locator; - } - const [remotes, resolved] = yield* Effect.all([ - gitRead(input.cwd, ["remote", "-v"]), - gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), - ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); - const remoteOutput = remotes?.exitCode === 0 ? remotes.stdout : ""; - const fetchRemotes = remoteOutput - .split("\n") - .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) - .filter((match): match is RegExpExecArray => match !== null) - .map((match) => ({ - name: match[1]!, - url: match[2]!, - host: gitHubApiHostForRemote(match[2]!), - })); - const host = - (input.host === undefined ? undefined : apiHostForHint(input.host)) ?? - fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? - fetchRemotes.find((remote) => remote.host !== null)?.host ?? - defaultHost; - const selected = selectGitHubBaseRepository({ - remotes: remoteOutput, - resolved: resolved?.exitCode === 0 ? resolved.stdout : "", - host, - }); - if (selected !== null) return { host, ...selected }; - // gh's own order without its prompt: upstream, github, origin, then the first remote, among - // the ones on this host (an SSH alias counts as its API host). - const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); - const candidates = fetchRemotes - .filter((remote) => remote.host === host) - .toSorted((left, right) => { - const l = rank(left.name); - const r = rank(right.name); - return (l === -1 ? 99 : l) - (r === -1 ? 99 : r); - }); - for (const remote of candidates) { - const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1); - if (owner && name && rest.length === 0) return { host, owner, name }; - } - return yield* commandFailure( - input.cwd, - `No GitHub repository on ${host} was found among this checkout's git remotes.`, - ); - }); - - const graphqlJson = ( - cwd: string, - input: GitHubApi.GitHubGraphQlInput, - decode: (raw: string) => Result.Result, - onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError, - ) => - api.graphql(input).pipe( - Effect.mapError((error) => fromGitHubApiError(cwd, error)), - Effect.flatMap((raw) => { - const decoded = decode(raw); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail(onDecodeFailure(decoded.failure)); - }), - ); - - const rest = (cwd: string, input: GitHubApi.GitHubRestInput) => - api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(cwd, error))); - - /** Pull requests whose head branch is `headRefName` on one repository. */ - const readPullRequestsByHead = (input: { - readonly cwd: string; - readonly locator: GitHubRepositoryLocator; - readonly lookups: ReadonlyArray< - Pick - >; - readonly allowReserve: boolean; - readonly onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError; - }) => { - const query = buildPullRequestsByHeadQuery(input.lookups); - return graphqlJson( - input.cwd, - { - host: input.locator.host, - operation: "listPullRequestsByHead", - query: query.document, - variables: { owner: input.locator.owner, name: input.locator.name, ...query.variables }, - allowReserve: input.allowReserve, - // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred - // fork pull requests, so the usual cap would cut the answer short. - maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, - }, - decodePullRequestsByHead, - input.onDecodeFailure, - ).pipe( - Effect.flatMap((decoded) => - decoded.data.repository === null - ? Effect.fail( - new GitHubPullRequestNotFoundError({ - cwd: input.cwd, - cause: new Error("The repository could not be read."), - }), - ) - : Effect.succeed(decoded.data.repository), - ), - ); - }; - - const headResolver = RequestResolver.makeGrouped({ - key: ({ request, context }) => - [ - request.host, - request.owner, - request.name, - String(request.allowReserve), - Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) - ?.credentialFingerprint ?? "", - Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), - ].join("\0"), - resolver: (entries) => { - const [first] = entries; - const { cwd, host, owner, name, allowReserve } = first.request; - return readPullRequestsByHead({ - cwd, - locator: { host, owner, name }, - lookups: entries.map((entry) => entry.request), - allowReserve, - onDecodeFailure: (cause) => new GitHubChangeRequestListDecodeError({ cwd, cause }), - }).pipe( - Effect.map((aliases) => { - for (const [index, entry] of entries.entries()) { - const alias = aliases[`h${index}`]; - entry.completeUnsafe( - Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), - ); - } - }), - Effect.catchCause((cause) => - Effect.sync(() => { - for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); - }), - ), - ); - }, - }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT)); - const interactiveHeadResolver = headResolver.pipe( - RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW), - ); - const backgroundHeadResolver = headResolver.pipe( - RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT), - RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), - ); - - const listByHead = Effect.fn("GitHubRepositoryApi.listByHead")(function* (input: { - readonly cwd: string; - readonly headSelector: string; - readonly state: PullRequestListState; - readonly limit: number; - readonly rateLimitHost?: string | undefined; - readonly allowReserve: boolean; - }) { - const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); - const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); - // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the - // owner is matched on the rows it returns. - const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); - const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); - const rows = yield* Effect.request( - new PullRequestsByHeadRead({ - cwd: input.cwd, - ...locator, - headRefName, - state: input.state, - limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, - allowReserve: input.allowReserve, - }), - input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver, - ); - if (!ownerMatch) return rows; - const headOwner = ownerMatch[1]!.toLowerCase(); - return rows - .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner) - .slice(0, limit); - }); - - const toSummaries = (rows: ReadonlyArray) => - rows.map(pullRequestSummary); - - const readPullRequest = Effect.fn("GitHubRepositoryApi.readPullRequest")(function* (input: { - readonly cwd: string; - readonly reference: string; - readonly rateLimitHost?: string | undefined; - }) { - const parsed = parsePullRequestReference(input.reference); - if (parsed.kind === "branch") { - // `gh pr view ` prefers an open pull request, then the newest of any state. - const [open] = yield* listByHead({ - cwd: input.cwd, - headSelector: parsed.headSelector, - state: "open", - limit: 1, - rateLimitHost: input.rateLimitHost, - allowReserve: true, - }); - const found = - open ?? - (yield* listByHead({ - cwd: input.cwd, - headSelector: parsed.headSelector, - state: "all", - limit: 1, - rateLimitHost: input.rateLimitHost, - allowReserve: true, - }))[0]; - if (found === undefined) { - return yield* new GitHubPullRequestNotFoundError({ - cwd: input.cwd, - cause: new Error("No pull request has this head branch."), - }); - } - return found; - } - const locator = - parsed.kind === "url" - ? parsed.locator - : yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); - const decodeFailure = (cause: unknown) => - new GitHubPullRequestDecodeError({ cwd: input.cwd, cause }); - const decoded = yield* graphqlJson( - input.cwd, - { - host: locator.host, - operation: "getPullRequest", - query: PULL_REQUEST_BY_NUMBER_QUERY, - variables: { owner: locator.owner, name: locator.name, number: parsed.number }, - allowReserve: true, - }, - decodePullRequestByNumber, - decodeFailure, - ); - const node = decoded.data.repository?.pullRequest; - if (node == null) { - return yield* new GitHubPullRequestNotFoundError({ - cwd: input.cwd, - cause: new Error("The pull request does not exist."), - }); - } - const [record] = decodeGitHubPullRequestEntries([node]); - if (record === undefined) return yield* decodeFailure(new Error("Malformed pull request.")); - return record; - }); - - const readRepository = Effect.fn("GitHubRepositoryApi.readRepository")(function* ( - cwd: string, - locator: GitHubRepositoryLocator, - ) { - const response = yield* rest(cwd, { - host: locator.host, - operation: "getRepository", - path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, - allowReserve: true, - }); - const decoded = decodeRawRepository(response.body); - if (Result.isFailure(decoded)) { - return yield* new GitHubRepositoryDecodeError({ - cwd, - cause: decoded.failure, - }); - } - return decoded.success; - }); - - const readViewerLogin = Effect.fn("GitHubRepositoryApi.readViewerLogin")(function* ( - cwd: string, - host: string, - ) { - const response = yield* rest(cwd, { host, operation: "getViewer", path: "user" }); - const decoded = decodeViewerLogin(response.body); - if (Result.isFailure(decoded)) { - return yield* new GitHubRepositoryCommandError({ cwd, cause: decoded.failure }); - } - return decoded.success.login; - }); - - const gitFailure = (cwd: string) => (cause: unknown) => - new GitHubRepositoryCommandError({ cwd, cause }); - - const runGit = (cwd: string, operation: string, args: ReadonlyArray) => - git.execute({ operation: `GitHubRepositoryApi.checkoutPullRequest.${operation}`, cwd, args }); - - /** - * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a - * fork gets a remote of its own), checked out under the name gh would give it, and set to - * track the head. A head branch that is gone is read from the base's `refs/pull//head`. - * An existing branch fast-forwards, or with `force` is reset to the pull request. - */ - const checkoutPullRequest: GitHubRepositoryApi["Service"]["checkoutPullRequest"] = Effect.fn( - "GitHubRepositoryApi.checkoutPullRequest", - )(function* (input) { - const reference = parsePullRequestReference(input.reference); - const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); - const base = - reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd }); - const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); - const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; - const isCrossRepository = - pullRequest.isCrossRepository ?? - (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); - const headOwner = - pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; - - const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( - Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), - Effect.mapError(gitFailure(input.cwd)), - ); - const remoteFor = (nameWithOwner: string) => - remotes - .split("\n") - .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) - .find( - (match) => - match !== null && - normalizeGitRemoteUrl(match[2]!).split("/").slice(1).join("/") === - nameWithOwner.toLowerCase(), - )?.[1] ?? null; - const baseRemote = Effect.suspend(() => { - const known = remoteFor(baseNameWithOwner); - return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); - }); - // A fork's branch named like the base's default branch is checked out under the owner's - // prefix. Without the default branch that collision cannot be ruled out, and the checkout - // would reset the local default branch to the fork's commit, so it fails instead. - const defaultBranch = isCrossRepository - ? yield* readRepository(input.cwd, base).pipe( - Effect.map((repository) => repository.default_branch ?? null), - ) - : null; - const localBranch = pullRequestCheckoutBranchName({ - headRefName: pullRequest.headRefName, - headOwner, - isCrossRepository, - defaultBranch, - }); - - /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ - const headRemote = Effect.gen(function* () { - if (!isCrossRepository) return yield* baseRemote; - if (headNameWithOwner === null) return yield* commandFailure(input.cwd, "The fork is gone."); - const known = remoteFor(headNameWithOwner); - if (known !== null) return known; - const [owner, name] = headNameWithOwner.split("/"); - const fork = yield* readRepository(input.cwd, { - host: base.host, - owner: owner!, - name: name!, - }); - const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); - return yield* git.ensureRemote({ - cwd: input.cwd, - preferredName: headOwner ?? "fork", - url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url, - }); - }); - - const exists = (yield* git - .listLocalBranchNames(input.cwd) - .pipe(Effect.mapError(gitFailure(input.cwd)))).includes(localBranch); - - // The commit to check out, fetched from the head branch where it still exists. - const target = yield* Effect.gen(function* () { - const remoteName = yield* headRemote; - yield* git.fetchRemoteTrackingBranch({ - cwd: input.cwd, - remoteName, - remoteBranch: pullRequest.headRefName, - }); - return { - ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`, - upstream: { remoteName, remoteBranch: pullRequest.headRefName }, - }; - }).pipe( - Effect.catch(() => - Effect.gen(function* () { - yield* runGit(input.cwd, "fetchPullRef", [ - "fetch", - "--quiet", - "--no-tags", - yield* baseRemote, - `refs/pull/${pullRequest.number}/head`, - ]); - const { commitSha } = yield* git.resolveCommit({ - cwd: input.cwd, - revision: "FETCH_HEAD", - }); - return { ref: commitSha, upstream: null }; - }), - ), - Effect.mapError(gitFailure(input.cwd)), - ); - - yield* Effect.gen(function* () { - if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]); - yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); - if (exists) { - yield* runGit( - input.cwd, - "sync", - input.force === true - ? ["reset", "--hard", "--quiet", target.ref] - : ["merge", "--ff-only", "--quiet", target.ref], - ); - } - // Tracking is set once the branch is the pull request's, so a sync that fails leaves an - // existing branch's upstream as it was. - if (target.upstream !== null) { - yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); - } - }).pipe(Effect.mapError(gitFailure(input.cwd))); - }); - - return GitHubRepositoryApi.of({ - listPullRequestsByHead: (input) => - AllowGitHubReserve.pipe( - Effect.flatMap((allowReserve) => listByHead({ ...input, allowReserve })), - ), - listOpenPullRequests: (input) => - listByHead({ - cwd: input.cwd, - headSelector: input.headSelector, - state: "open", - limit: input.limit ?? 1, - rateLimitHost: input.rateLimitHost, - allowReserve: true, - }).pipe(Effect.map(toSummaries)), - getPullRequest: (input) => readPullRequest(input).pipe(Effect.map(pullRequestSummary)), - getRepositoryCloneUrls: (input) => - Effect.gen(function* () { - const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe( - Effect.map((locator) => locator.host), - Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), - )).toLowerCase(); - const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); - if (locator === null) { - return yield* commandFailure(input.cwd, "Repositories are named owner/name."); - } - return repositoryCloneUrls(yield* readRepository(input.cwd, locator)); - }), - createRepository: (input) => - Effect.gen(function* () { - const locator = parseGitHubRepositorySelector( - input.repository, - (environment.GH_HOST ?? "github.com").toLowerCase(), - ); - const viewer = locator === null ? null : yield* readViewerLogin(input.cwd, locator.host); - const owner = locator?.owner ?? viewer; - const name = locator?.name ?? input.repository.trim(); - const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); - const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); - const response = yield* rest(input.cwd, { - host, - operation: "createRepository", - method: "POST", - path: - isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, - body: { name, private: input.visibility === "private" }, - }); - const decoded = decodeRawRepository(response.body); - if (Result.isFailure(decoded)) { - return yield* new GitHubRepositoryDecodeError({ - cwd: input.cwd, - cause: decoded.failure, - }); - } - return repositoryCloneUrls(decoded.success); - }), - createPullRequest: (input) => - Effect.gen(function* () { - const locator = yield* resolveRepository({ cwd: input.cwd }); - const body = yield* fileSystem - .readFileString(input.bodyFile) - .pipe(Effect.mapError(gitFailure(input.cwd))); - yield* rest(input.cwd, { - host: locator.host, - operation: "createPullRequest", - method: "POST", - path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, - // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. - // gh allows maintainer edits unless told otherwise; the API's default is not documented. - body: { - base: input.baseBranch, - head: input.headSelector, - title: input.title, - body, - maintainer_can_modify: true, - }, - }); - }), - getDefaultBranch: (input) => - Effect.gen(function* () { - const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); - const repository = yield* readRepository(input.cwd, locator); - const branch = repository.default_branch?.trim() ?? ""; - return branch.length > 0 ? branch : null; - }), - checkoutPullRequest, - }); -}); - -export const layer = Layer.effect(GitHubRepositoryApi, make).pipe( - Layer.provideMerge(GitHubApi.layer), - Layer.provideMerge(GitHubCredentials.layer), - Layer.provideMerge(GitHubGraphQlBudget.layer), - Layer.provideMerge(SourceControlRateLimit.layer), -); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 89398b63478604b9c392d16f19f7e99c50463315..b70bfff78f6b73fc207d3fe6d176f2abd071fb03 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -1,17 +1,23 @@ -import { assert, it } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; +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 GitHubRepositoryApi from "./GitHubRepositoryApi.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?: { @@ -26,191 +32,611 @@ const processResult = ( stderrTruncated: false, }); -function makeProvider( - github: Partial, - api: Partial = {}, -) { - return GitHubSourceControlProvider.make.pipe( - Effect.provide( - Layer.merge( - Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(github), - Layer.mock(GitHubApi.GitHubApi)(api), - ), - ), - ); -} +const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(exitCode), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const remotesOutput = (...entries: ReadonlyArray) => + entries + .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join("\n"); -const restResponse = (body: string): GitHubApi.GitHubRestResponse => ({ - status: 200, +const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({ + status, headers: {}, - body, + body: body === undefined ? "" : encodeJson(body), truncated: false, invalidUtf8: false, }); -it.effect("maps GitHub PR summaries into provider-neutral change requests", () => - Effect.gen(function* () { - const provider = yield* makeProvider({ - getPullRequest: () => - Effect.succeed({ - number: 42, - title: "Add GitHub provider", - url: "https://github.com/pingdotgg/t3code/pull/42", - baseRefName: "main", - headRefName: "feature/source-control", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "fork/t3code", - headRepositoryOwnerLogin: "fork", - }), - }); - - const changeRequest = yield* provider.getChangeRequest({ - cwd: "/repo", - reference: "42", - }); - - assert.deepStrictEqual(changeRequest, { - provider: "github", - number: 42, - title: "Add GitHub provider", - url: "https://github.com/pingdotgg/t3code/pull/42", - baseRefName: "main", - headRefName: "feature/source-control", - state: "open", - closedAt: null, - mergedAt: null, - updatedAt: Option.none(), - isCrossRepository: true, - headRepositoryNameWithOwner: "fork/t3code", - headRepositoryOwnerLogin: "fork", - }); - }), -); - -it.effect("adds safe request context while retaining GitHub API causes", () => - Effect.gen(function* () { - const cause = new GitHubRepositoryApi.GitHubPullRequestNotFoundError({ - cwd: "/repo", - cause: new Error("raw upstream detail that should remain in the cause"), - }); - const provider = yield* makeProvider({ - getPullRequest: () => Effect.fail(cause), - }); - - const error = yield* provider - .getChangeRequest({ - cwd: "/repo", - reference: "https://user:secret@github.com/pingdotgg/t3code/pull/42?token=secret#diff", - }) - .pipe(Effect.flip); - - assert.deepStrictEqual( - { - provider: error.provider, - operation: error.operation, - cwd: error.cwd, - reference: error.reference, - detail: error.detail, +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; + /** 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("", 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")] + : [], + }, + }, + }, + }), + ), }, - { - provider: "github", - operation: "getChangeRequest", + }); + 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/pingdotgg/t3code/pull/42", - detail: "Pull request not found. Check the PR number or URL and try again.", + 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); + }), }, - ); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes("raw upstream detail"), false); - }), -); - -it.effect("lists change request history through the batched head lookup", () => - Effect.gen(function* () { - let lookup: - | Parameters[0] - | null = null; - const provider = yield* makeProvider({ - listPullRequestsByHead: (input) => { - lookup = input; - return Effect.succeed([ - { - number: 7, - title: "Merged work", - url: "https://enterprise.test/acme/web/pull/7", - baseRefName: "main", - headRefName: "feature/merged", - headSha: "a".repeat(40), - state: "merged", - mergedAt: "2026-01-01T00:00:00Z", - updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), - }, - ]); + }); + 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"); + 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, + }); - const changeRequests = yield* provider.listChangeRequests({ - cwd: "/repo", - context: { - provider: { kind: "github", name: "GitHub Enterprise", baseUrl: "https://enterprise.test" }, - remoteName: "origin", - remoteUrl: "https://enterprise.test/acme/web.git", + 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") } } }), + ), }, - headSelector: "feature/merged", - state: "all", - limit: 10, - }); - - assert.deepStrictEqual(lookup, { - cwd: "/repo", - headSelector: "feature/merged", - state: "all", - limit: 10, - rateLimitHost: "enterprise.test", - }); - assert.strictEqual(changeRequests[0]?.provider, "github"); - assert.strictEqual(changeRequests[0]?.state, "merged"); - assert.strictEqual(changeRequests[0]?.headSha, "a".repeat(40)); - assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z"); - assert.deepStrictEqual( - changeRequests[0]?.updatedAt, - Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), - ); - }), -); - -it.effect("creates GitHub PRs through provider-neutral input names", () => - Effect.gen(function* () { - let createInput: - | Parameters[0] - | null = null; - const provider = yield* makeProvider({ - createPullRequest: (input) => { - createInput = input; - return Effect.void; + }); + 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)); + }); - yield* provider.createChangeRequest({ - cwd: "/repo", - baseRefName: "main", - headSelector: "owner:feature/provider", - title: "Provider PR", - bodyFile: "/tmp/body.md", + 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)); + }); - assert.deepStrictEqual(createInput, { - cwd: "/repo", - baseBranch: "main", - headSelector: "owner:feature/provider", - title: "Provider PR", - bodyFile: "/tmp/body.md", + 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( @@ -409,20 +835,15 @@ 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( - encodeJson({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }), - ), - ); - }, + 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`), @@ -451,15 +872,12 @@ it.effect.each(["read", "decode"] as const)( operation: "resolveLink", status: 500, }); - const provider = yield* makeProvider( - {}, - { - rest: () => - stage === "read" - ? Effect.fail(cause) - : Effect.succeed(restResponse("private response text")), - }, - ); + 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"), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index a3b7a22f8d55942cb455601ff817f9c9f1854d61..86edda994ccef6c170e9ec5809482c6111a70fe8 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -1,21 +1,42 @@ -import * as Schema from "effect/Schema"; -import * as DateTime from "effect/DateTime"; +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 * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; +import { + decodeGitHubPullRequestEntries, + type NormalizedGitHubPullRequestRecord, +} from "./gitHubPullRequests.ts"; +import { + parseFetchRemotes, + parseGitHubRepositorySelector, + parsePullRequestReference, + resolveGitHubRepository, + type GitHubRepositoryLocator, +} from "./gitHubRepositoryResolution.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; import { effectiveGitHubAccount, findAuthenticatedGitHubAccount, @@ -40,33 +61,8 @@ const decodeLinkSubject = Schema.decodeUnknownEffect( ), ); -function toChangeRequest(summary: GitHubRepositoryApi.GitHubPullRequestSummary): ChangeRequest { - return { - provider: "github", - number: summary.number, - title: summary.title, - url: summary.url, - baseRefName: summary.baseRefName, - headRefName: summary.headRefName, - ...(summary.headSha !== undefined ? { headSha: summary.headSha } : {}), - state: summary.state ?? "open", - ...(summary.isDraft === true ? { isDraft: true } : {}), - closedAt: summary.closedAt ?? null, - mergedAt: summary.mergedAt ?? null, - updatedAt: - summary.updatedAt === undefined - ? Option.none() - : Option.some(DateTime.makeUnsafe(summary.updatedAt)), - ...(summary.isCrossRepository !== undefined - ? { isCrossRepository: summary.isCrossRepository } - : {}), - ...(summary.headRepositoryNameWithOwner !== undefined - ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } - : {}), - ...(summary.headRepositoryOwnerLogin !== undefined - ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } - : {}), - }; +function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest { + return { provider: "github", ...record }; } function authAccounts(accounts: ReadonlyArray) { @@ -263,77 +259,601 @@ export const makeDiscovery = Effect.gen(function* () { } 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 github = yield* GitHubRepositoryApi.GitHubRepositoryApi; 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 listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = - (input) => { - if (input.state === "open") { - return github - .listOpenPullRequests({ + const gitRead = (cwd: string, args: ReadonlyArray) => + process.run({ + operation: "GitHubSourceControlProvider.resolveRepository", + command: "git", + args, + cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + }); + + /** + * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else + * the remote the caller resolved the provider from, else the best-ranked GitHub remote. + */ + const resolveRepository = Effect.fn("GitHubSourceControlProvider.resolveRepository")( + function* (input: { readonly cwd: string; readonly host?: string | undefined }) { + const envRepository = environment.GH_REPO?.trim(); + const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase(); + if (envRepository) { + const locator = parseGitHubRepositorySelector(envRepository, defaultHost); + if (locator !== null) return locator; + } + const [remotes, resolved] = yield* Effect.all([ + gitRead(input.cwd, ["remote", "-v"]), + gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), + ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); + const { host, locator } = resolveGitHubRepository({ + remotes: remotes?.exitCode === 0 ? remotes.stdout : "", + resolved: resolved?.exitCode === 0 ? resolved.stdout : "", + hostHint: input.host, + defaultHost, + }); + if (locator !== null) return locator; + 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 host?: string | undefined; + readonly allowReserve: boolean; + }) { + const locator = yield* resolveRepository({ cwd: input.cwd, host: input.host }); + 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 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: input.headSelector, - ...(input.context === undefined - ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), - ...(input.limit !== undefined ? { limit: input.limit } : {}), - }) - .pipe( - Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.message, - cause: error, - }), - ), + headSelector: parsed.headSelector, + state, + limit: 1, + host: input.host, + 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({ cwd: input.cwd, host: input.host }); + 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" }); + 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; + }) { + const reference = parsePullRequestReference(input.reference); + const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); + const base = + reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd }); + const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); + const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; + const isCrossRepository = + pullRequest.isCrossRepository ?? + (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); + const headOwner = + pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; + + const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( + Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), + Effect.mapError(gitFailure), + ); + const remoteFor = (nameWithOwner: string) => + parseFetchRemotes(remotes).find( + (remote) => + 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)); + }, + ); - return github - .listPullRequestsByHead({ - cwd: input.cwd, - headSelector: input.headSelector, - state: input.state, - limit: input.limit ?? 20, - ...(input.context === undefined - ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), - }) - .pipe( - Effect.map((items) => - items.map(({ updatedAt, ...summary }) => ({ - ...toChangeRequest({ - ...summary, - ...(Option.isSome(updatedAt) - ? { updatedAt: DateTime.formatIso(updatedAt.value) } - : {}), - }), - updatedAt, - })), - ), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.message, - cause: error, - }), - ), - ); - }; + /** 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 }, @@ -385,121 +905,122 @@ export const make = Effect.gen(function* () { if (!match) return undefined; return readLinkSubject(input, `repos/${match[1]}/${match[2]}/issues/${match[3]}`); }, - listChangeRequests, + 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), + allowReserve, + }), + ), + Effect.map((records) => records.map(toChangeRequest)), + Effect.mapError( + providerError("listChangeRequests", input.cwd, { reference: input.headSelector }), + ), + ), getChangeRequest: (input) => - github - .getPullRequest({ - ...input, - ...(input.context === undefined - ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), - }) - .pipe( - Effect.map(toChangeRequest), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getChangeRequest", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.message, - cause: error, - }), - ), + readPullRequest({ ...input, host: contextHost(input.context) }).pipe( + Effect.map(toChangeRequest), + Effect.mapError( + providerError("getChangeRequest", input.cwd, { reference: input.reference }), ), + ), createChangeRequest: (input) => - github - .createPullRequest({ - cwd: input.cwd, - baseBranch: input.baseRefName, - headSelector: input.headSelector, - title: input.title, - bodyFile: input.bodyFile, - }) - .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "createChangeRequest", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.message, - cause: error, - }), - ), + Effect.gen(function* () { + const locator = yield* resolveRepository({ cwd: input.cwd }); + 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", + 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) => - github.getRepositoryCloneUrls(input).pipe( + Effect.gen(function* () { + const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe( + Effect.map((locator) => locator.host), + Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), + )).toLowerCase(); + const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); + if (locator === null) return yield* failure("Repositories are named owner/name."); + return repositoryCloneUrls(yield* readRepository(locator)); + }).pipe( Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getRepositoryCloneUrls", - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.message, - cause: error, - }), + providerError("getRepositoryCloneUrls", input.cwd, { repository: input.repository }), ), ), createRepository: (input) => - github.createRepository(input).pipe( + 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", + 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( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "createRepository", - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.message, - cause: error, - }), + providerError("createRepository", input.cwd, { repository: input.repository }), ), ), getDefaultBranch: (input) => - github - .getDefaultBranch({ - ...input, - ...(input.context === undefined - ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), - }) - .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getDefaultBranch", - cwd: input.cwd, - detail: error.message, - cause: error, - }), - ), - ), + Effect.gen(function* () { + const locator = yield* resolveRepository({ + cwd: input.cwd, + host: contextHost(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) => - github.checkoutPullRequest(input).pipe( + checkoutPullRequest(input).pipe( Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "checkoutChangeRequest", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.message, - cause: error, - }), + providerError("checkoutChangeRequest", input.cwd, { reference: input.reference }), ), ), }); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index d9bbb51d7a61678f0bd583f5366ae4d6f610102a..6101d148a632740479c58dcc81225934cd642c5d 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -13,12 +13,12 @@ import { VcsProcessSpawnError } from "@t3tools/contracts"; import * as ServerSettings from "../serverSettings.ts"; import * as ServerConfig from "../config.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; @@ -40,8 +40,8 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)({}), Layer.mock(GitHubApi.GitHubApi)({}), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(GitLabCli.GitLabCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index c9a7164c8b3107d1922c58d18b7e4d327a76a172..f715eff1dea01cb56b65e75893b4f9f023366be1 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -14,8 +14,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; @@ -42,7 +42,6 @@ function makeRegistry(input: { readonly url: string; }>; readonly process?: Partial; - readonly github?: Partial; readonly githubApi?: Partial; readonly gitlab?: Partial; readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; @@ -98,8 +97,8 @@ function makeRegistry(input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(input.github ?? {}), Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), ServerConfig.layerTest(process.cwd(), { diff --git a/apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts b/apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b33f0c38e40636ec2b1df0d30883f97207df971 --- /dev/null +++ b/apps/server/src/sourceControl/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/apps/server/src/sourceControl/gitHubRepositoryResolution.ts b/apps/server/src/sourceControl/gitHubRepositoryResolution.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d4cbf038bfdbe1c5a95089c945c870e07590d3c --- /dev/null +++ b/apps/server/src/sourceControl/gitHubRepositoryResolution.ts @@ -0,0 +1,190 @@ +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) + .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/ws.ts b/apps/server/src/ws.ts index 5f64ecb12f87ecc45d0de631ccce9c5be9c05028..55d5ec85b5a54f0a48b711421a28a46da5a70a10 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -229,7 +229,7 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery. import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubApi from "./sourceControl/GitHubApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; @@ -3190,7 +3190,7 @@ export const layer = Layer.unwrap( Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, - GitHubRepositoryApi.layer, + GitHubApi.layerWithDependencies, GitLabCli.layer, ForgejoCli.layer, ),