diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 9c47e8099b..b5c9c0338f 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -655,6 +655,7 @@ function makeManager(input?: { get: () => Effect.succeed(provider), resolveHandle: () => Effect.succeed({ provider, context: null }), resolve: () => Effect.succeed(provider), + discover: Effect.succeed([]), }), ), Effect.provide(Layer.succeed(GitHubCli, gitHubCli)), diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index bc0624beaf..dd7273b40c 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -1,28 +1,27 @@ -import { assert, it } from "@effect/vitest"; +import { assert, describe, it, vi } from "@effect/vitest"; import { Effect, Layer } from "effect"; -import { describe, vi } from "vitest"; -import { GitManager } from "./GitManager.ts"; -import { GitWorkflowService, layer as GitWorkflowServiceLayer } from "./GitWorkflowService.ts"; -import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; -import { VcsDriverRegistry, type VcsDriverRegistryShape } from "../vcs/VcsDriverRegistry.ts"; +import * as GitManager from "./GitManager.ts"; +import * as GitWorkflowService from "./GitWorkflowService.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; -function makeLayer(input: { readonly detect: VcsDriverRegistryShape["detect"] }) { - return GitWorkflowServiceLayer.pipe( +function makeLayer(input: { readonly detect: VcsDriverRegistry.VcsDriverRegistryShape["detect"] }) { + return GitWorkflowService.layer.pipe( Layer.provide( - Layer.mock(VcsDriverRegistry)({ + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ detect: input.detect, }), ), - Layer.provide(Layer.mock(GitVcsDriver)({})), - Layer.provide(Layer.mock(GitManager)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(GitManager.GitManager)({})), ); } describe("GitWorkflowService", () => { it.effect("returns an empty local status when no VCS repository is detected", () => Effect.gen(function* () { - const workflow = yield* GitWorkflowService; + const workflow = yield* GitWorkflowService.GitWorkflowService; const status = yield* workflow.localStatus({ cwd: "/not-a-repo" }); assert.deepStrictEqual(status, { @@ -48,7 +47,7 @@ describe("GitWorkflowService", () => { it.effect("returns an empty full status when no VCS repository is detected", () => Effect.gen(function* () { - const workflow = yield* GitWorkflowService; + const workflow = yield* GitWorkflowService.GitWorkflowService; const status = yield* workflow.status({ cwd: "/not-a-repo" }); assert.deepStrictEqual(status, { @@ -82,15 +81,15 @@ describe("GitWorkflowService", () => { const remoteStatus = vi.fn(); const status = vi.fn(); - const testLayer = GitWorkflowServiceLayer.pipe( + const testLayer = GitWorkflowService.layer.pipe( Layer.provide( - Layer.mock(VcsDriverRegistry)({ + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ detect: () => Effect.succeed(null), }), ), - Layer.provide(Layer.mock(GitVcsDriver)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), Layer.provide( - Layer.mock(GitManager)({ + Layer.mock(GitManager.GitManager)({ localStatus, remoteStatus, status, @@ -99,7 +98,7 @@ describe("GitWorkflowService", () => { ); return Effect.gen(function* () { - const workflow = yield* GitWorkflowService; + const workflow = yield* GitWorkflowService.GitWorkflowService; yield* workflow.localStatus({ cwd: "/not-a-repo" }); yield* workflow.remoteStatus({ cwd: "/not-a-repo" }); yield* workflow.status({ cwd: "/not-a-repo" }); @@ -112,7 +111,7 @@ describe("GitWorkflowService", () => { it.effect("returns an empty ref list when no VCS repository is detected", () => Effect.gen(function* () { - const workflow = yield* GitWorkflowService; + const workflow = yield* GitWorkflowService.GitWorkflowService; const refs = yield* workflow.listRefs({ cwd: "/not-a-repo" }); assert.deepStrictEqual(refs, { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 36af229d4c..152fba1ea2 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -105,23 +105,12 @@ import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; -import type { VcsDriverShape } from "./vcs/VcsDriver.ts"; -import { - VcsStatusBroadcaster, - type VcsStatusBroadcasterShape, - layer as VcsStatusBroadcasterLayer, -} from "./vcs/VcsStatusBroadcaster.ts"; -import { - VcsDriverRegistry, - type VcsDriverRegistryShape, - type VcsDriverHandle, -} from "./vcs/VcsDriverRegistry.ts"; -import { layer as VcsProvisioningServiceLayer } from "./vcs/VcsProvisioningService.ts"; -import { layer as GitWorkflowServiceLayer } from "./git/GitWorkflowService.ts"; -import { - SourceControlRepositoryService, - type SourceControlRepositoryServiceShape, -} from "./sourceControl/SourceControlRepositoryService.ts"; +import * as VcsDriver from "./vcs/VcsDriver.ts"; +import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; +import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; +import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; @@ -329,12 +318,12 @@ const buildAppUnderTest = (options?: { providerRegistry?: Partial; serverSettings?: Partial; open?: Partial; - vcsDriver?: Partial; - vcsDriverRegistry?: Partial; + vcsDriver?: Partial; + vcsDriverRegistry?: Partial; gitVcsDriver?: Partial; gitManager?: Partial; - sourceControlRepositoryService?: Partial; - vcsStatusBroadcaster?: Partial; + sourceControlRepositoryService?: Partial; + vcsStatusBroadcaster?: Partial; projectSetupScriptRunner?: Partial; terminalManager?: Partial; orchestrationEngine?: Partial; @@ -382,7 +371,7 @@ const buildAppUnderTest = (options?: { ...options?.config, }; const layerConfig = Layer.succeed(ServerConfig, config); - const defaultVcsDriver: VcsDriverShape = { + const defaultVcsDriver: VcsDriver.VcsDriverShape = { capabilities: { kind: "git", supportsWorktrees: true, @@ -424,7 +413,7 @@ const buildAppUnderTest = (options?: { initRepository: () => Effect.void, ...options?.layers?.vcsDriver, }; - const vcsDriverRegistryLayer = Layer.mock(VcsDriverRegistry)({ + const vcsDriverRegistryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ get: () => Effect.succeed(defaultVcsDriver), detect: (input) => defaultVcsDriver.detectRepository(input.cwd).pipe( @@ -454,7 +443,7 @@ const buildAppUnderTest = (options?: { kind: repository.kind, repository, driver: defaultVcsDriver, - } satisfies VcsDriverHandle) + } satisfies VcsDriverRegistry.VcsDriverHandle) : null, ), ), @@ -496,19 +485,19 @@ const buildAppUnderTest = (options?: { ), ProjectFaviconResolverLive, ); - const gitWorkflowLayer = GitWorkflowServiceLayer.pipe( + const gitWorkflowLayer = GitWorkflowService.layer.pipe( Layer.provideMerge(vcsDriverRegistryLayer), Layer.provideMerge(gitVcsDriverLayer), Layer.provideMerge(gitManagerLayer), ); - const vcsProvisioningLayer = VcsProvisioningServiceLayer.pipe( + const vcsProvisioningLayer = VcsProvisioningService.layer.pipe( Layer.provide(vcsDriverRegistryLayer), ); const vcsStatusBroadcasterLayer = options?.layers?.vcsStatusBroadcaster - ? Layer.mock(VcsStatusBroadcaster)({ + ? Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ ...options.layers.vcsStatusBroadcaster, }) - : VcsStatusBroadcasterLayer.pipe(Layer.provide(gitWorkflowLayer)); + : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer)); const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, @@ -552,7 +541,7 @@ const buildAppUnderTest = (options?: { Layer.provide(gitWorkflowLayer), Layer.provide(vcsProvisioningLayer), Layer.provide( - Layer.mock(SourceControlRepositoryService)({ + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ ...options?.layers?.sourceControlRepositoryService, }), ), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f06e0e78ed..939b2c8abf 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -25,6 +25,8 @@ import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReap import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; +import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; +import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; @@ -164,7 +166,10 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( ); const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe( - Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)), + Layer.provide( + Layer.mergeAll(AzureDevOpsCli.layer, BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer), + ), + Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(VcsDriverRegistryLayerLive), ); @@ -235,6 +240,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), + Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts new file mode 100644 index 0000000000..406c9772f3 --- /dev/null +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -0,0 +1,288 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type { VcsError } from "@t3tools/contracts"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; + +const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const mockRun = vi.fn(); + +const supportLayer = Layer.mergeAll( + Layer.mock(VcsProcess.VcsProcess)({ + run: mockRun, + }), + NodeServices.layer, +); +const layer = Layer.mergeAll(AzureDevOpsCli.layer.pipe(Layer.provide(supportLayer)), supportLayer); + +afterEach(() => { + mockRun.mockReset(); +}); + +describe("AzureDevOpsCli.layer", () => { + it.effect("parses pull request view output", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + pullRequestId: 42, + title: "Add Azure provider", + sourceRefName: "refs/heads/feature/source-control", + targetRefName: "refs/heads/main", + status: "active", + creationDate: "2026-01-02T00:00:00.000Z", + closedDate: null, + _links: { + web: { + href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + }, + }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getPullRequest({ + cwd: "/repo", + reference: "#42", + }); + + assert.strictEqual(result.number, 42); + assert.strictEqual(result.title, "Add Azure provider"); + assert.strictEqual(result.baseRefName, "main"); + assert.strictEqual(result.headRefName, "feature/source-control"); + assert.strictEqual(result.state, "open"); + assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag); + assert.deepStrictEqual(mockRun.mock.calls.at(-1)?.[0], { + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "show", + "--detect", + "true", + "--id", + "42", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("lists pull requests with Azure status and source branch arguments", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify([ + { + pullRequestId: 7, + title: "Merged work", + sourceRefName: "refs/heads/feature/merged", + targetRefName: "refs/heads/main", + status: "completed", + closedDate: "2026-01-03T00:00:00.000Z", + _links: { + web: { + href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/7", + }, + }, + }, + ]), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.listPullRequests({ + cwd: "/repo", + headSelector: "origin:feature/merged", + state: "merged", + limit: 10, + }); + + assert.strictEqual(result[0]?.state, "merged"); + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "list", + "--detect", + "true", + "--source-branch", + "feature/merged", + "--status", + "completed", + "--top", + "10", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reads repository clone URLs", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + name: "repo", + webUrl: "https://dev.azure.com/acme/project/_git/repo", + remoteUrl: "https://dev.azure.com/acme/project/_git/repo", + sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", + project: { + name: "project", + }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "repo", + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "project/repo", + url: "https://dev.azure.com/acme/project/_git/repo", + sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("creates repositories through Azure Repos", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + name: "repo", + webUrl: "https://dev.azure.com/acme/project/_git/repo", + remoteUrl: "https://dev.azure.com/acme/project/_git/repo", + sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", + project: { + name: "project", + }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.createRepository({ + cwd: "/repo", + repository: "project/repo", + visibility: "private", + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "project/repo", + url: "https://dev.azure.com/acme/project/_git/repo", + sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", + }); + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "create", + "--detect", + "true", + "--name", + "repo", + "--project", + "project", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("creates pull requests using the body file as the Azure description", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const bodyFile = `/tmp/t3code-azure-devops-cli-${Date.now()}.md`; + yield* fileSystem.writeFileString(bodyFile, "Generated body"); + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "feature/provider", + title: "Provider PR", + bodyFile, + }); + + expect(mockRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "az", + cwd: "/repo", + args: expect.arrayContaining(["--description", `@${bodyFile}`]), + }), + ); + expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--output"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("does not force JSON output on checkout side-effect commands", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.checkoutPullRequest({ + cwd: "/repo", + reference: "42", + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "checkout", + "--only-show-errors", + "--detect", + "true", + "--id", + "42", + "--remote-name", + "origin", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts new file mode 100644 index 0000000000..eefae1cbde --- /dev/null +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -0,0 +1,442 @@ +import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; +import { + TrimmedNonEmptyString, + type SourceControlRepositoryVisibility, + type VcsError, +} from "@t3tools/contracts"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as AzureDevOpsPullRequests from "./azureDevOpsPullRequests.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +export class AzureDevOpsCliError extends Schema.TaggedErrorClass()( + "AzureDevOpsCliError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export interface AzureDevOpsRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; +} + +export interface AzureDevOpsCliShape { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect< + ReadonlyArray, + AzureDevOpsCliError + >; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect< + AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord, + AzureDevOpsCliError + >; + + 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 source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly remoteName?: string; + }) => Effect.Effect; +} + +export class AzureDevOpsCli extends Context.Service()( + "t3/source-control/AzureDevOpsCli", +) {} + +function errorText(error: VcsError | unknown): string { + if (typeof error === "object" && error !== null) { + const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; + const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; + const message = "message" in error && typeof error.message === "string" ? error.message : ""; + return [tag, detail, message].filter(Boolean).join("\n"); + } + + return String(error); +} + +function normalizeAzureDevOpsCliError( + operation: "execute", + error: VcsError | unknown, +): AzureDevOpsCliError { + const text = errorText(error); + const lower = text.toLowerCase(); + + if (lower.includes("command not found: az") || lower.includes("enoent")) { + return new AzureDevOpsCliError({ + operation, + detail: + "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH.", + cause: error, + }); + } + + if ( + lower.includes("az devops login") || + lower.includes("please run az login") || + lower.includes("not logged in") || + lower.includes("authentication failed") || + lower.includes("unauthorized") + ) { + return new AzureDevOpsCliError({ + operation, + detail: "Azure DevOps CLI is not authenticated. Run `az devops login` and retry.", + cause: error, + }); + } + + if ( + lower.includes("pull request") && + (lower.includes("not found") || lower.includes("does not exist")) + ) { + return new AzureDevOpsCliError({ + operation, + detail: "Pull request not found. Check the PR number or URL and try again.", + cause: error, + }); + } + + return new AzureDevOpsCliError({ + operation, + detail: text, + cause: error, + }); +} + +function normalizeChangeRequestId(reference: string): string { + const trimmed = reference.trim().replace(/^#/, ""); + const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed); + return urlMatch?.[1] ?? trimmed; +} + +function normalizeSourceBranch(headSelector: string): string { + return ( + SourceControlProvider.parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim() + ); +} + +function sourceBranch(input: { + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; +}): string { + return input.source?.refName ?? normalizeSourceBranch(input.headSelector); +} + +function toAzureStatus(state: "open" | "closed" | "merged" | "all"): string { + switch (state) { + case "open": + return "active"; + case "closed": + return "abandoned"; + case "merged": + return "completed"; + case "all": + return "all"; + } +} + +const RawAzureDevOpsRepositorySchema = Schema.Struct({ + name: TrimmedNonEmptyString, + webUrl: TrimmedNonEmptyString, + remoteUrl: TrimmedNonEmptyString, + sshUrl: TrimmedNonEmptyString, + project: Schema.optional( + Schema.Struct({ + name: TrimmedNonEmptyString, + }), + ), + defaultBranch: Schema.optional(Schema.NullOr(Schema.String)), +}); + +function normalizeDefaultBranch(value: string | null | undefined): string | null { + const trimmed = value?.trim().replace(/^refs\/heads\//, "") ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeRepositoryCloneUrls( + raw: Schema.Schema.Type, +): AzureDevOpsRepositoryCloneUrls { + const projectName = raw.project?.name.trim(); + return { + nameWithOwner: projectName ? `${projectName}/${raw.name}` : raw.name, + url: raw.remoteUrl, + sshUrl: raw.sshUrl, + }; +} + +function parseRepositorySpecifier(repository: string): { + readonly project: string | null; + readonly name: string; +} { + const parts = repository + .split("/") + .map((part) => part.trim()) + .filter((part) => part.length > 0); + return { + project: parts.length > 1 ? (parts.at(-2) ?? null) : null, + name: parts.at(-1) ?? repository.trim(), + }; +} + +function decodeAzureDevOpsJson( + raw: string, + schema: S, + operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", + invalidDetail: string, +): Effect.Effect { + return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError( + (error) => + new AzureDevOpsCliError({ + operation, + detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, + cause: error, + }), + ), + ); +} + +export const make = Effect.fn("makeAzureDevOpsCli")(function* () { + const process = yield* VcsProcess.VcsProcess; + + const execute: AzureDevOpsCliShape["execute"] = (input) => + process + .run({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: input.args, + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }) + .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error))); + + const executeJson = (input: Parameters[0]) => + execute({ + ...input, + args: [...input.args, "--only-show-errors", "--output", "json"], + }); + + return AzureDevOpsCli.of({ + execute, + listPullRequests: (input) => + executeJson({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "list", + "--detect", + "true", + "--source-branch", + sourceBranch(input), + "--status", + toAzureStatus(input.state), + "--top", + String(input.limit ?? 20), + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + raw.length === 0 + ? Effect.succeed([]) + : Effect.sync(() => + AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestListJson(raw), + ).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new AzureDevOpsCliError({ + operation: "listPullRequests", + detail: `Azure DevOps CLI returned invalid PR list JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed(decoded.success); + }), + ), + ), + ), + getPullRequest: (input) => + executeJson({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "show", + "--detect", + "true", + "--id", + normalizeChangeRequestId(input.reference), + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + Effect.sync(() => AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new AzureDevOpsCliError({ + operation: "getPullRequest", + detail: `Azure DevOps CLI returned invalid pull request JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed(decoded.success); + }), + ), + ), + ), + getRepositoryCloneUrls: (input) => + executeJson({ + cwd: input.cwd, + args: ["repos", "show", "--detect", "true", "--repository", input.repository], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeAzureDevOpsJson( + raw, + RawAzureDevOpsRepositorySchema, + "getRepositoryCloneUrls", + "Azure DevOps CLI returned invalid repository JSON.", + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ), + createRepository: (input) => { + const repository = parseRepositorySpecifier(input.repository); + // Azure Repos access is governed by project/organization permissions. + // `az repos create` does not expose a per-repository visibility flag, so + // the generic source-control visibility input is intentionally not + // translated into CLI args for this provider. + return executeJson({ + cwd: input.cwd, + args: [ + "repos", + "create", + "--detect", + "true", + "--name", + repository.name, + ...(repository.project ? ["--project", repository.project] : []), + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeAzureDevOpsJson( + raw, + RawAzureDevOpsRepositorySchema, + "createRepository", + "Azure DevOps CLI returned invalid repository JSON.", + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ); + }, + createPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "create", + "--only-show-errors", + "--detect", + "true", + "--target-branch", + input.target?.refName ?? input.baseBranch, + "--source-branch", + sourceBranch(input), + "--title", + input.title, + "--description", + `@${input.bodyFile}`, + ], + }).pipe(Effect.asVoid), + getDefaultBranch: (input) => + executeJson({ + cwd: input.cwd, + args: ["repos", "show", "--detect", "true"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeAzureDevOpsJson( + raw, + RawAzureDevOpsRepositorySchema, + "getDefaultBranch", + "Azure DevOps CLI returned invalid repository JSON.", + ), + ), + Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), + ), + checkoutPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "checkout", + "--only-show-errors", + "--detect", + "true", + "--id", + normalizeChangeRequestId(input.reference), + "--remote-name", + input.remoteName ?? "origin", + ], + }).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(AzureDevOpsCli, make()); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts new file mode 100644 index 0000000000..c46bc0ee7f --- /dev/null +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -0,0 +1,91 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; + +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; + +function makeProvider(azure: Partial) { + return AzureDevOpsSourceControlProvider.make().pipe( + Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)), + ); +} + +it.effect("maps Azure DevOps PR summaries into provider-neutral change requests", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + getPullRequest: () => + Effect.succeed({ + number: 42, + title: "Add Azure provider", + url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + }), + }); + + const changeRequest = yield* provider.getChangeRequest({ + cwd: "/repo", + reference: "42", + }); + + assert.deepStrictEqual(changeRequest, { + provider: "azure-devops", + number: 42, + title: "Add Azure provider", + url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + isCrossRepository: false, + }); + }), +); + +it.effect("creates Azure DevOps 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; + }, + }); + + yield* provider.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + + assert.deepStrictEqual(createInput, { + cwd: "/repo", + baseBranch: "main", + headSelector: "feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + }), +); + +it.effect("uses Azure CLI repository detection for default branch lookup", () => + Effect.gen(function* () { + let cwdInput: string | null = null; + const provider = yield* makeProvider({ + getDefaultBranch: (input) => { + cwdInput = input.cwd; + return Effect.succeed("main"); + }, + }); + + const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); + + assert.strictEqual(defaultBranch, "main"); + assert.strictEqual(cwdInput, "/repo"); + }), +); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts new file mode 100644 index 0000000000..6e4b1eb68f --- /dev/null +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -0,0 +1,145 @@ +import { Effect, Layer } from "effect"; +import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; + +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; + +function providerError( + operation: string, + cause: AzureDevOpsCli.AzureDevOpsCliError, +): SourceControlProviderError { + return new SourceControlProviderError({ + provider: "azure-devops", + operation, + detail: cause.detail, + cause, + }); +} + +function parseAzureAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { + const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); + + if (input.exitCode !== 0) { + return SourceControlProviderDiscovery.providerAuth({ + status: "unauthenticated", + detail: + SourceControlProviderDiscovery.firstSafeAuthLine( + SourceControlProviderDiscovery.combinedAuthOutput(input), + ) ?? "Run `az login` to authenticate Azure CLI.", + }); + } + + if (account && account.length > 0) { + return SourceControlProviderDiscovery.providerAuth({ + status: "authenticated", + account, + host: "dev.azure.com", + }); + } + + return SourceControlProviderDiscovery.providerAuth({ + status: "unknown", + host: "dev.azure.com", + detail: "Azure CLI account status could not be parsed.", + }); +} + +export const discovery = { + type: "cli", + kind: "azure-devops", + label: "Azure DevOps", + executable: "az", + versionArgs: ["--version"], + authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], + parseAuth: parseAzureAuth, + implemented: true, + installHint: + "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.", +} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; + +function toChangeRequest(summary: { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: ChangeRequest["updatedAt"]; +}): ChangeRequest { + return { + provider: "azure-devops", + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state, + updatedAt: summary.updatedAt, + isCrossRepository: false, + }; +} + +export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* () { + const azure = yield* AzureDevOpsCli.AzureDevOpsCli; + + return SourceControlProvider.SourceControlProvider.of({ + kind: "azure-devops", + listChangeRequests: (input) => { + const source = SourceControlProvider.sourceControlRefFromInput(input); + return azure + .listPullRequests({ + cwd: input.cwd, + headSelector: input.headSelector, + ...(source ? { source } : {}), + state: input.state, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((items) => items.map(toChangeRequest)), + Effect.mapError((error) => providerError("listChangeRequests", error)), + ); + }, + getChangeRequest: (input) => + azure.getPullRequest(input).pipe( + Effect.map(toChangeRequest), + Effect.mapError((error) => providerError("getChangeRequest", error)), + ), + createChangeRequest: (input) => { + const source = SourceControlProvider.sourceControlRefFromInput(input); + return azure + .createPullRequest({ + cwd: input.cwd, + baseBranch: input.baseRefName, + headSelector: input.headSelector, + ...(source ? { source } : {}), + ...(input.target ? { target: input.target } : {}), + title: input.title, + bodyFile: input.bodyFile, + }) + .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + }, + getRepositoryCloneUrls: (input) => + azure + .getRepositoryCloneUrls(input) + .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + createRepository: (input) => + azure + .createRepository(input) + .pipe(Effect.mapError((error) => providerError("createRepository", error))), + getDefaultBranch: (input) => + azure + .getDefaultBranch({ cwd: input.cwd }) + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + checkoutChangeRequest: (input) => + azure + .checkoutPullRequest({ + cwd: input.cwd, + reference: input.reference, + ...(input.context ? { remoteName: input.context.remoteName } : {}), + }) + .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + }); +}); + +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts new file mode 100644 index 0000000000..5542ea4092 --- /dev/null +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -0,0 +1,513 @@ +import { assert, it, vi } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ConfigProvider, DateTime, Effect, FileSystem, Layer, Option } from "effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import * as BitbucketApi from "./BitbucketApi.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import type * as VcsDriver from "../vcs/VcsDriver.ts"; + +const bitbucketPullRequest = { + id: 42, + title: "Add Bitbucket provider", + state: "OPEN", + updated_on: "2026-01-02T00:00:00.000Z", + links: { + html: { + href: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", + }, + }, + source: { + branch: { name: "feature/source-control" }, + repository: { + full_name: "octocat/t3code", + workspace: { slug: "octocat" }, + }, + }, + destination: { + branch: { name: "main" }, + repository: { + full_name: "pingdotgg/t3code", + workspace: { slug: "pingdotgg" }, + }, + }, +}; + +const repositoryJson = { + full_name: "pingdotgg/t3code", + links: { + html: { href: "https://bitbucket.org/pingdotgg/t3code" }, + clone: [ + { name: "https", href: "https://bitbucket.org/pingdotgg/t3code.git" }, + { name: "ssh", href: "git@bitbucket.org:pingdotgg/t3code.git" }, + ], + }, + mainbranch: { name: "main" }, +}; + +function makeLayer(input: { + readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; + readonly git?: Partial; +}) { + const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => + Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), + ); + const gitMock = { + readConfigValue: vi.fn(() => + Effect.succeed("git@bitbucket.org:pingdotgg/t3code.git"), + ), + resolvePrimaryRemoteName: vi.fn( + () => Effect.succeed("origin"), + ), + ensureRemote: vi.fn(() => + Effect.succeed("octocat"), + ), + fetchRemoteBranch: vi.fn( + () => Effect.void, + ), + fetchRemoteTrackingBranch: vi.fn( + () => Effect.void, + ), + setBranchUpstream: vi.fn( + () => Effect.void, + ), + switchRef: vi.fn((request) => + Effect.succeed({ refName: request.refName }), + ), + listLocalBranchNames: vi.fn(() => + Effect.succeed([]), + ), + }; + const git = { + ...gitMock, + ...input.git, + } satisfies Partial; + + const driver = { + listRemotes: () => + Effect.succeed({ + remotes: [ + { + name: "origin", + url: "git@bitbucket.org:pingdotgg/t3code.git", + pushUrl: Option.none(), + isPrimary: true, + }, + ], + freshness: { + source: "live-local" as const, + observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), + expiresAt: Option.none(), + }, + }), + } satisfies Partial; + + const layer = BitbucketApi.layer.pipe( + Layer.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => execute(request)), + ), + ), + Layer.provide( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + resolve: () => + Effect.succeed({ + kind: "git", + repository: { + kind: "git", + rootPath: "/repo", + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), + expiresAt: Option.none(), + }, + }, + driver: driver as unknown as VcsDriver.VcsDriverShape, + }), + }), + ), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(git)), + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0", + T3CODE_BITBUCKET_EMAIL: "user@example.com", + T3CODE_BITBUCKET_API_TOKEN: "token", + }, + }), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return { execute, git: gitMock, layer }; +} + +it.effect("parses pull request responses from the Bitbucket REST API", () => { + const { execute, layer } = makeLayer({ + response: () => + Response.json({ + ...bitbucketPullRequest, + }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const result = yield* bitbucket.getPullRequest({ + cwd: "/repo", + reference: "#42", + }); + + assert.deepStrictEqual(result, { + number: 42, + title: "Add Bitbucket provider", + url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/t3code", + headRepositoryOwnerLogin: "octocat", + }); + assert.strictEqual( + execute.mock.calls[0]?.[0].url, + "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests/42", + ); + }).pipe(Effect.provide(layer)); +}); + +it.effect("lists pull requests with Bitbucket state and source branch query params", () => { + const { execute, layer } = makeLayer({ + response: () => + Response.json({ + values: [ + { + ...bitbucketPullRequest, + id: 7, + state: "MERGED", + source: { + branch: { name: "feature/merged" }, + repository: { full_name: "pingdotgg/t3code" }, + }, + }, + ], + }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const result = yield* bitbucket.listPullRequests({ + cwd: "/repo", + headSelector: "origin:feature/merged", + state: "merged", + limit: 10, + }); + + assert.strictEqual(result[0]?.state, "merged"); + const request = execute.mock.calls[0]?.[0]; + assert.strictEqual( + request?.url, + "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests", + ); + assert.deepStrictEqual(request?.urlParams.params, [ + ["pagelen", "10"], + ["sort", "-updated_on"], + ["q", 'source.branch.name = "feature/merged" AND state = "MERGED"'], + ["state", "MERGED"], + ]); + }).pipe(Effect.provide(layer)); +}); + +it.effect("lists closed pull requests with both closed Bitbucket states", () => { + const { execute, layer } = makeLayer({ + response: () => + Response.json({ + values: [], + }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + yield* bitbucket.listPullRequests({ + cwd: "/repo", + headSelector: "feature/closed", + state: "closed", + limit: 10, + }); + + assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [ + ["pagelen", "10"], + ["sort", "-updated_on"], + [ + "q", + 'source.branch.name = "feature/closed" AND (state = "DECLINED" OR state = "SUPERSEDED")', + ], + ["state", "DECLINED"], + ["state", "SUPERSEDED"], + ]); + }).pipe(Effect.provide(layer)); +}); + +it.effect("expands all-state pull request listing instead of relying on Bitbucket defaults", () => { + const { execute, layer } = makeLayer({ + response: () => + Response.json({ + values: [], + }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + yield* bitbucket.listPullRequests({ + cwd: "/repo", + headSelector: "feature/all", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [ + ["pagelen", "10"], + ["sort", "-updated_on"], + [ + "q", + 'source.branch.name = "feature/all" AND (state = "OPEN" OR state = "MERGED" OR state = "DECLINED" OR state = "SUPERSEDED")', + ], + ["state", "OPEN"], + ["state", "MERGED"], + ["state", "DECLINED"], + ["state", "SUPERSEDED"], + ]); + }).pipe(Effect.provide(layer)); +}); + +it.effect("reads repository clone URLs and default branch", () => { + const { layer } = makeLayer({ + response: () => Response.json(repositoryJson), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const cloneUrls = yield* bitbucket.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "pingdotgg/t3code", + }); + const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" }); + + assert.deepStrictEqual(cloneUrls, { + nameWithOwner: "pingdotgg/t3code", + url: "https://bitbucket.org/pingdotgg/t3code.git", + sshUrl: "git@bitbucket.org:pingdotgg/t3code.git", + }); + assert.strictEqual(defaultBranch, "main"); + }).pipe(Effect.provide(layer)); +}); + +it.effect("creates repositories through the Bitbucket REST API", () => { + const { execute, layer } = makeLayer({ + response: () => Response.json(repositoryJson), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const cloneUrls = yield* bitbucket.createRepository({ + cwd: "/repo", + repository: "pingdotgg/t3code", + visibility: "private", + }); + + assert.deepStrictEqual(cloneUrls, { + nameWithOwner: "pingdotgg/t3code", + url: "https://bitbucket.org/pingdotgg/t3code.git", + sshUrl: "git@bitbucket.org:pingdotgg/t3code.git", + }); + + const request = execute.mock.calls[0]?.[0]; + assert.strictEqual(request?.url, "https://api.test.local/2.0/repositories/pingdotgg/t3code"); + assert.strictEqual(request?.method, "POST"); + assert.ok(request); + const rawBody = (request.body as { readonly body?: Uint8Array }).body; + assert.ok(rawBody); + assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), { + scm: "git", + is_private: true, + }); + }).pipe(Effect.provide(layer)); +}); + +it.effect("creates pull requests using the official REST payload shape", () => { + const { execute, layer } = makeLayer({ + response: () => Response.json(bitbucketPullRequest), + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const bodyFile = yield* fileSystem.makeTempFileScoped({ prefix: "bitbucket-pr-body-" }); + yield* fileSystem.writeFileString(bodyFile, "PR body"); + + const bitbucket = yield* BitbucketApi.BitbucketApi; + yield* bitbucket.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "owner:feature/provider", + title: "Provider PR", + bodyFile, + }); + + const request = execute.mock.calls[0]?.[0]; + assert.strictEqual( + request?.url, + "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests", + ); + assert.strictEqual(request?.method, "POST"); + assert.ok(request); + const rawBody = (request.body as { readonly body?: Uint8Array }).body; + assert.ok(rawBody); + assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), { + title: "Provider PR", + description: "PR body", + source: { + branch: { name: "feature/provider" }, + repository: { full_name: "owner/t3code" }, + }, + destination: { + branch: { name: "main" }, + }, + }); + }).pipe(Effect.provide(layer), Effect.scoped); +}); + +it.effect("reports auth status through the Bitbucket REST /user endpoint", () => { + const { layer } = makeLayer({ + response: () => Response.json({ username: "bitbucket-user" }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const auth = yield* bitbucket.probeAuth; + + assert.deepStrictEqual(auth, { + status: "authenticated", + account: Option.some("bitbucket-user"), + host: Option.some("bitbucket.org"), + detail: Option.none(), + }); + }).pipe(Effect.provide(layer)); +}); + +it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { + const { git, layer } = makeLayer({ + response: () => + Response.json({ + ...bitbucketPullRequest, + source: { + branch: { name: "feature/source-control" }, + repository: { + full_name: "pingdotgg/t3code", + workspace: { slug: "pingdotgg" }, + }, + }, + }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + yield* bitbucket.checkoutPullRequest({ + cwd: "/repo", + context: { + provider: { + kind: "bitbucket", + name: "Bitbucket", + baseUrl: "https://bitbucket.org", + }, + remoteName: "origin", + remoteUrl: "git@bitbucket.org:pingdotgg/t3code.git", + }, + reference: "42", + force: true, + }); + + assert.strictEqual(git.ensureRemote.mock.calls.length, 0); + assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], { + cwd: "/repo", + remoteName: "origin", + remoteBranch: "feature/source-control", + localBranch: "feature/source-control", + }); + assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], { + cwd: "/repo", + branch: "feature/source-control", + remoteName: "origin", + remoteBranch: "feature/source-control", + }); + assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], { + cwd: "/repo", + refName: "feature/source-control", + }); + }).pipe(Effect.provide(layer)); +}); + +it.effect("checks out fork pull requests through an ensured fork remote", () => { + const { git, layer } = makeLayer({ + response: (request) => { + if (request.url.endsWith("/repositories/octocat/t3code")) { + return Response.json({ + ...repositoryJson, + full_name: "octocat/t3code", + links: { + html: { href: "https://bitbucket.org/octocat/t3code" }, + clone: [ + { name: "https", href: "https://bitbucket.org/octocat/t3code.git" }, + { name: "ssh", href: "git@bitbucket.org:octocat/t3code.git" }, + ], + }, + }); + } + return Response.json({ + ...bitbucketPullRequest, + source: { + branch: { name: "main" }, + repository: { + full_name: "octocat/t3code", + workspace: { slug: "octocat" }, + }, + }, + }); + }, + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + yield* bitbucket.checkoutPullRequest({ + cwd: "/repo", + reference: "42", + force: true, + }); + + assert.deepStrictEqual(git.ensureRemote.mock.calls[0]?.[0], { + cwd: "/repo", + preferredName: "octocat", + url: "git@bitbucket.org:octocat/t3code.git", + }); + assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], { + cwd: "/repo", + remoteName: "octocat", + remoteBranch: "main", + localBranch: "t3code/pr-42/main", + }); + assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], { + cwd: "/repo", + branch: "t3code/pr-42/main", + remoteName: "octocat", + remoteBranch: "main", + }); + assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], { + cwd: "/repo", + refName: "t3code/pr-42/main", + }); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts new file mode 100644 index 0000000000..de6d75a634 --- /dev/null +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -0,0 +1,716 @@ +import { Config, Context, Effect, FileSystem, Layer, Option, Schema } from "effect"; +import { + TrimmedNonEmptyString, + type SourceControlProviderAuth, + type SourceControlRepositoryCloneUrls, + type SourceControlRepositoryVisibility, +} from "@t3tools/contracts"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { sanitizeBranchFragment } from "@t3tools/shared/git"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; + +import * as BitbucketPullRequests from "./bitbucketPullRequests.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; + +const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; + +const BitbucketApiEnvConfig = Config.all({ + baseUrl: Config.string("T3CODE_BITBUCKET_API_BASE_URL").pipe( + Config.withDefault(DEFAULT_API_BASE_URL), + ), + accessToken: Config.string("T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option), + email: Config.string("T3CODE_BITBUCKET_EMAIL").pipe(Config.option), + apiToken: Config.string("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), +}); + +export class BitbucketApiError extends Schema.TaggedErrorClass()( + "BitbucketApiError", + { + operation: Schema.String, + detail: Schema.String, + status: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: ${this.detail}`; + } +} + +const RawBitbucketRepositorySchema = Schema.Struct({ + full_name: TrimmedNonEmptyString, + links: Schema.Struct({ + html: Schema.optional( + Schema.Struct({ + href: TrimmedNonEmptyString, + }), + ), + clone: Schema.optional( + Schema.Array( + Schema.Struct({ + name: TrimmedNonEmptyString, + href: TrimmedNonEmptyString, + }), + ), + ), + }), + mainbranch: Schema.optional( + Schema.NullOr( + Schema.Struct({ + name: TrimmedNonEmptyString, + }), + ), + ), +}); + +const BitbucketUserSchema = Schema.Struct({ + username: Schema.optional(TrimmedNonEmptyString), + display_name: Schema.optional(TrimmedNonEmptyString), + account_id: Schema.optional(TrimmedNonEmptyString), +}); + +export interface BitbucketRepositoryLocator { + readonly workspace: string; + readonly repoSlug: string; +} + +export interface BitbucketApiShape { + readonly probeAuth: Effect.Effect; + readonly listPullRequests: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect< + ReadonlyArray, + BitbucketApiError + >; + readonly getPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + }) => Effect.Effect< + BitbucketPullRequests.NormalizedBitbucketPullRequestRecord, + BitbucketApiError + >; + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + 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 context?: SourceControlProvider.SourceControlProviderContext; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + readonly getDefaultBranch: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + }) => Effect.Effect; + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; +} + +export class BitbucketApi extends Context.Service()( + "t3/source-control/BitbucketApi", +) {} + +function nonEmpty(value: string | undefined): Option.Option { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +function normalizeChangeRequestId(reference: string): string { + const trimmed = reference.trim().replace(/^#/, ""); + const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec( + trimmed, + ); + return urlMatch?.[1] ?? trimmed; +} + +function normalizeSourceBranch(headSelector: string): string { + return ( + SourceControlProvider.parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim() + ); +} + +function sourceBranch(input: { + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; +}): string { + return input.source?.refName ?? normalizeSourceBranch(input.headSelector); +} + +function sourceWorkspace(input: { + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; +}): string | undefined { + if (input.source?.owner) return input.source.owner; + return SourceControlProvider.parseSourceControlOwnerRef(input.headSelector)?.owner; +} + +function toBitbucketStates(state: "open" | "closed" | "merged" | "all"): ReadonlyArray { + switch (state) { + case "open": + return ["OPEN"]; + case "closed": + return ["DECLINED", "SUPERSEDED"]; + case "merged": + return ["MERGED"]; + case "all": + return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]; + } +} + +function bitbucketQueryString(filters: ReadonlyArray): string { + return filters.join(" AND "); +} + +function bitbucketStateFilter(states: ReadonlyArray): string { + return states.length === 1 + ? `state = "${states[0]}"` + : `(${states.map((state) => `state = "${state}"`).join(" OR ")})`; +} + +function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator | null { + const normalized = value.trim().replace(/\.git$/u, ""); + const parts = normalized.split("/").filter((part) => part.length > 0); + if (parts.length < 2) return null; + const workspace = parts.at(-2); + const repoSlug = parts.at(-1); + return workspace && repoSlug ? { workspace, repoSlug } : null; +} + +function requireRepositoryLocator( + operation: string, + repository: string, +): Effect.Effect { + const locator = parseBitbucketRepositorySlug(repository); + return locator + ? Effect.succeed(locator) + : Effect.fail( + new BitbucketApiError({ + operation, + detail: "Bitbucket repositories must be specified as workspace/repository.", + }), + ); +} + +function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null { + const trimmed = remoteUrl.trim(); + if (trimmed.startsWith("git@")) { + const pathStart = trimmed.indexOf(":"); + return pathStart < 0 ? null : parseBitbucketRepositorySlug(trimmed.slice(pathStart + 1)); + } + + try { + return parseBitbucketRepositorySlug(new URL(trimmed).pathname); + } catch { + return null; + } +} + +function normalizeRepositoryCloneUrls( + raw: Schema.Schema.Type, +): SourceControlRepositoryCloneUrls { + const httpClone = + raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ?? + raw.links.html?.href; + const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href; + + return { + nameWithOwner: raw.full_name, + url: httpClone ?? raw.links.html?.href ?? raw.full_name, + sshUrl: sshClone ?? httpClone ?? raw.full_name, + }; +} + +function shouldPreferSshRemote(originRemoteUrl: string | null): boolean { + const trimmed = originRemoteUrl?.trim() ?? ""; + return trimmed.startsWith("git@") || trimmed.startsWith("ssh://"); +} + +function selectCloneUrl(input: { + readonly cloneUrls: SourceControlRepositoryCloneUrls; + readonly originRemoteUrl: string | null; +}): string { + return shouldPreferSshRemote(input.originRemoteUrl) + ? input.cloneUrls.sshUrl + : input.cloneUrls.url; +} + +function checkoutBranchName(input: { + readonly pullRequestId: number; + readonly headBranch: string; + readonly isCrossRepository: boolean; +}): string { + if (!input.isCrossRepository) { + return input.headBranch; + } + + return `t3code/pr-${input.pullRequestId}/${sanitizeBranchFragment(input.headBranch)}`; +} + +function repositoryNameWithOwner( + repository: Schema.Schema.Type< + typeof BitbucketPullRequests.BitbucketPullRequestSchema + >["source"]["repository"], +): string | null { + const fullName = repository?.full_name?.trim() ?? ""; + return fullName.length > 0 ? fullName : null; +} + +function repositoryOwnerName(repositoryName: string): string { + return repositoryName.split("/")[0]?.trim() || "bitbucket"; +} + +function authFromConfig( + config: Config.Success, +): SourceControlProviderAuth { + if (Option.isSome(config.accessToken)) { + return { + status: "unknown", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.some("Bitbucket access token is configured."), + }; + } + + if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { + return { + status: "unknown", + account: config.email, + host: Option.some("bitbucket.org"), + detail: Option.some("Bitbucket API token is configured."), + }; + } + + return { + status: "unauthenticated", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.some( + "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + ), + }; +} + +function requestError(operation: string, cause: unknown): BitbucketApiError { + return new BitbucketApiError({ + operation, + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }); +} + +function isBitbucketApiError(cause: unknown): cause is BitbucketApiError { + return Schema.is(BitbucketApiError)(cause); +} + +function responseError( + operation: string, + response: HttpClientResponse.HttpClientResponse, +): Effect.Effect { + return response.text.pipe( + Effect.catch(() => Effect.succeed("")), + Effect.flatMap((body) => + Effect.fail( + new BitbucketApiError({ + operation, + status: response.status, + detail: + body.trim().length > 0 + ? `Bitbucket returned HTTP ${response.status}: ${body.trim()}` + : `Bitbucket returned HTTP ${response.status}.`, + }), + ), + ), + ); +} + +export const make = Effect.fn("makeBitbucketApi")(function* () { + const config = yield* BitbucketApiEnvConfig; + const httpClient = yield* HttpClient.HttpClient; + const fileSystem = yield* FileSystem.FileSystem; + const git = yield* GitVcsDriver.GitVcsDriver; + const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; + + const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; + + const withAuth = (request: HttpClientRequest.HttpClientRequest) => { + if (Option.isSome(config.accessToken)) { + return request.pipe(HttpClientRequest.bearerToken(config.accessToken.value)); + } + if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { + return request.pipe(HttpClientRequest.basicAuth(config.email.value, config.apiToken.value)); + } + return request; + }; + + const decodeResponse = ( + operation: string, + schema: S, + response: HttpClientResponse.HttpClientResponse, + ): Effect.Effect => + HttpClientResponse.matchStatus({ + "2xx": (success) => + HttpClientResponse.schemaBodyJson(schema)(success).pipe( + Effect.mapError( + (cause) => + new BitbucketApiError({ + operation, + detail: "Bitbucket returned invalid JSON for the requested resource.", + cause, + }), + ), + ), + orElse: (failed) => responseError(operation, failed), + })(response); + + const executeJson = ( + operation: string, + request: HttpClientRequest.HttpClientRequest, + schema: S, + ): Effect.Effect => + httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( + Effect.mapError((cause) => requestError(operation, cause)), + Effect.flatMap((response) => decodeResponse(operation, schema, response)), + ); + + const resolveRepository = Effect.fn("BitbucketApi.resolveRepository")(function* (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository?: string; + }) { + const fromRepository = + input.repository !== undefined ? parseBitbucketRepositorySlug(input.repository) : null; + if (fromRepository) return fromRepository; + + const fromContext = + input.context?.provider.kind === "bitbucket" + ? parseBitbucketRemoteUrl(input.context.remoteUrl) + : null; + if (fromContext) return fromContext; + + const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe( + Effect.mapError( + (cause) => + new BitbucketApiError({ + operation: "resolveRepository", + detail: `Failed to resolve VCS repository for ${input.cwd}.`, + cause, + }), + ), + ); + const remotes = yield* handle.driver.listRemotes(input.cwd).pipe( + Effect.mapError( + (cause) => + new BitbucketApiError({ + operation: "resolveRepository", + detail: `Failed to list remotes for ${input.cwd}.`, + cause, + }), + ), + ); + + for (const remote of remotes.remotes) { + if (detectSourceControlProviderFromRemoteUrl(remote.url)?.kind !== "bitbucket") continue; + const parsed = parseBitbucketRemoteUrl(remote.url); + if (parsed) return parsed; + } + + return yield* new BitbucketApiError({ + operation: "resolveRepository", + detail: `No Bitbucket repository remote was detected for ${input.cwd}.`, + }); + }); + + const getRepository = (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository?: string; + }) => + resolveRepository(input).pipe( + Effect.flatMap((repository) => + executeJson( + "getRepository", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, + ), + ), + RawBitbucketRepositorySchema, + ), + ), + ); + + const getRawPullRequestFromRepository = ( + repository: BitbucketRepositoryLocator, + reference: string, + ) => + executeJson( + "getPullRequest", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`, + ), + ), + BitbucketPullRequests.BitbucketPullRequestSchema, + ); + + const getRawPullRequest = (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + }) => + resolveRepository(input).pipe( + Effect.flatMap((repository) => getRawPullRequestFromRepository(repository, input.reference)), + ); + + const readConfigValueNullable = (cwd: string, key: string) => + git.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); + + const resolveCheckoutRemote = Effect.fn("BitbucketApi.resolveCheckoutRemote")(function* (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly destinationRepository: BitbucketRepositoryLocator; + readonly sourceRepositoryName: string; + readonly isCrossRepository: boolean; + }) { + if ( + input.context?.provider.kind === "bitbucket" && + !input.isCrossRepository && + parseBitbucketRemoteUrl(input.context.remoteUrl) !== null + ) { + return input.context.remoteName; + } + + if (!input.isCrossRepository) { + const remoteName = yield* git + .resolvePrimaryRemoteName(input.cwd) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (remoteName) return remoteName; + } + + const cloneUrls = yield* getRepository({ + cwd: input.cwd, + repository: input.sourceRepositoryName, + ...(input.context ? { context: input.context } : {}), + }).pipe(Effect.map(normalizeRepositoryCloneUrls)); + const originRemoteUrl = yield* readConfigValueNullable(input.cwd, "remote.origin.url"); + return yield* git.ensureRemote({ + cwd: input.cwd, + preferredName: input.isCrossRepository + ? repositoryOwnerName(input.sourceRepositoryName) + : input.destinationRepository.workspace, + url: selectCloneUrl({ cloneUrls, originRemoteUrl }), + }); + }); + + return BitbucketApi.of({ + probeAuth: executeJson( + "probeAuth", + HttpClientRequest.get(apiUrl("/user")), + BitbucketUserSchema, + ).pipe( + Effect.map((user) => ({ + status: "authenticated" as const, + account: nonEmpty(user.username ?? user.display_name ?? user.account_id), + host: Option.some("bitbucket.org"), + detail: Option.none(), + })), + Effect.catch(() => Effect.succeed(authFromConfig(config))), + ), + listPullRequests: (input) => + resolveRepository(input).pipe( + Effect.flatMap((repository) => { + const states = toBitbucketStates(input.state); + const query: Record> = { + pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))), + sort: "-updated_on", + q: bitbucketQueryString([ + `source.branch.name = "${sourceBranch(input).replaceAll('"', '\\"')}"`, + bitbucketStateFilter(states), + ]), + state: states, + }; + + return executeJson( + "listPullRequests", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, + ), + { urlParams: query }, + ), + BitbucketPullRequests.BitbucketPullRequestListSchema, + ); + }), + Effect.map((list) => + list.values.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord), + ), + ), + getPullRequest: (input) => + getRawPullRequest(input).pipe( + Effect.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord), + ), + getRepositoryCloneUrls: (input) => + getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), + createRepository: (input) => + requireRepositoryLocator("createRepository", input.repository).pipe( + Effect.flatMap((repository) => + executeJson( + "createRepository", + HttpClientRequest.post( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, + ), + ).pipe( + HttpClientRequest.bodyJsonUnsafe({ + scm: "git", + is_private: input.visibility === "private", + }), + ), + RawBitbucketRepositorySchema, + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ), + createPullRequest: (input) => + Effect.gen(function* () { + const repository = yield* resolveRepository(input); + const description = yield* fileSystem.readFileString(input.bodyFile).pipe( + Effect.mapError( + (cause) => + new BitbucketApiError({ + operation: "createPullRequest", + detail: `Failed to read pull request body file ${input.bodyFile}.`, + cause, + }), + ), + ); + const sourceOwner = sourceWorkspace(input); + const body = { + title: input.title, + description, + source: { + branch: { + name: sourceBranch(input), + }, + ...(sourceOwner + ? { + repository: { + full_name: `${sourceOwner}/${input.source?.repository ?? repository.repoSlug}`, + }, + } + : {}), + }, + destination: { + branch: { + name: input.target?.refName ?? input.baseBranch, + }, + }, + }; + + yield* executeJson( + "createPullRequest", + HttpClientRequest.post( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, + ), + ).pipe(HttpClientRequest.bodyJsonUnsafe(body)), + BitbucketPullRequests.BitbucketPullRequestSchema, + ); + }), + getDefaultBranch: (input) => + getRepository(input).pipe(Effect.map((repository) => repository.mainbranch?.name ?? null)), + // Bitbucket Cloud pull requests are Git-backed and Bitbucket does not provide + // an official checkout CLI. This provider-local path uses GitVcsDriver as a + // narrow escape hatch to materialize Bitbucket PR refs. Do not generalize this + // as the source-control provider model: if we support non-Git-compatible + // hosting providers or native JJ/Sapling checkout flows, move this into a + // VCS-specific change-request checkout capability. + checkoutPullRequest: (input) => + Effect.gen(function* () { + const destinationRepository = yield* resolveRepository(input); + const pullRequest = yield* getRawPullRequestFromRepository( + destinationRepository, + input.reference, + ); + const destinationRepositoryName = + repositoryNameWithOwner(pullRequest.destination.repository) ?? + `${destinationRepository.workspace}/${destinationRepository.repoSlug}`; + const sourceRepositoryName = + repositoryNameWithOwner(pullRequest.source.repository) ?? destinationRepositoryName; + const isCrossRepository = sourceRepositoryName !== destinationRepositoryName; + const remoteName = yield* resolveCheckoutRemote({ + cwd: input.cwd, + destinationRepository, + sourceRepositoryName, + isCrossRepository, + ...(input.context ? { context: input.context } : {}), + }); + const remoteBranch = pullRequest.source.branch.name; + const localBranch = checkoutBranchName({ + pullRequestId: pullRequest.id, + headBranch: remoteBranch, + isCrossRepository, + }); + const localBranchNames = yield* git.listLocalBranchNames(input.cwd); + const localBranchExists = localBranchNames.includes(localBranch); + + if (input.force === true || !localBranchExists) { + yield* git.fetchRemoteBranch({ + cwd: input.cwd, + remoteName, + remoteBranch, + localBranch, + }); + } else { + yield* git.fetchRemoteTrackingBranch({ + cwd: input.cwd, + remoteName, + remoteBranch, + }); + } + + yield* git.setBranchUpstream({ + cwd: input.cwd, + branch: localBranch, + remoteName, + remoteBranch, + }); + yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); + }).pipe( + Effect.mapError((cause) => + isBitbucketApiError(cause) + ? cause + : new BitbucketApiError({ + operation: "checkoutPullRequest", + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + ), + ), + }); +}); + +export const layer = Layer.effect(BitbucketApi, make()); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts new file mode 100644 index 0000000000..4bf658f568 --- /dev/null +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -0,0 +1,126 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; + +import * as BitbucketApi from "./BitbucketApi.ts"; +import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; + +function makeProvider(bitbucket: Partial) { + return BitbucketSourceControlProvider.make().pipe( + Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)), + ); +} + +it.effect("maps Bitbucket PR summaries into provider-neutral change requests", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + getPullRequest: () => + Effect.succeed({ + number: 42, + title: "Add Bitbucket provider", + url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }), + }); + + const changeRequest = yield* provider.getChangeRequest({ + cwd: "/repo", + reference: "42", + }); + + assert.deepStrictEqual(changeRequest, { + provider: "bitbucket", + number: 42, + title: "Add Bitbucket provider", + url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }); + }), +); + +it.effect("lists Bitbucket PRs through provider-neutral input names", () => + Effect.gen(function* () { + let listInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + listPullRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature/provider", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(listInput, { + cwd: "/repo", + headSelector: "feature/provider", + state: "all", + limit: 10, + }); + }), +); + +it.effect("creates Bitbucket 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; + }, + }); + + yield* provider.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "owner:feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + + assert.deepStrictEqual(createInput, { + cwd: "/repo", + baseBranch: "main", + headSelector: "owner:feature/provider", + source: { + owner: "owner", + refName: "feature/provider", + }, + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + }), +); + +it.effect("uses Bitbucket API repository detection for default branch lookup", () => + Effect.gen(function* () { + let cwdInput: string | null = null; + const provider = yield* makeProvider({ + getDefaultBranch: (input) => { + cwdInput = input.cwd; + return Effect.succeed("main"); + }, + }); + + const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); + + assert.strictEqual(defaultBranch, "main"); + assert.strictEqual(cwdInput, "/repo"); + }), +); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts new file mode 100644 index 0000000000..e3fe337e74 --- /dev/null +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -0,0 +1,128 @@ +import { Effect, Layer, Option } from "effect"; +import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; + +import * as BitbucketApi from "./BitbucketApi.ts"; +import * as BitbucketPullRequests from "./bitbucketPullRequests.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import type * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; + +function providerError( + operation: string, + cause: BitbucketApi.BitbucketApiError, +): SourceControlProviderError { + return new SourceControlProviderError({ + provider: "bitbucket", + operation, + detail: cause.detail, + cause, + }); +} + +function toChangeRequest( + summary: BitbucketPullRequests.NormalizedBitbucketPullRequestRecord, +): ChangeRequest { + return { + provider: "bitbucket", + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state, + updatedAt: summary.updatedAt ?? Option.none(), + ...(summary.isCrossRepository !== undefined + ? { isCrossRepository: summary.isCrossRepository } + : {}), + ...(summary.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } + : {}), + ...(summary.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } + : {}), + }; +} + +export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + return SourceControlProvider.SourceControlProvider.of({ + kind: "bitbucket", + listChangeRequests: (input) => { + const source = SourceControlProvider.sourceControlRefFromInput(input); + return bitbucket + .listPullRequests({ + cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), + headSelector: input.headSelector, + ...(source ? { source } : {}), + state: input.state, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((items) => items.map(toChangeRequest)), + Effect.mapError((error) => providerError("listChangeRequests", error)), + ); + }, + getChangeRequest: (input) => + bitbucket.getPullRequest(input).pipe( + Effect.map(toChangeRequest), + Effect.mapError((error) => providerError("getChangeRequest", error)), + ), + createChangeRequest: (input) => { + const source = SourceControlProvider.sourceControlRefFromInput(input); + return bitbucket + .createPullRequest({ + cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), + baseBranch: input.baseRefName, + headSelector: input.headSelector, + ...(source ? { source } : {}), + ...(input.target ? { target: input.target } : {}), + title: input.title, + bodyFile: input.bodyFile, + }) + .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + }, + getRepositoryCloneUrls: (input) => + bitbucket + .getRepositoryCloneUrls(input) + .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + createRepository: (input) => + bitbucket + .createRepository(input) + .pipe(Effect.mapError((error) => providerError("createRepository", error))), + getDefaultBranch: (input) => + bitbucket + .getDefaultBranch({ + cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), + }) + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + checkoutChangeRequest: (input) => + bitbucket + .checkoutPullRequest({ + cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), + reference: input.reference, + ...(input.force !== undefined ? { force: input.force } : {}), + }) + .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + }); +}); + +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); + +export const makeDiscovery = Effect.fn("makeBitbucketSourceControlProviderDiscovery")(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + return { + type: "api", + kind: "bitbucket", + label: "Bitbucket", + executable: "Bitbucket REST API", + implemented: true, + installHint: + "Create a Bitbucket API token with pull request/repository scopes, then set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN.", + probeAuth: bitbucket.probeAuth, + } satisfies SourceControlProviderDiscovery.SourceControlApiDiscoverySpec; +}); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 7f1ac44152..778e0c4962 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -1,13 +1,12 @@ -import { assert, it } from "@effect/vitest"; +import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; import { Effect, Layer } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsProcessExitError, type VcsError } from "@t3tools/contracts"; -import { afterEach, describe, expect, vi } from "vitest"; -import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; -const processOutput = (stdout: string): VcsProcessOutput => ({ +const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ exitCode: ChildProcessSpawner.ExitCode(0), stdout, stderr: "", @@ -15,11 +14,11 @@ const processOutput = (stdout: string): VcsProcessOutput => ({ stderrTruncated: false, }); -const mockRun = vi.fn<(input: VcsProcessInput) => Effect.Effect>(); +const mockRun = vi.fn(); const layer = GitHubCli.layer.pipe( Layer.provide( - Layer.mock(VcsProcess)({ + Layer.mock(VcsProcess.VcsProcess)({ run: mockRun, }), ), diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index b646613853..fe83d41ef4 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -6,12 +6,8 @@ import { type VcsError, } from "@t3tools/contracts"; -import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; -import { - decodeGitHubPullRequestJson, - decodeGitHubPullRequestListJson, - formatGitHubJsonDecodeError, -} from "./gitHubPullRequests.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubPullRequests from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -48,7 +44,7 @@ export interface GitHubCliShape { readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly listOpenPullRequests: (input: { readonly cwd: string; @@ -226,7 +222,7 @@ function decodeGitHubJson( } export const make = Effect.fn("makeGitHubCli")(function* () { - const process = yield* VcsProcess; + const process = yield* VcsProcess.VcsProcess; const execute: GitHubCliShape["execute"] = (input) => process @@ -261,13 +257,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( + : Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new GitHubCliError({ operation: "listOpenPullRequests", - detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, + detail: `GitHub CLI returned invalid PR list JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -293,13 +289,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( + Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new GitHubCliError({ operation: "getPullRequest", - detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, + detail: `GitHub CLI returned invalid pull request JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index ac3de1d974..3c4ad5ac47 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -2,11 +2,11 @@ import { assert, it } from "@effect/vitest"; import { DateTime, Effect, Layer, Option } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { GitHubCli, type GitHubCliShape } from "./GitHubCli.ts"; -import type { VcsProcessOutput } from "../vcs/VcsProcess.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubCli from "./GitHubCli.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; -const processResult = (stdout: string): VcsProcessOutput => ({ +const processResult = (stdout: string): VcsProcess.VcsProcessOutput => ({ exitCode: ChildProcessSpawner.ExitCode(0), stdout, stderr: "", @@ -14,8 +14,10 @@ const processResult = (stdout: string): VcsProcessOutput => ({ stderrTruncated: false, }); -function makeProvider(github: Partial) { - return GitHubSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitHubCli)(github))); +function makeProvider(github: Partial) { + return GitHubSourceControlProvider.make().pipe( + Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)), + ); } it.effect("maps GitHub PR summaries into provider-neutral change requests", () => @@ -127,7 +129,7 @@ it.effect("treats empty non-open change request listing output as no results", ( it.effect("creates GitHub PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = null; + let createInput: Parameters[0] | null = null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b893e70f7b..496f1ec48c 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -5,11 +5,15 @@ import { type ChangeRequestState, } from "@t3tools/contracts"; -import { GitHubCli, type GitHubCliError, type GitHubPullRequestSummary } from "./GitHubCli.ts"; -import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts"; -import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts"; +import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubPullRequests from "./gitHubPullRequests.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; -function providerError(operation: string, cause: GitHubCliError): SourceControlProviderError { +function providerError( + operation: string, + cause: GitHubCli.GitHubCliError, +): SourceControlProviderError { return new SourceControlProviderError({ provider: "github", operation, @@ -18,7 +22,7 @@ function providerError(operation: string, cause: GitHubCliError): SourceControlP }); } -function toChangeRequest(summary: GitHubPullRequestSummary): ChangeRequest { +function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { return { provider: "github", number: summary.number, @@ -40,75 +44,119 @@ function toChangeRequest(summary: GitHubPullRequestSummary): ChangeRequest { }; } +function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { + const output = SourceControlProviderDiscovery.combinedAuthOutput(input); + const account = SourceControlProviderDiscovery.matchFirst(output, [ + /Logged in to .* account\s+([^\s(]+)/iu, + /Logged in to .* as\s+([^\s(]+)/iu, + ]); + const host = SourceControlProviderDiscovery.parseCliHost(output); + + if (input.exitCode !== 0) { + return SourceControlProviderDiscovery.providerAuth({ + status: "unauthenticated", + host, + detail: + SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? + "Run `gh auth login` to authenticate GitHub CLI.", + }); + } + + if (account) { + return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host }); + } + + return SourceControlProviderDiscovery.providerAuth({ + status: "unknown", + host, + detail: + SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? + "GitHub CLI auth status could not be parsed.", + }); +} + +export const discovery = { + type: "cli", + kind: "github", + label: "GitHub", + executable: "gh", + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: parseGitHubAuth, + implemented: true, + installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.", +} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; + export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { - const github = yield* GitHubCli; + const github = yield* GitHubCli.GitHubCli; - const listChangeRequests: SourceControlProviderShape["listChangeRequests"] = (input) => { - if (input.state === "open") { + const listChangeRequests: SourceControlProvider.SourceControlProviderShape["listChangeRequests"] = + (input) => { + if (input.state === "open") { + return github + .listOpenPullRequests({ + cwd: input.cwd, + headSelector: input.headSelector, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((items) => items.map(toChangeRequest)), + Effect.mapError((error) => providerError("listChangeRequests", error)), + ); + } + + const stateArg: ChangeRequestState | "all" = input.state; return github - .listOpenPullRequests({ + .execute({ cwd: input.cwd, - headSelector: input.headSelector, - ...(input.limit !== undefined ? { limit: input.limit } : {}), + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + stateArg, + "--limit", + String(input.limit ?? 20), + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], }) .pipe( - Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed([]); + } + return Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => + Result.isSuccess(decoded) + ? Effect.succeed( + decoded.success.map((item) => ({ + ...toChangeRequest(item), + updatedAt: item.updatedAt, + })), + ) + : Effect.fail( + new SourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + detail: "GitHub CLI returned invalid change request JSON.", + cause: decoded.failure, + }), + ), + ), + ); + }), + Effect.mapError((error) => + Schema.is(SourceControlProviderError)(error) + ? error + : providerError("listChangeRequests", error), + ), ); - } - - const stateArg: ChangeRequestState | "all" = input.state; - return github - .execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - "--head", - input.headSelector, - "--state", - stateArg, - "--limit", - String(input.limit ?? 20), - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }) - .pipe( - Effect.flatMap((result) => { - const raw = result.stdout.trim(); - if (raw.length === 0) { - return Effect.succeed([]); - } - return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( - Effect.flatMap((decoded) => - Result.isSuccess(decoded) - ? Effect.succeed( - decoded.success.map((item) => ({ - ...toChangeRequest(item), - updatedAt: item.updatedAt, - })), - ) - : Effect.fail( - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", - detail: "GitHub CLI returned invalid change request JSON.", - cause: decoded.failure, - }), - ), - ), - ); - }), - Effect.mapError((error) => - Schema.is(SourceControlProviderError)(error) - ? error - : providerError("listChangeRequests", error), - ), - ); - }; + }; - return SourceControlProvider.of({ + return SourceControlProvider.SourceControlProvider.of({ kind: "github", listChangeRequests, getChangeRequest: (input) => @@ -145,4 +193,4 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { }); }); -export const layer = Layer.effect(SourceControlProvider, make()); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index d4e07efb88..5c7e978d85 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -1,25 +1,24 @@ -import { assert, it } from "@effect/vitest"; +import { assert, it, afterEach, expect, vi } from "@effect/vitest"; import { Effect, Layer } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { afterEach, expect, vi } from "vitest"; import { VcsProcessExitError } from "@t3tools/contracts"; -import { VcsProcess, type VcsProcessOutput, type VcsProcessShape } from "../vcs/VcsProcess.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitLabCli from "./GitLabCli.ts"; -const mockedRun = vi.fn(); +const mockedRun = vi.fn(); const layer = it.layer( GitLabCli.layer.pipe( Layer.provide( - Layer.mock(VcsProcess)({ + Layer.mock(VcsProcess.VcsProcess)({ run: mockedRun, }), ), ), ); -function processOutput(stdout: string): VcsProcessOutput { +function processOutput(stdout: string): VcsProcess.VcsProcessOutput { return { exitCode: ChildProcessSpawner.ExitCode(0), stdout, diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 6dac7f7eab..c4485bb09b 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -2,13 +2,9 @@ import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateT import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; -import { - decodeGitLabMergeRequestJson, - decodeGitLabMergeRequestListJson, - formatGitLabJsonDecodeError, -} from "./gitLabMergeRequests.ts"; -import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; -import type { SourceControlRefSelector } from "./SourceControlProvider.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitLabMergeRequests from "./gitLabMergeRequests.ts"; +import type * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -46,12 +42,12 @@ export interface GitLabCliShape { readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly listMergeRequests: (input: { readonly cwd: string; readonly headSelector: string; - readonly source?: SourceControlRefSelector; + readonly source?: SourceControlProvider.SourceControlRefSelector; readonly state: "open" | "closed" | "merged" | "all"; readonly limit?: number; }) => Effect.Effect, GitLabCliError>; @@ -76,8 +72,8 @@ export interface GitLabCliShape { readonly cwd: string; readonly baseBranch: string; readonly headSelector: string; - readonly source?: SourceControlRefSelector; - readonly target?: SourceControlRefSelector; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; readonly title: string; readonly bodyFile: string; }) => Effect.Effect; @@ -220,12 +216,14 @@ function normalizeHeadSelector(headSelector: string): string { function sourceRefName(input: { readonly headSelector: string; - readonly source?: SourceControlRefSelector; + readonly source?: SourceControlProvider.SourceControlRefSelector; }): string { return input.source?.refName ?? normalizeHeadSelector(input.headSelector); } -function sourceProjectIdentifier(source: SourceControlRefSelector | undefined): string | null { +function sourceProjectIdentifier( + source: SourceControlProvider.SourceControlRefSelector | undefined, +): string | null { return source?.repository ?? source?.owner ?? null; } @@ -252,7 +250,7 @@ function parseRepositoryPath(repository: string): { } export const make = Effect.fn("makeGitLabCli")(function* () { - const process = yield* VcsProcess; + const process = yield* VcsProcess.VcsProcess; const execute: GitLabCliShape["execute"] = (input) => process @@ -286,13 +284,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe( + : Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestListJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new GitLabCliError({ operation: "listMergeRequests", - detail: `GitLab CLI returned invalid MR list JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, + detail: `GitLab CLI returned invalid MR list JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -310,13 +308,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe( + Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new GitLabCliError({ operation: "getMergeRequest", - detail: `GitLab CLI returned invalid merge request JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, + detail: `GitLab CLI returned invalid merge request JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 7773a3b905..930c1c018f 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -1,11 +1,13 @@ import { assert, it } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; -import { GitLabCli, type GitLabCliShape } from "./GitLabCli.ts"; +import * as GitLabCli from "./GitLabCli.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; -function makeProvider(gitlab: Partial) { - return GitLabSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitLabCli)(gitlab))); +function makeProvider(gitlab: Partial) { + return GitLabSourceControlProvider.make().pipe( + Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)), + ); } it.effect("maps GitLab MR summaries into provider-neutral change requests", () => @@ -48,7 +50,7 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = it.effect("lists GitLab MRs through provider-neutral input names", () => Effect.gen(function* () { - let listInput: Parameters[0] | null = null; + let listInput: Parameters[0] | null = null; const provider = yield* makeProvider({ listMergeRequests: (input) => { listInput = input; @@ -74,7 +76,7 @@ it.effect("lists GitLab MRs through provider-neutral input names", () => it.effect("creates GitLab MRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = null; + let createInput: Parameters[0] | null = null; const provider = yield* makeProvider({ createMergeRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 41c5598e67..a007dd246f 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -1,10 +1,14 @@ import { Effect, Layer, Option } from "effect"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; -import { GitLabCli, type GitLabCliError, type GitLabMergeRequestSummary } from "./GitLabCli.ts"; -import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts"; +import * as GitLabCli from "./GitLabCli.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; -function providerError(operation: string, cause: GitLabCliError): SourceControlProviderError { +function providerError( + operation: string, + cause: GitLabCli.GitLabCliError, +): SourceControlProviderError { return new SourceControlProviderError({ provider: "gitlab", operation, @@ -13,7 +17,7 @@ function providerError(operation: string, cause: GitLabCliError): SourceControlP }); } -function toChangeRequest(summary: GitLabMergeRequestSummary): ChangeRequest { +function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest { return { provider: "gitlab", number: summary.number, @@ -35,27 +39,58 @@ function toChangeRequest(summary: GitLabMergeRequestSummary): ChangeRequest { }; } -function sourceFromInput(input: { - readonly headSelector: string; - readonly source?: SourceControlRefSelector; -}): SourceControlRefSelector | undefined { - if (input.source) { - return input.source; +function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { + const output = SourceControlProviderDiscovery.combinedAuthOutput(input); + const account = SourceControlProviderDiscovery.matchFirst(output, [ + /Logged in to .* as\s+([^\s(]+)/iu, + /Logged in to .* account\s+([^\s(]+)/iu, + /account:\s*([^\s(]+)/iu, + ]); + const host = SourceControlProviderDiscovery.parseCliHost(output); + + if (input.exitCode !== 0) { + return SourceControlProviderDiscovery.providerAuth({ + status: "unauthenticated", + host, + detail: + SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? + "Run `glab auth login` to authenticate GitLab CLI.", + }); + } + + if (account) { + return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host }); } - const match = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); - const owner = match?.[1]?.trim(); - const refName = match?.[2]?.trim(); - return owner && refName ? { owner, refName } : undefined; + return SourceControlProviderDiscovery.providerAuth({ + status: "unknown", + host, + detail: + SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? + "GitLab CLI auth status could not be parsed.", + }); } +export const discovery = { + type: "cli", + kind: "gitlab", + label: "GitLab", + executable: "glab", + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: parseGitLabAuth, + implemented: true, + installHint: + "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.", +} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; + export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { - const gitlab = yield* GitLabCli; + const gitlab = yield* GitLabCli.GitLabCli; - return SourceControlProvider.of({ + return SourceControlProvider.SourceControlProvider.of({ kind: "gitlab", listChangeRequests: (input) => { - const source = sourceFromInput(input); + const source = SourceControlProvider.sourceControlRefFromInput(input); return gitlab .listMergeRequests({ cwd: input.cwd, @@ -75,7 +110,7 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { Effect.mapError((error) => providerError("getChangeRequest", error)), ), createChangeRequest: (input) => { - const source = sourceFromInput(input); + const source = SourceControlProvider.sourceControlRefFromInput(input); return gitlab .createMergeRequest({ cwd: input.cwd, @@ -107,4 +142,4 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { }); }); -export const layer = Layer.effect(SourceControlProvider, make()); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 4ac3a3ffb4..6da3513e73 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -5,8 +5,34 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsProcessSpawnError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts"; +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as BitbucketApi from "./BitbucketApi.ts"; +import * as GitHubCli from "./GitHubCli.ts"; +import * as GitLabCli from "./GitLabCli.ts"; +import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; + +const sourceControlProviderRegistryTestLayer = (input: { + readonly bitbucket: Partial; + readonly process: Partial; +}) => + SourceControlProviderRegistry.layer.pipe( + Layer.provide( + Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( + Layer.provide(NodeServices.layer), + ), + Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), + Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), + Layer.mock(GitHubCli.GitHubCli)({}), + Layer.mock(GitLabCli.GitLabCli)({}), + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), + Layer.mock(VcsProcess.VcsProcess)(input.process), + ), + ), + ); const processOutput = ( stdout: string, @@ -23,38 +49,52 @@ const processOutput = ( }); it.effect("reports implemented tools separately from locally available CLIs", () => { - const testLayer = layer.pipe( - Layer.provide( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), - ), - Layer.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - if (input.command === "git") { - return Effect.succeed(processOutput("git version 2.51.0\n")); - } - if (input.command === "gh" && input.args[0] === "--version") { - return Effect.succeed(processOutput("gh version 2.83.0\n")); - } - if (input.command === "gh" && input.args.join(" ") === "auth status") { - return Effect.succeed( - processOutput(`github.com + const processMock = { + run: (input: VcsProcess.VcsProcessInput) => { + if (input.command === "git") { + return Effect.succeed(processOutput("git version 2.51.0\n")); + } + if (input.command === "gh" && input.args[0] === "--version") { + return Effect.succeed(processOutput("gh version 2.83.0\n")); + } + if (input.command === "gh" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`github.com Logged in to github.com account juliusmarminge (keyring) - Active account: true - Git operations protocol: ssh - Token: gho_************************************ - Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo' `), - ); - } - return Effect.fail( - new VcsProcessSpawnError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - cause: new Error(`${input.command} not found`), - }), - ); + ); + } + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error(`${input.command} not found`), + }), + ); + }, + } satisfies Partial; + const testLayer = SourceControlDiscovery.layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), + ), + Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), + Layer.provide( + sourceControlProviderRegistryTestLayer({ + process: processMock, + bitbucket: { + probeAuth: Effect.succeed({ + status: "unauthenticated", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.some( + "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + ), + }), }, }), ), @@ -62,7 +102,7 @@ Logged in to github.com account juliusmarminge (keyring) ); return Effect.gen(function* () { - const discovery = yield* SourceControlDiscovery; + const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; const result = yield* discovery.discover; assert.deepStrictEqual( @@ -101,74 +141,79 @@ Logged in to github.com account juliusmarminge (keyring) }, { kind: "azure-devops", - implemented: false, + implemented: true, status: "missing", auth: "unknown", account: Option.none(), }, { kind: "bitbucket", - implemented: false, - status: "missing", - auth: "unknown", + implemented: true, + status: "available", + auth: "unauthenticated", account: Option.none(), }, ], ); const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); assert.ok(bitbucket); - assert.strictEqual("executable" in bitbucket, false); + assert.strictEqual(bitbucket.executable, "Bitbucket REST API"); }).pipe(Effect.provide(testLayer)); }); it.effect("probes provider authentication without exposing token details", () => { - const testLayer = layer.pipe( - Layer.provide( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), - ), - Layer.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - if (input.args[0] === "--version") { - return Effect.succeed(processOutput(`${input.command} version test\n`)); - } - if (input.command === "gh" && input.args.join(" ") === "auth status") { - return Effect.succeed( - processOutput(`github.com + const processMock = { + run: (input: VcsProcess.VcsProcessInput) => { + if (input.args[0] === "--version") { + return Effect.succeed(processOutput(`${input.command} version test\n`)); + } + if (input.command === "gh" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`github.com Logged in to github.com account octocat (keyring) - Token: gho_************************************ - Token scopes: 'repo' `), - ); - } - if (input.command === "glab" && input.args.join(" ") === "auth status") { - return Effect.succeed( - processOutput(`gitlab.com + ); + } + if (input.command === "glab" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`gitlab.com Logged in to gitlab.com as gitlab-user `), - ); - } - if ( - input.command === "az" && - input.args.join(" ") === "account show --query user.name -o tsv" - ) { - return Effect.succeed(processOutput("azure-user@example.com\n")); - } - if (input.command === "bb" && input.args.join(" ") === "auth status") { - return Effect.succeed( - processOutput(`bitbucket.org -Logged in as bitbucket-user -`), - ); - } - return Effect.fail( - new VcsProcessSpawnError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - cause: new Error(`${input.command} not found`), - }), - ); + ); + } + if ( + input.command === "az" && + input.args.join(" ") === "account show --query user.name -o tsv" + ) { + return Effect.succeed(processOutput("azure-user@example.com\n")); + } + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error(`${input.command} not found`), + }), + ); + }, + } satisfies Partial; + const testLayer = SourceControlDiscovery.layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), + ), + Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), + Layer.provide( + sourceControlProviderRegistryTestLayer({ + process: processMock, + bitbucket: { + probeAuth: Effect.succeed({ + status: "authenticated", + account: Option.some("bitbucket-user"), + host: Option.some("bitbucket.org"), + detail: Option.none(), + }), }, }), ), @@ -176,7 +221,7 @@ Logged in as bitbucket-user ); return Effect.gen(function* () { - const discovery = yield* SourceControlDiscovery; + const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; const result = yield* discovery.discover; assert.deepStrictEqual( @@ -207,9 +252,9 @@ Logged in as bitbucket-user }, { kind: "bitbucket", - auth: "unknown", - account: Option.none(), - detail: Option.some("Bitbucket provider support is not available yet."), + auth: "authenticated", + account: Option.some("bitbucket-user"), + detail: Option.none(), }, ], ); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index 8a387a96cd..4a44d35087 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -1,8 +1,5 @@ import { - type SourceControlProviderAuth, type SourceControlDiscoveryResult, - type SourceControlProviderDiscoveryItem, - type SourceControlProviderKind, type VcsDiscoveryItem, type VcsDriverKind, } from "@t3tools/contracts"; @@ -10,6 +7,8 @@ import { Context, Effect, Layer, Option } from "effect"; import { ServerConfig } from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; interface DiscoveryProbe { readonly label: string; @@ -25,18 +24,6 @@ type VcsProbe = DiscoveryProbe & { readonly versionArgs: ReadonlyArray; }; -type ProviderProbe = DiscoveryProbe & { - readonly kind: SourceControlProviderKind; - readonly authArgs?: ReadonlyArray; - readonly parseAuth?: (input: AuthProbeInput) => SourceControlProviderAuth; -}; - -interface AuthProbeInput { - readonly stdout: string; - readonly stderr: string; - readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"]; -} - interface DiscoveryProbeResult { readonly kind: Kind; readonly label: string; @@ -67,203 +54,6 @@ const VCS_PROBES: ReadonlyArray = [ }, ]; -const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray = [ - { - kind: "github", - label: "GitHub", - executable: "gh", - versionArgs: ["--version"], - authArgs: ["auth", "status"], - parseAuth: parseGitHubAuth, - implemented: true, - installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.", - }, - { - kind: "gitlab", - label: "GitLab", - executable: "glab", - versionArgs: ["--version"], - authArgs: ["auth", "status"], - parseAuth: parseGitLabAuth, - implemented: true, - installHint: - "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.", - }, - { - kind: "azure-devops", - label: "Azure DevOps", - executable: "az", - versionArgs: ["--version"], - authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], - parseAuth: parseAzureAuth, - implemented: false, - installHint: - "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.", - }, - { - kind: "bitbucket", - label: "Bitbucket", - implemented: false, - installHint: "Bitbucket provider support is not available yet.", - }, -]; - -function firstNonEmptyLine(text: string): Option.Option { - const line = text - .split(/\r?\n/) - .map((entry) => entry.trim()) - .find((entry) => entry.length > 0); - return line === undefined ? Option.none() : Option.some(line); -} - -function detailFromCause(cause: unknown): Option.Option { - if (cause instanceof Error && cause.message.trim().length > 0) { - return Option.some(cause.message.trim()); - } - return Option.none(); -} - -function authAccount(account: string | undefined): Option.Option { - const trimmed = account?.trim(); - return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); -} - -function authHost(host: string | undefined): Option.Option { - const trimmed = host?.trim(); - return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); -} - -function authDetail(detail: string | undefined): Option.Option { - const trimmed = detail?.trim(); - return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); -} - -function providerAuth(input: { - readonly status: SourceControlProviderAuth["status"]; - readonly account?: string | undefined; - readonly host?: string | undefined; - readonly detail?: string | undefined; -}): SourceControlProviderAuth { - return { - status: input.status, - account: authAccount(input.account), - host: authHost(input.host), - detail: authDetail(input.detail), - }; -} - -function unknownAuth(detail?: string): SourceControlProviderAuth { - return providerAuth({ status: "unknown", detail }); -} - -function combinedAuthOutput(input: AuthProbeInput): string { - return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n"); -} - -function sanitizedAuthLines(text: string): ReadonlyArray { - return text - .split(/\r?\n/) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - .filter((entry) => !/^[-\s]*token(?:\s+scopes?)?:/iu.test(entry)); -} - -function firstSafeAuthLine(text: string): string | undefined { - return sanitizedAuthLines(text)[0]; -} - -function parseCliHost(text: string): string | undefined { - return sanitizedAuthLines(text) - .map((line) => line.replace(/^[^a-z0-9]+/iu, "")) - .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line)); -} - -function matchFirst(text: string, patterns: ReadonlyArray): string | undefined { - for (const pattern of patterns) { - const match = pattern.exec(text); - const value = match?.[1]?.trim(); - if (value && value.length > 0) return value; - } - return undefined; -} - -function parseGitHubAuth(input: AuthProbeInput): SourceControlProviderAuth { - const output = combinedAuthOutput(input); - const account = matchFirst(output, [ - /Logged in to .* account\s+([^\s(]+)/iu, - /Logged in to .* as\s+([^\s(]+)/iu, - ]); - const host = parseCliHost(output); - - if (input.exitCode !== 0) { - return providerAuth({ - status: "unauthenticated", - host, - detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", - }); - } - - if (account) { - return providerAuth({ status: "authenticated", account, host }); - } - - return providerAuth({ - status: "unknown", - host, - detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", - }); -} - -function parseGitLabAuth(input: AuthProbeInput): SourceControlProviderAuth { - const output = combinedAuthOutput(input); - const account = matchFirst(output, [ - /Logged in to .* as\s+([^\s(]+)/iu, - /Logged in to .* account\s+([^\s(]+)/iu, - /account:\s*([^\s(]+)/iu, - ]); - const host = parseCliHost(output); - - if (input.exitCode !== 0) { - return providerAuth({ - status: "unauthenticated", - host, - detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.", - }); - } - - if (account) { - return providerAuth({ status: "authenticated", account, host }); - } - - return providerAuth({ - status: "unknown", - host, - detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.", - }); -} - -function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth { - const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); - - if (input.exitCode !== 0) { - return providerAuth({ - status: "unauthenticated", - detail: - firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.", - }); - } - - if (account && account.length > 0) { - return providerAuth({ status: "authenticated", account, host: "dev.azure.com" }); - } - - return providerAuth({ - status: "unknown", - host: "dev.azure.com", - detail: "Azure CLI account status could not be parsed.", - }); -} - export interface SourceControlDiscoveryShape { readonly discover: Effect.Effect; } @@ -278,8 +68,10 @@ export const layer = Layer.effect( Effect.gen(function* () { const config = yield* ServerConfig; const process = yield* VcsProcess.VcsProcess; + const sourceControlProviders = + yield* SourceControlProviderRegistry.SourceControlProviderRegistry; - const probe = ( + const probe = ( input: DiscoveryProbe & { readonly kind: Kind }, ): Effect.Effect> => { const executable = input.executable; @@ -316,8 +108,9 @@ export const layer = Layer.effect( executable, implemented: input.implemented, status: "available" as const, - version: Option.orElse(firstNonEmptyLine(result.stdout), () => - firstNonEmptyLine(result.stderr), + version: Option.orElse( + SourceControlProviderDiscovery.firstNonEmptyLine(result.stdout), + () => SourceControlProviderDiscovery.firstNonEmptyLine(result.stderr), ), installHint: input.installHint, detail: Option.none(), @@ -332,74 +125,19 @@ export const layer = Layer.effect( status: "missing" as const, version: Option.none(), installHint: input.installHint, - detail: detailFromCause(cause), + detail: SourceControlProviderDiscovery.detailFromCause(cause), } satisfies DiscoveryProbeResult), ), ); }; - const probeProvider = (input: ProviderProbe) => - probe(input).pipe( - Effect.flatMap((item) => { - const executable = input.executable; - const authArgs = input.authArgs; - const parseAuth = input.parseAuth; - - if (!executable || !authArgs || !parseAuth) { - return Effect.succeed({ - ...item, - auth: unknownAuth(input.installHint), - } satisfies SourceControlProviderDiscoveryItem); - } - - if (item.status !== "available") { - return Effect.succeed({ - ...item, - auth: unknownAuth("CLI is not installed."), - } satisfies SourceControlProviderDiscoveryItem); - } - - return process - .run({ - operation: "source-control.discovery.auth", - command: executable, - args: authArgs, - cwd: config.cwd, - allowNonZeroExit: true, - timeoutMs: 5_000, - maxOutputBytes: 8_000, - truncateOutputAtMaxBytes: true, - }) - .pipe( - Effect.map( - (result) => - ({ - ...item, - auth: parseAuth(result), - }) satisfies SourceControlProviderDiscoveryItem, - ), - Effect.catch((cause) => - Effect.succeed({ - ...item, - auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), - } satisfies SourceControlProviderDiscoveryItem), - ), - ); - }), - ); - return SourceControlDiscovery.of({ discover: Effect.all({ versionControlSystems: Effect.all( VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray>, { concurrency: "unbounded" }, ), - sourceControlProviders: Effect.all( - SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probeProvider(entry)) as ReadonlyArray< - Effect.Effect - >, - { concurrency: "unbounded" }, - ), + sourceControlProviders: sourceControlProviders.discover, }), }); }), diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index ef376eedb4..8f56121097 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -21,6 +21,22 @@ export interface SourceControlRefSelector { readonly repository?: string; } +export function parseSourceControlOwnerRef( + headSelector: string, +): SourceControlRefSelector | undefined { + const match = /^([^:/\s]+):(.+)$/u.exec(headSelector.trim()); + const owner = match?.[1]?.trim(); + const refName = match?.[2]?.trim(); + return owner && refName ? { owner, refName } : undefined; +} + +export function sourceControlRefFromInput(input: { + readonly headSelector: string; + readonly source?: SourceControlRefSelector; +}): SourceControlRefSelector | undefined { + return input.source ?? parseSourceControlOwnerRef(input.headSelector); +} + export interface SourceControlProviderShape { readonly kind: SourceControlProviderKind; readonly listChangeRequests: (input: { diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts new file mode 100644 index 0000000000..5182812489 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -0,0 +1,243 @@ +import type { + SourceControlProviderAuth, + SourceControlProviderDiscoveryItem, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import { Effect, Option } from "effect"; + +import type * as VcsProcess from "../vcs/VcsProcess.ts"; + +export interface SourceControlAuthProbeInput { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"]; +} + +interface SourceControlDiscoverySpecBase { + readonly kind: SourceControlProviderKind; + readonly label: string; + readonly executable: string; + readonly implemented: boolean; + readonly installHint: string; +} + +export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { + readonly type: "cli"; + readonly versionArgs: ReadonlyArray; + readonly authArgs: ReadonlyArray; + readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; +}; + +export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & { + readonly type: "api"; + readonly probeAuth: Effect.Effect; +}; + +export type SourceControlProviderDiscoverySpec = + | SourceControlCliDiscoverySpec + | SourceControlApiDiscoverySpec; + +interface DiscoveryProbeResult { + readonly kind: SourceControlProviderKind; + readonly label: string; + readonly executable: string; + readonly implemented: boolean; + readonly status: "available" | "missing"; + readonly version: Option.Option; + readonly installHint: string; + readonly detail: Option.Option; +} + +export function firstNonEmptyLine(text: string): Option.Option { + const line = text + .split(/\r?\n/) + .map((entry) => entry.trim()) + .find((entry) => entry.length > 0); + return line === undefined ? Option.none() : Option.some(line); +} + +export function detailFromCause(cause: unknown): Option.Option { + if (cause instanceof Error && cause.message.trim().length > 0) { + return Option.some(cause.message.trim()); + } + return Option.none(); +} + +function authAccount(account: string | undefined): Option.Option { + const trimmed = account?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +function authHost(host: string | undefined): Option.Option { + const trimmed = host?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +function authDetail(detail: string | undefined): Option.Option { + const trimmed = detail?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +export function providerAuth(input: { + readonly status: SourceControlProviderAuth["status"]; + readonly account?: string | undefined; + readonly host?: string | undefined; + readonly detail?: string | undefined; +}): SourceControlProviderAuth { + return { + status: input.status, + account: authAccount(input.account), + host: authHost(input.host), + detail: authDetail(input.detail), + }; +} + +export function unknownAuth(detail?: string): SourceControlProviderAuth { + return providerAuth({ status: "unknown", detail }); +} + +export function combinedAuthOutput(input: SourceControlAuthProbeInput): string { + return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n"); +} + +function sanitizedAuthLines(text: string): ReadonlyArray { + return text + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .filter((entry) => !/^[-\s]*token(?:\s+scopes?)?:/iu.test(entry)); +} + +export function firstSafeAuthLine(text: string): string | undefined { + return sanitizedAuthLines(text)[0]; +} + +export function parseCliHost(text: string): string | undefined { + return sanitizedAuthLines(text) + .map((line) => line.replace(/^[^a-z0-9]+/iu, "")) + .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line)); +} + +export function matchFirst(text: string, patterns: ReadonlyArray): string | undefined { + for (const pattern of patterns) { + const match = pattern.exec(text); + const value = match?.[1]?.trim(); + if (value && value.length > 0) return value; + } + return undefined; +} + +function probeCli(input: { + readonly spec: SourceControlCliDiscoverySpec; + readonly process: VcsProcess.VcsProcessShape; + readonly cwd: string; +}): Effect.Effect { + return input.process + .run({ + operation: "source-control.discovery.probe", + command: input.spec.executable, + args: input.spec.versionArgs, + cwd: input.cwd, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + truncateOutputAtMaxBytes: true, + }) + .pipe( + Effect.map( + (result) => + ({ + kind: input.spec.kind, + label: input.spec.label, + executable: input.spec.executable, + implemented: input.spec.implemented, + status: "available" as const, + version: Option.orElse(firstNonEmptyLine(result.stdout), () => + firstNonEmptyLine(result.stderr), + ), + installHint: input.spec.installHint, + detail: Option.none(), + }) satisfies DiscoveryProbeResult, + ), + Effect.catch((cause) => + Effect.succeed({ + kind: input.spec.kind, + label: input.spec.label, + executable: input.spec.executable, + implemented: input.spec.implemented, + status: "missing" as const, + version: Option.none(), + installHint: input.spec.installHint, + detail: detailFromCause(cause), + } satisfies DiscoveryProbeResult), + ), + ); +} + +export function probeSourceControlProvider(input: { + readonly spec: SourceControlProviderDiscoverySpec; + readonly process: VcsProcess.VcsProcessShape; + readonly cwd: string; +}): Effect.Effect { + if (input.spec.type === "api") { + return input.spec.probeAuth.pipe( + Effect.map( + (auth) => + ({ + kind: input.spec.kind, + label: input.spec.label, + executable: input.spec.executable, + implemented: input.spec.implemented, + status: "available" as const, + version: Option.none(), + installHint: input.spec.installHint, + detail: Option.none(), + auth, + }) satisfies SourceControlProviderDiscoveryItem, + ), + ); + } + + const spec = input.spec; + + return probeCli({ + spec, + process: input.process, + cwd: input.cwd, + }).pipe( + Effect.flatMap((item) => { + if (item.status !== "available") { + return Effect.succeed({ + ...item, + auth: unknownAuth("CLI is not installed."), + } satisfies SourceControlProviderDiscoveryItem); + } + + return input.process + .run({ + operation: "source-control.discovery.auth", + command: spec.executable, + args: spec.authArgs, + cwd: input.cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + truncateOutputAtMaxBytes: true, + }) + .pipe( + Effect.map( + (result) => + ({ + ...item, + auth: spec.parseAuth(result), + }) satisfies SourceControlProviderDiscoveryItem, + ), + Effect.catch((cause) => + Effect.succeed({ + ...item, + auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), + } satisfies SourceControlProviderDiscoveryItem), + ), + ); + }), + ); +} diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index fbfe8a3689..395bd9b5e8 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -1,11 +1,16 @@ import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { DateTime, Effect, Layer, Option } from "effect"; -import { GitHubCli } from "./GitHubCli.ts"; -import { GitLabCli } from "./GitLabCli.ts"; +import { ServerConfig } from "../config.ts"; +import type * as VcsDriver from "../vcs/VcsDriver.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 GitHubCli from "./GitHubCli.ts"; +import * as GitLabCli from "./GitLabCli.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; -import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; -import type { VcsDriverShape } from "../vcs/VcsDriver.ts"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); @@ -29,10 +34,10 @@ function makeRegistry(input: { expiresAt: Option.none(), }, }), - } satisfies Partial; + } satisfies Partial; - const registryLayer = Layer.mock(VcsDriverRegistry)({ - get: () => Effect.succeed(driver as unknown as VcsDriverShape), + const registryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriverShape), resolve: () => Effect.succeed({ kind: "git", @@ -46,13 +51,23 @@ function makeRegistry(input: { expiresAt: Option.none(), }, }, - driver: driver as unknown as VcsDriverShape, + driver: driver as unknown as VcsDriver.VcsDriverShape, }), }); return SourceControlProviderRegistry.make().pipe( Effect.provide( - Layer.mergeAll(registryLayer, Layer.mock(GitHubCli)({}), Layer.mock(GitLabCli)({})), + Layer.mergeAll( + registryLayer, + Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), + Layer.mock(BitbucketApi.BitbucketApi)({}), + Layer.mock(GitHubCli.GitHubCli)({}), + Layer.mock(GitLabCli.GitLabCli)({}), + Layer.mock(VcsProcess.VcsProcess)({}), + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ), ), ); } @@ -93,6 +108,30 @@ it.effect("routes GitLab remotes to the GitLab provider", () => }), ); +it.effect("routes Bitbucket remotes to the Bitbucket provider", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@bitbucket.org:pingdotgg/t3code.git" }], + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "bitbucket"); + }), +); + +it.effect("routes Azure DevOps remotes to the Azure DevOps provider", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "https://dev.azure.com/acme/project/_git/repo" }], + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "azure-devops"); + }), +); + it.effect("falls back to a non-origin remote when origin is not configured", () => Effect.gen(function* () { const registry = yield* makeRegistry({ diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 6b04b1f8bb..c8b79f2165 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -1,40 +1,46 @@ import { Cache, Context, Duration, Effect, Exit, Layer } from "effect"; -import { SourceControlProviderError } from "@t3tools/contracts"; +import { + SourceControlProviderError, + type SourceControlProviderDiscoveryItem, +} from "@t3tools/contracts"; import type { SourceControlProviderKind } from "@t3tools/contracts"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import { - SourceControlProvider, - type SourceControlProviderContext, - type SourceControlProviderShape, -} from "./SourceControlProvider.ts"; +import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; +import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; -import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import { ServerConfig } from "../config.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); export interface SourceControlProviderRegistration { readonly kind: SourceControlProviderKind; - readonly provider: SourceControlProviderShape; + readonly provider: SourceControlProvider.SourceControlProviderShape; + readonly discovery: SourceControlProviderDiscovery.SourceControlProviderDiscoverySpec; } export interface SourceControlProviderHandle { - readonly provider: SourceControlProviderShape; - readonly context: SourceControlProviderContext | null; + readonly provider: SourceControlProvider.SourceControlProviderShape; + readonly context: SourceControlProvider.SourceControlProviderContext | null; } export interface SourceControlProviderRegistryShape { readonly get: ( kind: SourceControlProviderKind, - ) => Effect.Effect; + ) => Effect.Effect; readonly resolveHandle: (input: { readonly cwd: string; }) => Effect.Effect; readonly resolve: (input: { readonly cwd: string; - }) => Effect.Effect; + }) => Effect.Effect; + readonly discover: Effect.Effect>; } export class SourceControlProviderRegistry extends Context.Service< @@ -42,7 +48,9 @@ export class SourceControlProviderRegistry extends Context.Service< SourceControlProviderRegistryShape >()("t3/source-control/SourceControlProviderRegistry") {} -function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProviderShape { +function unsupportedProvider( + kind: SourceControlProviderKind, +): SourceControlProvider.SourceControlProviderShape { const unsupported = (operation: string) => Effect.fail( new SourceControlProviderError({ @@ -52,7 +60,7 @@ function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProv }), ); - return SourceControlProvider.of({ + return SourceControlProvider.SourceControlProvider.of({ kind, listChangeRequests: () => unsupported("listChangeRequests"), getChangeRequest: () => unsupported("getChangeRequest"), @@ -78,7 +86,7 @@ function selectProviderContext( readonly name: string; readonly url: string; }>, -): SourceControlProviderContext | null { +): SourceControlProvider.SourceControlProviderContext | null { const candidates = remotes .map((remote) => { const provider = detectSourceControlProviderFromRemoteUrl(remote.url); @@ -90,7 +98,7 @@ function selectProviderContext( } : null; }) - .filter((value): value is SourceControlProviderContext => value !== null); + .filter((value): value is SourceControlProvider.SourceControlProviderContext => value !== null); return ( candidates.find((candidate) => candidate.remoteName === "origin") ?? @@ -100,12 +108,60 @@ function selectProviderContext( ); } +function bindProviderContext( + provider: SourceControlProvider.SourceControlProviderShape, + context: SourceControlProvider.SourceControlProviderContext | null, +): SourceControlProvider.SourceControlProviderShape { + if (context === null) { + return provider; + } + + return SourceControlProvider.SourceControlProvider.of({ + kind: provider.kind, + listChangeRequests: (input) => + provider.listChangeRequests({ + ...input, + context: input.context ?? context, + }), + getChangeRequest: (input) => + provider.getChangeRequest({ + ...input, + context: input.context ?? context, + }), + createChangeRequest: (input) => + provider.createChangeRequest({ + ...input, + context: input.context ?? context, + }), + getRepositoryCloneUrls: (input) => + provider.getRepositoryCloneUrls({ + ...input, + context: input.context ?? context, + }), + createRepository: (input) => provider.createRepository(input), + getDefaultBranch: (input) => + provider.getDefaultBranch({ + ...input, + context: input.context ?? context, + }), + checkoutChangeRequest: (input) => + provider.checkoutChangeRequest({ + ...input, + context: input.context ?? context, + }), + }); +} + export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( function* (registrations: ReadonlyArray) { - const vcsRegistry = yield* VcsDriverRegistry; - const providers = new Map( - registrations.map((registration) => [registration.kind, registration.provider]), - ); + const config = yield* ServerConfig; + const process = yield* VcsProcess.VcsProcess; + const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; + const providers = new Map< + SourceControlProviderKind, + SourceControlProvider.SourceControlProviderShape + >(registrations.map((registration) => [registration.kind, registration.provider])); + const discoverySpecs = registrations.map((registration) => registration.discovery); const get: SourceControlProviderRegistryShape["get"] = (kind) => Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); @@ -125,7 +181,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit const providerContextCache = yield* Cache.makeWith< string, - SourceControlProviderContext | null, + SourceControlProvider.SourceControlProviderContext | null, SourceControlProviderError >(detectProviderContext, { capacity: PROVIDER_DETECTION_CACHE_CAPACITY, @@ -136,8 +192,9 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit Cache.get(providerContextCache, input.cwd).pipe( Effect.map((context) => { const kind = context?.provider.kind ?? "unknown"; + const provider = providers.get(kind) ?? unsupportedProvider(kind); return { - provider: providers.get(kind) ?? unsupportedProvider(kind), + provider: bindProviderContext(provider, context), context, } satisfies SourceControlProviderHandle; }), @@ -147,6 +204,16 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit get, resolveHandle, resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), + discover: Effect.all( + discoverySpecs.map((spec) => + SourceControlProviderDiscovery.probeSourceControlProvider({ + spec, + process, + cwd: config.cwd, + }), + ), + { concurrency: "unbounded" }, + ), }); }, ); @@ -154,14 +221,29 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () { const github = yield* GitHubSourceControlProvider.make(); const gitlab = yield* GitLabSourceControlProvider.make(); + const bitbucket = yield* BitbucketSourceControlProvider.make(); + const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery(); + const azureDevOps = yield* AzureDevOpsSourceControlProvider.make(); return yield* makeWithProviders([ { kind: "github", provider: github, + discovery: GitHubSourceControlProvider.discovery, }, { kind: "gitlab", provider: gitlab, + discovery: GitLabSourceControlProvider.discovery, + }, + { + kind: "azure-devops", + provider: azureDevOps, + discovery: AzureDevOpsSourceControlProvider.discovery, + }, + { + kind: "bitbucket", + provider: bitbucket, + discovery: bitbucketDiscovery, }, ]); }); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 56ab64b54b..5280ee0e59 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -6,14 +6,10 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; -import { - GitVcsDriver, - type ExecuteGitResult, - type GitVcsDriverShape, -} from "../vcs/GitVcsDriver.ts"; -import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; -import type { SourceControlProviderShape } from "./SourceControlProvider.ts"; -import { SourceControlRepositoryService, layer } from "./SourceControlRepositoryService.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import type * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; +import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts"; const CLONE_URLS = { nameWithOwner: "octocat/t3code", @@ -22,8 +18,8 @@ const CLONE_URLS = { }; function makeProvider( - overrides: Partial = {}, -): SourceControlProviderShape { + overrides: Partial = {}, +): SourceControlProvider.SourceControlProviderShape { const unsupported = (operation: string) => Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect< never, @@ -43,7 +39,7 @@ function makeProvider( }; } -function processOutput(): ExecuteGitResult { +function processOutput(): GitVcsDriver.ExecuteGitResult { return { exitCode: ChildProcessSpawner.ExitCode(0), stdout: "", @@ -54,17 +50,17 @@ function processOutput(): ExecuteGitResult { } function makeLayer(input: { - readonly provider?: SourceControlProviderShape; - readonly git?: Partial; + readonly provider?: SourceControlProvider.SourceControlProviderShape; + readonly git?: Partial; }) { - return layer.pipe( + return SourceControlRepositoryService.layer.pipe( Layer.provide( - Layer.mock(SourceControlProviderRegistry)({ + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ get: () => Effect.succeed(input.provider ?? makeProvider()), }), ), Layer.provide( - Layer.mock(GitVcsDriver)({ + Layer.mock(GitVcsDriver.GitVcsDriver)({ execute: () => Effect.succeed(processOutput()), ensureRemote: () => Effect.succeed("origin"), pushCurrentBranch: () => @@ -93,7 +89,7 @@ it.effect("looks up repositories through the requested provider without search", }); return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService; + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.lookupRepository({ provider: "github", repository: "octocat/t3code", @@ -115,7 +111,7 @@ it.effect("clones a looked-up repository into the requested destination", () => const cloneCalls: Array<{ cwd: string; args: ReadonlyArray }> = []; yield* Effect.gen(function* () { - const service = yield* SourceControlRepositoryService; + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.cloneRepository({ provider: "github", repository: "octocat/t3code", @@ -167,7 +163,7 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up }); return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService; + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.publishRepository({ cwd: "/workspace", provider: "github", @@ -222,7 +218,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => { const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService; + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.publishRepository({ cwd: "/workspace", provider: "github", @@ -258,7 +254,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => { it.effect("publish succeeds with status remote_added when the local repo has no commits", () => { let pushCalls = 0; return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService; + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.publishRepository({ cwd: "/workspace", provider: "github", diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index bfd19f2cdf..1bf71ac12d 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -1,4 +1,4 @@ -import OS from "node:os"; +import * as NodeOS from "node:os"; import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect"; import { @@ -15,8 +15,8 @@ import { } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; -import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; -import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; export interface SourceControlRepositoryServiceShape { readonly lookupRepository: ( @@ -102,10 +102,10 @@ function selectRemoteUrl( function expandHomePath(input: string, path: Path.Path): string { if (input === "~") { - return OS.homedir(); + return NodeOS.homedir(); } if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(OS.homedir(), input.slice(2)); + return path.join(NodeOS.homedir(), input.slice(2)); } return input; } @@ -113,9 +113,9 @@ function expandHomePath(input: string, path: Path.Path): string { export const make = Effect.fn("makeSourceControlRepositoryService")(function* () { const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; - const git = yield* GitVcsDriver; + const git = yield* GitVcsDriver.GitVcsDriver; const path = yield* Path.Path; - const providers = yield* SourceControlProviderRegistry; + const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const ensureConcreteProvider = (input: { readonly operation: string; diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts new file mode 100644 index 0000000000..48c7a83611 --- /dev/null +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -0,0 +1,106 @@ +import { Cause, DateTime, Exit, Option, Result, Schema } from "effect"; +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; + +export interface NormalizedAzureDevOpsPullRequestRecord { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: Option.Option; +} + +const AzureDevOpsPullRequestSchema = Schema.Struct({ + pullRequestId: PositiveInt, + title: TrimmedNonEmptyString, + url: Schema.optional(Schema.String), + sourceRefName: TrimmedNonEmptyString, + targetRefName: TrimmedNonEmptyString, + status: Schema.String, + creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), + closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), + _links: Schema.optional( + Schema.Struct({ + web: Schema.optional( + Schema.Struct({ + href: Schema.String, + }), + ), + }), + ), +}); + +function trimOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeRefName(refName: string): string { + return refName.trim().replace(/^refs\/heads\//, ""); +} + +function normalizeAzureDevOpsPullRequestState(status: string): "open" | "closed" | "merged" { + switch (status.trim().toLowerCase()) { + case "completed": + return "merged"; + case "abandoned": + return "closed"; + default: + return "open"; + } +} + +function normalizeAzureDevOpsPullRequestRecord( + raw: Schema.Schema.Type, +): NormalizedAzureDevOpsPullRequestRecord { + return { + number: raw.pullRequestId, + title: raw.title, + url: trimOptionalString(raw._links?.web?.href) ?? trimOptionalString(raw.url) ?? "", + baseRefName: normalizeRefName(raw.targetRefName), + headRefName: normalizeRefName(raw.sourceRefName), + state: normalizeAzureDevOpsPullRequestState(raw.status), + updatedAt: (raw.closedDate ?? Option.none()).pipe( + Option.orElse(() => raw.creationDate ?? Option.none()), + ), + }; +} + +const decodeAzureDevOpsPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeAzureDevOpsPullRequest = decodeJsonResult(AzureDevOpsPullRequestSchema); +const decodeAzureDevOpsPullRequestEntry = Schema.decodeUnknownExit(AzureDevOpsPullRequestSchema); + +export const formatAzureDevOpsJsonDecodeError = formatSchemaError; + +export function decodeAzureDevOpsPullRequestListJson( + raw: string, +): Result.Result< + ReadonlyArray, + Cause.Cause +> { + const result = decodeAzureDevOpsPullRequestList(raw); + if (Result.isSuccess(result)) { + const pullRequests: NormalizedAzureDevOpsPullRequestRecord[] = []; + for (const entry of result.success) { + const decodedEntry = decodeAzureDevOpsPullRequestEntry(entry); + if (Exit.isFailure(decodedEntry)) { + continue; + } + pullRequests.push(normalizeAzureDevOpsPullRequestRecord(decodedEntry.value)); + } + return Result.succeed(pullRequests); + } + return Result.fail(result.failure); +} + +export function decodeAzureDevOpsPullRequestJson( + raw: string, +): Result.Result> { + const result = decodeAzureDevOpsPullRequest(raw); + if (Result.isSuccess(result)) { + return Result.succeed(normalizeAzureDevOpsPullRequestRecord(result.success)); + } + return Result.fail(result.failure); +} diff --git a/apps/server/src/sourceControl/bitbucketPullRequests.ts b/apps/server/src/sourceControl/bitbucketPullRequests.ts new file mode 100644 index 0000000000..5313eaba97 --- /dev/null +++ b/apps/server/src/sourceControl/bitbucketPullRequests.ts @@ -0,0 +1,104 @@ +import { DateTime, Option, Schema } from "effect"; +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; + +export interface NormalizedBitbucketPullRequestRecord { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: Option.Option; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +export const BitbucketRepositoryRefSchema = Schema.Struct({ + full_name: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + workspace: Schema.optional( + Schema.NullOr( + Schema.Struct({ + slug: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + }), + ), + ), +}); + +export const BitbucketPullRequestBranchSchema = Schema.Struct({ + repository: Schema.optional(Schema.NullOr(BitbucketRepositoryRefSchema)), + branch: Schema.Struct({ + name: TrimmedNonEmptyString, + }), +}); + +export const BitbucketPullRequestSchema = Schema.Struct({ + id: PositiveInt, + title: TrimmedNonEmptyString, + state: Schema.optional(Schema.NullOr(Schema.String)), + updated_on: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), + links: Schema.Struct({ + html: Schema.Struct({ + href: TrimmedNonEmptyString, + }), + }), + source: BitbucketPullRequestBranchSchema, + destination: BitbucketPullRequestBranchSchema, +}); + +export const BitbucketPullRequestListSchema = Schema.Struct({ + values: Schema.Array(BitbucketPullRequestSchema), + next: Schema.optional(TrimmedNonEmptyString), +}); + +function trimOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function repositoryOwner(repository: Schema.Schema.Type) { + return ( + trimOptionalString(repository.workspace?.slug) ?? + (repository.full_name?.includes("/") ? (repository.full_name.split("/")[0] ?? null) : null) + ); +} + +function normalizeBitbucketPullRequestState(state: string | null | undefined) { + switch (state?.trim().toUpperCase()) { + case "MERGED": + return "merged" as const; + case "DECLINED": + case "SUPERSEDED": + return "closed" as const; + case "OPEN": + default: + return "open" as const; + } +} + +export function normalizeBitbucketPullRequestRecord( + raw: Schema.Schema.Type, +): NormalizedBitbucketPullRequestRecord { + const headRepositoryNameWithOwner = trimOptionalString(raw.source.repository?.full_name); + const baseRepositoryNameWithOwner = trimOptionalString(raw.destination.repository?.full_name); + const headRepositoryOwnerLogin = raw.source.repository + ? repositoryOwner(raw.source.repository) + : null; + const isCrossRepository = + headRepositoryNameWithOwner !== null && + baseRepositoryNameWithOwner !== null && + headRepositoryNameWithOwner !== baseRepositoryNameWithOwner; + + return { + number: raw.id, + title: raw.title, + url: raw.links.html.href, + baseRefName: raw.destination.branch.name, + headRefName: raw.source.branch.name, + state: normalizeBitbucketPullRequestState(raw.state), + updatedAt: raw.updated_on ?? Option.none(), + ...(isCrossRepository ? { isCrossRepository: true } : {}), + ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), + }; +} diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 727f7e8650..49a135aec4 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -18,9 +18,9 @@ import { type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; -import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; -import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts"; -import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts"; +import * as GitVcsDriverCore from "./GitVcsDriverCore.ts"; +import * as VcsDriver from "./VcsDriver.ts"; +import * as VcsProcess from "./VcsProcess.ts"; export interface ExecuteGitInput { readonly operation: string; @@ -304,7 +304,7 @@ function parseGitRemoteVerboseOutput( } const gitCommand = ( - process: VcsProcessShape, + process: VcsProcess.VcsProcessShape, operation: string, cwd: string, args: ReadonlyArray, @@ -335,7 +335,7 @@ const gitCommand = ( }); export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () { - const process = yield* VcsProcess; + const process = yield* VcsProcess.VcsProcess; const capabilities = { kind: "git" as const, supportsWorktrees: true, @@ -345,7 +345,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ignoreClassifier: "native" as const, }; - const isInsideWorkTree: VcsDriverShape["isInsideWorkTree"] = (cwd) => + const isInsideWorkTree: VcsDriver.VcsDriverShape["isInsideWorkTree"] = (cwd) => gitCommand( process, "GitVcsDriver.isInsideWorkTree", @@ -358,7 +358,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }, ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true")); - const execute: VcsDriverShape["execute"] = (input) => + const execute: VcsDriver.VcsDriverShape["execute"] = (input) => gitCommand(process, input.operation, input.cwd, input.args, { ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), ...(input.env !== undefined ? { env: input.env } : {}), @@ -370,33 +370,33 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( : {}), }); - const detectRepository: VcsDriverShape["detectRepository"] = Effect.fn("detectRepository")( - function* (cwd) { - if (!(yield* isInsideWorkTree(cwd))) { - return null; - } + const detectRepository: VcsDriver.VcsDriverShape["detectRepository"] = Effect.fn( + "detectRepository", + )(function* (cwd) { + if (!(yield* isInsideWorkTree(cwd))) { + return null; + } - const root = yield* gitCommand(process, "GitVcsDriver.detectRepository.root", cwd, [ - "rev-parse", - "--show-toplevel", - ]); - const gitCommonDir = yield* gitCommand( - process, - "GitVcsDriver.detectRepository.commonDir", - cwd, - ["rev-parse", "--git-common-dir"], - ).pipe(Effect.catch(() => Effect.succeed(null))); + const root = yield* gitCommand(process, "GitVcsDriver.detectRepository.root", cwd, [ + "rev-parse", + "--show-toplevel", + ]); + const gitCommonDir = yield* gitCommand( + process, + "GitVcsDriver.detectRepository.commonDir", + cwd, + ["rev-parse", "--git-common-dir"], + ).pipe(Effect.catch(() => Effect.succeed(null))); - return { - kind: "git" as const, - rootPath: root.stdout.trim(), - metadataPath: gitCommonDir?.stdout.trim() || null, - freshness: yield* nowFreshness(), - }; - }, - ); + return { + kind: "git" as const, + rootPath: root.stdout.trim(), + metadataPath: gitCommonDir?.stdout.trim() || null, + freshness: yield* nowFreshness(), + }; + }); - const listWorkspaceFiles: VcsDriverShape["listWorkspaceFiles"] = (cwd) => + const listWorkspaceFiles: VcsDriver.VcsDriverShape["listWorkspaceFiles"] = (cwd) => gitCommand( process, "GitVcsDriver.listWorkspaceFiles", @@ -438,98 +438,100 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ), ); - const listRemotes: VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")(function* (cwd) { - const result = yield* gitCommand(process, "GitVcsDriver.listRemotes", cwd, ["remote", "-v"], { - allowNonZeroExit: true, - timeoutMs: 5_000, - maxOutputBytes: 64 * 1024, - }); - - if (result.exitCode !== 0) { - return yield* new VcsProcessExitError({ - operation: "GitVcsDriver.listRemotes", - command: "git remote -v", - cwd, - exitCode: result.exitCode, - detail: result.stderr.trim() || "git remote -v failed", + const listRemotes: VcsDriver.VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")( + function* (cwd) { + const result = yield* gitCommand(process, "GitVcsDriver.listRemotes", cwd, ["remote", "-v"], { + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, }); - } - const parsed = parseGitRemoteVerboseOutput(result.stdout); - const remotes = Array.from(parsed.entries()).flatMap(([name, remote]) => { - if (!remote.url) { - return []; + if (result.exitCode !== 0) { + return yield* new VcsProcessExitError({ + operation: "GitVcsDriver.listRemotes", + command: "git remote -v", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "git remote -v failed", + }); } - return [ - { - name, - url: remote.url, - pushUrl: remote.pushUrl ? Option.some(remote.pushUrl) : Option.none(), - isPrimary: name === "origin", - }, - ]; - }); - return { - remotes, - freshness: yield* nowFreshness(), - }; - }); + const parsed = parseGitRemoteVerboseOutput(result.stdout); + const remotes = Array.from(parsed.entries()).flatMap(([name, remote]) => { + if (!remote.url) { + return []; + } + return [ + { + name, + url: remote.url, + pushUrl: remote.pushUrl ? Option.some(remote.pushUrl) : Option.none(), + isPrimary: name === "origin", + }, + ]; + }); - const filterIgnoredPaths: VcsDriverShape["filterIgnoredPaths"] = Effect.fn("filterIgnoredPaths")( - function* (cwd, relativePaths) { - if (relativePaths.length === 0) { - return relativePaths; - } + return { + remotes, + freshness: yield* nowFreshness(), + }; + }, + ); - const ignoredPaths = new Set(); - const chunks = chunkPathsForGitCheckIgnore(relativePaths); + const filterIgnoredPaths: VcsDriver.VcsDriverShape["filterIgnoredPaths"] = Effect.fn( + "filterIgnoredPaths", + )(function* (cwd, relativePaths) { + if (relativePaths.length === 0) { + return relativePaths; + } - for (const chunk of chunks) { - const result = yield* gitCommand( - process, - "GitVcsDriver.filterIgnoredPaths", - cwd, - [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], - { - stdin: `${chunk.join("\0")}\0`, - allowNonZeroExit: true, - timeoutMs: 20_000, - maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ); - - if (result.exitCode !== 0 && result.exitCode !== 1) { - return yield* new VcsProcessExitError({ - operation: "GitVcsDriver.filterIgnoredPaths", - command: "git check-ignore", - cwd, - exitCode: result.exitCode, - detail: result.stderr.trim() || "git check-ignore failed", - }); - } + const ignoredPaths = new Set(); + const chunks = chunkPathsForGitCheckIgnore(relativePaths); - for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { - ignoredPaths.add(ignoredPath); - } + for (const chunk of chunks) { + const result = yield* gitCommand( + process, + "GitVcsDriver.filterIgnoredPaths", + cwd, + [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], + { + stdin: `${chunk.join("\0")}\0`, + allowNonZeroExit: true, + timeoutMs: 20_000, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ); + + if (result.exitCode !== 0 && result.exitCode !== 1) { + return yield* new VcsProcessExitError({ + operation: "GitVcsDriver.filterIgnoredPaths", + command: "git check-ignore", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "git check-ignore failed", + }); } - if (ignoredPaths.size === 0) { - return relativePaths; + for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { + ignoredPaths.add(ignoredPath); } + } - return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); - }, - ); + if (ignoredPaths.size === 0) { + return relativePaths; + } - const initRepository: VcsDriverShape["initRepository"] = (input) => + return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); + }); + + const initRepository: VcsDriver.VcsDriverShape["initRepository"] = (input) => gitCommand(process, "GitVcsDriver.initRepository", input.cwd, ["init"], { timeoutMs: 10_000, maxOutputBytes: 64 * 1024, }).pipe(Effect.asVoid); - return { + return VcsDriver.VcsDriver.of({ capabilities, execute, detectRepository, @@ -538,18 +540,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( listRemotes, filterIgnoredPaths, initRepository, - } satisfies VcsDriverShape; + }); }); export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { const driver = yield* makeVcsDriverShape(); - return VcsDriver.of(driver); + return VcsDriver.VcsDriver.of(driver); }); export const make = Effect.fn("makeGitVcsDriverService")(function* () { - const git = yield* makeGitVcsDriverCore(); + const git = yield* GitVcsDriverCore.makeGitVcsDriverCore(); return GitVcsDriver.of(git); }); -export const vcsLayer = Layer.effect(VcsDriver, makeVcsDriver()); +export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver()); export const layer = Layer.effect(GitVcsDriver, make()); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 25e9071b83..0daf9ab956 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,7 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { assert, it, describe } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path, PlatformError, Scope } from "effect"; -import { describe } from "vitest"; import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 707461cebc..e01a78a21f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -22,14 +22,7 @@ import { GitCommandError, type VcsRef } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import { compactTraceAttributes } from "../observability/Attributes.ts"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; -import type { - ExecuteGitProgress, - GitCommitOptions, - GitVcsDriverShape, - GitStatusDetails, - ExecuteGitInput, - ExecuteGitResult, -} from "./GitVcsDriver.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; import { parseRemoteNames, parseRemoteNamesInGitOrder, @@ -51,7 +44,7 @@ const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; -const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ +const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ isRepo: false, hasOriginRemote: false, isDefaultBranch: false, @@ -82,7 +75,7 @@ interface ExecuteGitOptions { fallbackErrorMessage?: string | undefined; maxOutputBytes?: number | undefined; truncateOutputAtMaxBytes?: boolean | undefined; - progress?: ExecuteGitProgress | undefined; + progress?: GitVcsDriver.ExecuteGitProgress | undefined; } function parseBranchAb(value: string): { ahead: number; behind: number } { @@ -332,7 +325,7 @@ function isMissingGitCwdError(error: GitCommandError): boolean { } function toGitCommandError( - input: Pick, + input: Pick, detail: string, ) { return (cause: unknown) => @@ -377,8 +370,8 @@ function trace2ChildKey(record: Record): string | null { const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( - input: Pick, - progress: ExecuteGitProgress | undefined, + input: Pick, + progress: GitVcsDriver.ExecuteGitProgress | undefined, ): Effect.fn.Return< Trace2Monitor, PlatformError.PlatformError, @@ -536,7 +529,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( }); const collectOutput = Effect.fn("collectOutput")(function* ( - input: Pick, + input: Pick, stream: Stream.Stream, maxOutputBytes: number, truncateOutputAtMaxBytes: boolean, @@ -610,13 +603,13 @@ const collectOutput = Effect.fn("collectOutput")(function* ( }); export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* (options?: { - executeOverride?: GitVcsDriverShape["execute"]; + executeOverride?: GitVcsDriver.GitVcsDriverShape["execute"]; }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const { worktreesDir } = yield* ServerConfig; - let executeRaw: GitVcsDriverShape["execute"]; + let executeRaw: GitVcsDriver.GitVcsDriverShape["execute"]; if (options?.executeOverride) { executeRaw = options.executeOverride; @@ -698,7 +691,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* stderr: stderr.text, stdoutTruncated: stdout.truncated, stderrTruncated: stderr.truncated, - } satisfies ExecuteGitResult; + } satisfies GitVcsDriver.ExecuteGitResult; }); return yield* runGitCommand().pipe( @@ -722,7 +715,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); } - const execute: GitVcsDriverShape["execute"] = (input) => + const execute: GitVcsDriver.GitVcsDriverShape["execute"] = (input) => executeRaw(input).pipe( withMetrics({ counter: gitCommandsTotal, @@ -746,7 +739,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd: string, args: readonly string[], options: ExecuteGitOptions = {}, - ): Effect.Effect => + ): Effect.Effect => execute({ operation, cwd, @@ -1023,7 +1016,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null))); }); - const ensureRemote: GitVcsDriverShape["ensureRemote"] = Effect.fn("ensureRemote")( + const ensureRemote: GitVcsDriver.GitVcsDriverShape["ensureRemote"] = Effect.fn("ensureRemote")( function* (input) { const preferredName = sanitizeRemoteName(input.preferredName); const normalizedTargetUrl = normalizeRemoteUrl(input.url); @@ -1322,13 +1315,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const statusDetailsLocal: GitVcsDriverShape["statusDetailsLocal"] = Effect.fn( + const statusDetailsLocal: GitVcsDriver.GitVcsDriverShape["statusDetailsLocal"] = Effect.fn( "statusDetailsLocal", )(function* (cwd) { return yield* readStatusDetailsLocal(cwd); }); - const statusDetails: GitVcsDriverShape["statusDetails"] = Effect.fn("statusDetails")( + const statusDetails: GitVcsDriver.GitVcsDriverShape["statusDetails"] = Effect.fn("statusDetails")( function* (cwd) { yield* refreshStatusUpstreamIfStale(cwd).pipe( Effect.catchIf(isMissingGitCwdError, () => Effect.void), @@ -1338,7 +1331,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); - const status: GitVcsDriverShape["status"] = (input) => + const status: GitVcsDriver.GitVcsDriverShape["status"] = (input) => statusDetails(input.cwd).pipe( Effect.map((details) => ({ isRepo: details.isRepo, @@ -1355,7 +1348,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* })), ); - const prepareCommitContext: GitVcsDriverShape["prepareCommitContext"] = Effect.fn( + const prepareCommitContext: GitVcsDriver.GitVcsDriverShape["prepareCommitContext"] = Effect.fn( "prepareCommitContext", )(function* (cwd, filePaths) { if (filePaths && filePaths.length > 0) { @@ -1397,11 +1390,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const commit: GitVcsDriverShape["commit"] = Effect.fn("commit")(function* ( + const commit: GitVcsDriver.GitVcsDriverShape["commit"] = Effect.fn("commit")(function* ( cwd, subject, body, - options?: GitCommitOptions, + options?: GitVcsDriver.GitCommitOptions, ) { const args = ["commit", "-m", subject]; const trimmedBody = body.trim(); @@ -1430,529 +1423,529 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { commitSha }; }); - const pushCurrentBranch: GitVcsDriverShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")( - function* (cwd, fallbackBranch, options) { - const details = yield* statusDetails(cwd); - const branch = details.branch ?? fallbackBranch; - if (!branch) { - return yield* createGitCommandError( - "GitVcsDriver.pushCurrentBranch", - cwd, - ["push"], - "Cannot push from detached HEAD.", - ); - } + const pushCurrentBranch: GitVcsDriver.GitVcsDriverShape["pushCurrentBranch"] = Effect.fn( + "pushCurrentBranch", + )(function* (cwd, fallbackBranch, options) { + const details = yield* statusDetails(cwd); + const branch = details.branch ?? fallbackBranch; + if (!branch) { + return yield* createGitCommandError( + "GitVcsDriver.pushCurrentBranch", + cwd, + ["push"], + "Cannot push from detached HEAD.", + ); + } + + const requestedRemoteName = options?.remoteName?.trim() || null; + if (requestedRemoteName) { + const publishBranch = yield* resolvePublishBranchName(cwd, branch); + yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", cwd, [ + "push", + "-u", + requestedRemoteName, + `HEAD:refs/heads/${publishBranch}`, + ]); + return { + status: "pushed" as const, + branch, + upstreamBranch: `${requestedRemoteName}/${publishBranch}`, + setUpstream: true, + }; + } - const requestedRemoteName = options?.remoteName?.trim() || null; - if (requestedRemoteName) { - const publishBranch = yield* resolvePublishBranchName(cwd, branch); - yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", cwd, [ - "push", - "-u", - requestedRemoteName, - `HEAD:refs/heads/${publishBranch}`, - ]); + const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; + if (hasNoLocalDelta) { + if (details.hasUpstream) { return { - status: "pushed" as const, + status: "skipped_up_to_date" as const, branch, - upstreamBranch: `${requestedRemoteName}/${publishBranch}`, - setUpstream: true, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), }; } - const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; - if (hasNoLocalDelta) { - if (details.hasUpstream) { + const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (comparableBaseBranch) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (!publishRemoteName) { return { status: "skipped_up_to_date" as const, branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), }; } - const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( - Effect.catch(() => Effect.succeed(null)), + const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( + Effect.catch(() => Effect.succeed(false)), ); - if (comparableBaseBranch) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( - Effect.catch(() => Effect.succeed(null)), - ); - if (!publishRemoteName) { - return { - status: "skipped_up_to_date" as const, - branch, - }; - } - - const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( - Effect.catch(() => Effect.succeed(false)), - ); - if (hasRemoteBranch) { - return { - status: "skipped_up_to_date" as const, - branch, - }; - } - } - } - - if (!details.hasUpstream) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); - if (!publishRemoteName) { - return yield* createGitCommandError( - "GitVcsDriver.pushCurrentBranch", - cwd, - ["push"], - "Cannot push because no git remote is configured for this repository.", - ); + if (hasRemoteBranch) { + return { + status: "skipped_up_to_date" as const, + branch, + }; } - const publishBranch = yield* resolvePublishBranchName(cwd, branch); - yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [ - "push", - "-u", - publishRemoteName, - `HEAD:refs/heads/${publishBranch}`, - ]); - return { - status: "pushed" as const, - branch, - upstreamBranch: `${publishRemoteName}/${publishBranch}`, - setUpstream: true, - }; } + } - const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( - Effect.catch(() => Effect.succeed(null)), - ); - if (currentUpstream) { - yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [ - "push", - currentUpstream.remoteName, - `HEAD:refs/heads/${currentUpstream.branchName}`, - ]); - return { - status: "pushed" as const, - branch, - upstreamBranch: currentUpstream.upstreamRef, - setUpstream: false, - }; + if (!details.hasUpstream) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); + if (!publishRemoteName) { + return yield* createGitCommandError( + "GitVcsDriver.pushCurrentBranch", + cwd, + ["push"], + "Cannot push because no git remote is configured for this repository.", + ); } + const publishBranch = yield* resolvePublishBranchName(cwd, branch); + yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [ + "push", + "-u", + publishRemoteName, + `HEAD:refs/heads/${publishBranch}`, + ]); + return { + status: "pushed" as const, + branch, + upstreamBranch: `${publishRemoteName}/${publishBranch}`, + setUpstream: true, + }; + } - yield* runGit("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"]); + const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (currentUpstream) { + yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [ + "push", + currentUpstream.remoteName, + `HEAD:refs/heads/${currentUpstream.branchName}`, + ]); return { status: "pushed" as const, branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + upstreamBranch: currentUpstream.upstreamRef, setUpstream: false, }; - }, - ); + } - const pullCurrentBranch: GitVcsDriverShape["pullCurrentBranch"] = Effect.fn("pullCurrentBranch")( - function* (cwd) { - const details = yield* statusDetails(cwd); - const refName = details.branch; - if (!refName) { - return yield* createGitCommandError( - "GitVcsDriver.pullCurrentBranch", - cwd, - ["pull", "--ff-only"], - "Cannot pull from detached HEAD.", - ); - } - if (!details.hasUpstream) { - return yield* createGitCommandError( - "GitVcsDriver.pullCurrentBranch", - cwd, - ["pull", "--ff-only"], - "Current branch has no upstream configured. Push with upstream first.", - ); - } - const beforeSha = yield* runGitStdout( - "GitVcsDriver.pullCurrentBranch.beforeSha", + yield* runGit("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"]); + return { + status: "pushed" as const, + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + setUpstream: false, + }; + }); + + const pullCurrentBranch: GitVcsDriver.GitVcsDriverShape["pullCurrentBranch"] = Effect.fn( + "pullCurrentBranch", + )(function* (cwd) { + const details = yield* statusDetails(cwd); + const refName = details.branch; + if (!refName) { + return yield* createGitCommandError( + "GitVcsDriver.pullCurrentBranch", cwd, - ["rev-parse", "HEAD"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { - timeoutMs: 30_000, - fallbackErrorMessage: "git pull failed", - }); - const afterSha = yield* runGitStdout( - "GitVcsDriver.pullCurrentBranch.afterSha", + ["pull", "--ff-only"], + "Cannot pull from detached HEAD.", + ); + } + if (!details.hasUpstream) { + return yield* createGitCommandError( + "GitVcsDriver.pullCurrentBranch", cwd, - ["rev-parse", "HEAD"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); + ["pull", "--ff-only"], + "Current branch has no upstream configured. Push with upstream first.", + ); + } + const beforeSha = yield* runGitStdout( + "GitVcsDriver.pullCurrentBranch.beforeSha", + cwd, + ["rev-parse", "HEAD"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { + timeoutMs: 30_000, + fallbackErrorMessage: "git pull failed", + }); + const afterSha = yield* runGitStdout( + "GitVcsDriver.pullCurrentBranch.afterSha", + cwd, + ["rev-parse", "HEAD"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); - const refreshed = yield* statusDetails(cwd); - return { - status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", - refName, - upstreamRef: refreshed.upstreamRef, - }; - }, - ); + const refreshed = yield* statusDetails(cwd); + return { + status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", + refName, + upstreamRef: refreshed.upstreamRef, + }; + }); - const readRangeContext: GitVcsDriverShape["readRangeContext"] = Effect.fn("readRangeContext")( - function* (cwd, baseRef) { - const range = `${baseRef}..HEAD`; - const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( - [ - runGitStdoutWithOptions( - "GitVcsDriver.readRangeContext.log", - cwd, - ["log", "--oneline", range], - { - maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ), - runGitStdoutWithOptions( - "GitVcsDriver.readRangeContext.diffStat", - cwd, - ["diff", "--stat", range], - { - maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ), - runGitStdoutWithOptions( - "GitVcsDriver.readRangeContext.diffPatch", - cwd, - ["diff", "--patch", "--minimal", range], - { - maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ), - ], - { concurrency: "unbounded" }, - ); + const readRangeContext: GitVcsDriver.GitVcsDriverShape["readRangeContext"] = Effect.fn( + "readRangeContext", + )(function* (cwd, baseRef) { + const range = `${baseRef}..HEAD`; + const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( + [ + runGitStdoutWithOptions( + "GitVcsDriver.readRangeContext.log", + cwd, + ["log", "--oneline", range], + { + maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ), + runGitStdoutWithOptions( + "GitVcsDriver.readRangeContext.diffStat", + cwd, + ["diff", "--stat", range], + { + maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ), + runGitStdoutWithOptions( + "GitVcsDriver.readRangeContext.diffPatch", + cwd, + ["diff", "--patch", "--minimal", range], + { + maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ), + ], + { concurrency: "unbounded" }, + ); - return { - commitSummary, - diffSummary, - diffPatch, - }; - }, - ); + return { + commitSummary, + diffSummary, + diffPatch, + }; + }); - const readConfigValue: GitVcsDriverShape["readConfigValue"] = (cwd, key) => + const readConfigValue: GitVcsDriver.GitVcsDriverShape["readConfigValue"] = (cwd, key) => runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe( Effect.map((stdout) => stdout.trim()), Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), ); - const listRefs: GitVcsDriverShape["listRefs"] = Effect.fn("listRefs")(function* (input) { - const branchRecencyPromise = readBranchRecency(input.cwd).pipe( - Effect.catch(() => Effect.succeed(new Map())), - ); - const localBranchResult = yield* executeGit( - "GitVcsDriver.listRefs.branchNoColor", - input.cwd, - ["branch", "--no-color", "--no-column"], - { - timeoutMs: 10_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catchIf(isMissingGitCwdError, () => - Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(128), - stdout: "", - stderr: "fatal: not a git repository", - stdoutTruncated: false, - stderrTruncated: false, - }), - ), - ); - - if (localBranchResult.exitCode !== 0) { - const stderr = localBranchResult.stderr.trim(); - if (stderr.toLowerCase().includes("not a git repository")) { - return { - refs: [], - isRepo: false, - hasPrimaryRemote: false, - nextCursor: null, - totalCount: 0, - }; - } - return yield* createGitCommandError( - "GitVcsDriver.listRefs", + const listRefs: GitVcsDriver.GitVcsDriverShape["listRefs"] = Effect.fn("listRefs")( + function* (input) { + const branchRecencyPromise = readBranchRecency(input.cwd).pipe( + Effect.catch(() => Effect.succeed(new Map())), + ); + const localBranchResult = yield* executeGit( + "GitVcsDriver.listRefs.branchNoColor", input.cwd, ["branch", "--no-color", "--no-column"], - stderr || "git branch failed", - ); - } - - const remoteBranchResultEffect = executeGit( - "GitVcsDriver.listRefs.remoteBranches", - input.cwd, - ["branch", "--no-color", "--no-column", "--remotes"], - { - timeoutMs: 10_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitVcsDriver.listRefs: remote refName lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote refName list.`, - ).pipe( - Effect.as({ - exitCode: ChildProcessSpawner.ExitCode(1), + { + timeoutMs: 10_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catchIf(isMissingGitCwdError, () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(128), stdout: "", - stderr: "", + stderr: "fatal: not a git repository", stdoutTruncated: false, stderrTruncated: false, - } satisfies ExecuteGitResult), + }), ), - ), - ); + ); - const remoteNamesResultEffect = executeGit( - "GitVcsDriver.listRefs.remoteNames", - input.cwd, - ["remote"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitVcsDriver.listRefs: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, - ).pipe( - Effect.as({ - exitCode: ChildProcessSpawner.ExitCode(1), - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - } satisfies ExecuteGitResult), - ), - ), - ); + if (localBranchResult.exitCode !== 0) { + const stderr = localBranchResult.stderr.trim(); + if (stderr.toLowerCase().includes("not a git repository")) { + return { + refs: [], + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 0, + }; + } + return yield* createGitCommandError( + "GitVcsDriver.listRefs", + input.cwd, + ["branch", "--no-color", "--no-column"], + stderr || "git branch failed", + ); + } - const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = - yield* Effect.all( - [ - executeGit( - "GitVcsDriver.listRefs.defaultRef", - input.cwd, - ["symbolic-ref", "refs/remotes/origin/HEAD"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ), - executeGit( - "GitVcsDriver.listRefs.worktreeList", - input.cwd, - ["worktree", "list", "--porcelain"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, + const remoteBranchResultEffect = executeGit( + "GitVcsDriver.listRefs.remoteBranches", + input.cwd, + ["branch", "--no-color", "--no-column", "--remotes"], + { + timeoutMs: 10_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catch((error) => + Effect.logWarning( + `GitVcsDriver.listRefs: remote refName lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote refName list.`, + ).pipe( + Effect.as({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } satisfies GitVcsDriver.ExecuteGitResult), ), - remoteBranchResultEffect, - remoteNamesResultEffect, - branchRecencyPromise, - ], - { concurrency: "unbounded" }, + ), ); - const remoteNames = - remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; - if (remoteBranchResult.exitCode !== 0 && remoteBranchResult.stderr.trim().length > 0) { - yield* Effect.logWarning( - `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.exitCode} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`, - ); - } - if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) { - yield* Effect.logWarning( - `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, + const remoteNamesResultEffect = executeGit( + "GitVcsDriver.listRefs.remoteNames", + input.cwd, + ["remote"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catch((error) => + Effect.logWarning( + `GitVcsDriver.listRefs: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, + ).pipe( + Effect.as({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } satisfies GitVcsDriver.ExecuteGitResult), + ), + ), ); - } - const defaultBranch = - defaultRef.exitCode === 0 - ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") - : null; + const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = + yield* Effect.all( + [ + executeGit( + "GitVcsDriver.listRefs.defaultRef", + input.cwd, + ["symbolic-ref", "refs/remotes/origin/HEAD"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + executeGit( + "GitVcsDriver.listRefs.worktreeList", + input.cwd, + ["worktree", "list", "--porcelain"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + remoteBranchResultEffect, + remoteNamesResultEffect, + branchRecencyPromise, + ], + { concurrency: "unbounded" }, + ); - const worktreeMap = new Map(); - if (worktreeList.exitCode === 0) { - let currentPath: string | null = null; - for (const line of worktreeList.stdout.split("\n")) { - if (line.startsWith("worktree ")) { - const candidatePath = line.slice("worktree ".length); - const exists = yield* fileSystem.stat(candidatePath).pipe( - Effect.map(() => true), - Effect.catch(() => Effect.succeed(false)), - ); - currentPath = exists ? candidatePath : null; - } else if (line.startsWith("branch refs/heads/") && currentPath) { - worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); - } else if (line === "") { - currentPath = null; - } + const remoteNames = + remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; + if (remoteBranchResult.exitCode !== 0 && remoteBranchResult.stderr.trim().length > 0) { + yield* Effect.logWarning( + `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.exitCode} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`, + ); + } + if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) { + yield* Effect.logWarning( + `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, + ); } - } - - const localBranches = localBranchResult.stdout - .split("\n") - .map(parseBranchLine) - .filter((refName): refName is { name: string; current: boolean } => refName !== null) - .map((refName) => ({ - name: refName.name, - current: refName.current, - isRemote: false, - isDefault: refName.name === defaultBranch, - worktreePath: worktreeMap.get(refName.name) ?? null, - })) - .toSorted((a, b) => { - const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; - const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; - if (aPriority !== bPriority) return aPriority - bPriority; - - const aLastCommit = branchLastCommit.get(a.name) ?? 0; - const bLastCommit = branchLastCommit.get(b.name) ?? 0; - if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; - return a.name.localeCompare(b.name); - }); - const remoteBranches = - remoteBranchResult.exitCode === 0 - ? remoteBranchResult.stdout - .split("\n") - .map(parseBranchLine) - .filter((refName): refName is { name: string; current: boolean } => refName !== null) - .map((refName) => { - const parsedRemoteRef = parseRemoteRefWithRemoteNames(refName.name, remoteNames); - const remoteBranch: { - name: string; - current: boolean; - isRemote: boolean; - remoteName?: string; - isDefault: boolean; - worktreePath: string | null; - } = { - name: refName.name, - current: false, - isRemote: true, - isDefault: false, - worktreePath: null, - }; - if (parsedRemoteRef) { - remoteBranch.remoteName = parsedRemoteRef.remoteName; - } - return remoteBranch; - }) - .toSorted((a, b) => { - const aLastCommit = branchLastCommit.get(a.name) ?? 0; - const bLastCommit = branchLastCommit.get(b.name) ?? 0; - if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; - return a.name.localeCompare(b.name); - }) - : []; - - const refs = paginateBranches({ - refs: filterBranchesForListQuery( - dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), - input.query, - ), - cursor: input.cursor, - limit: input.limit, - }); + const defaultBranch = + defaultRef.exitCode === 0 + ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") + : null; + + const worktreeMap = new Map(); + if (worktreeList.exitCode === 0) { + let currentPath: string | null = null; + for (const line of worktreeList.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + const candidatePath = line.slice("worktree ".length); + const exists = yield* fileSystem.stat(candidatePath).pipe( + Effect.map(() => true), + Effect.catch(() => Effect.succeed(false)), + ); + currentPath = exists ? candidatePath : null; + } else if (line.startsWith("branch refs/heads/") && currentPath) { + worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); + } else if (line === "") { + currentPath = null; + } + } + } - return { - refs: [...refs.refs], - isRepo: true, - hasPrimaryRemote: remoteNames.includes("origin"), - nextCursor: refs.nextCursor, - totalCount: refs.totalCount, - }; - }); + const localBranches = localBranchResult.stdout + .split("\n") + .map(parseBranchLine) + .filter((refName): refName is { name: string; current: boolean } => refName !== null) + .map((refName) => ({ + name: refName.name, + current: refName.current, + isRemote: false, + isDefault: refName.name === defaultBranch, + worktreePath: worktreeMap.get(refName.name) ?? null, + })) + .toSorted((a, b) => { + const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; + const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; + if (aPriority !== bPriority) return aPriority - bPriority; + + const aLastCommit = branchLastCommit.get(a.name) ?? 0; + const bLastCommit = branchLastCommit.get(b.name) ?? 0; + if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; + return a.name.localeCompare(b.name); + }); - const createWorktree: GitVcsDriverShape["createWorktree"] = Effect.fn("createWorktree")( - function* (input) { - const targetBranch = input.newRefName ?? input.refName; - const sanitizedBranch = targetBranch.replace(/\//g, "-"); - const repoName = path.basename(input.cwd); - const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); - const args = input.newRefName - ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] - : ["worktree", "add", worktreePath, input.refName]; - - yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { - fallbackErrorMessage: "git worktree add failed", + const remoteBranches = + remoteBranchResult.exitCode === 0 + ? remoteBranchResult.stdout + .split("\n") + .map(parseBranchLine) + .filter((refName): refName is { name: string; current: boolean } => refName !== null) + .map((refName) => { + const parsedRemoteRef = parseRemoteRefWithRemoteNames(refName.name, remoteNames); + const remoteBranch: { + name: string; + current: boolean; + isRemote: boolean; + remoteName?: string; + isDefault: boolean; + worktreePath: string | null; + } = { + name: refName.name, + current: false, + isRemote: true, + isDefault: false, + worktreePath: null, + }; + if (parsedRemoteRef) { + remoteBranch.remoteName = parsedRemoteRef.remoteName; + } + return remoteBranch; + }) + .toSorted((a, b) => { + const aLastCommit = branchLastCommit.get(a.name) ?? 0; + const bLastCommit = branchLastCommit.get(b.name) ?? 0; + if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; + return a.name.localeCompare(b.name); + }) + : []; + + const refs = paginateBranches({ + refs: filterBranchesForListQuery( + dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), + input.query, + ), + cursor: input.cursor, + limit: input.limit, }); return { - worktree: { - path: worktreePath, - refName: targetBranch, - }, + refs: [...refs.refs], + isRepo: true, + hasPrimaryRemote: remoteNames.includes("origin"), + nextCursor: refs.nextCursor, + totalCount: refs.totalCount, }; }, ); - const fetchPullRequestBranch: GitVcsDriverShape["fetchPullRequestBranch"] = Effect.fn( - "fetchPullRequestBranch", + const createWorktree: GitVcsDriver.GitVcsDriverShape["createWorktree"] = Effect.fn( + "createWorktree", )(function* (input) { - const remoteName = yield* resolvePrimaryRemoteName(input.cwd); - yield* executeGit( - "GitVcsDriver.fetchPullRequestBranch", - input.cwd, - [ - "fetch", - "--quiet", - "--no-tags", - remoteName, - `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, - ], - { - fallbackErrorMessage: "git fetch pull request branch failed", + const targetBranch = input.newRefName ?? input.refName; + const sanitizedBranch = targetBranch.replace(/\//g, "-"); + const repoName = path.basename(input.cwd); + const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); + const args = input.newRefName + ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] + : ["worktree", "add", worktreePath, input.refName]; + + yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { + fallbackErrorMessage: "git worktree add failed", + }); + + return { + worktree: { + path: worktreePath, + refName: targetBranch, }, - ); + }; }); - const fetchRemoteBranch: GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn("fetchRemoteBranch")( - function* (input) { - yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [ - "fetch", - "--quiet", - "--no-tags", - input.remoteName, - `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, - ]); - - const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); - const targetRef = `${input.remoteName}/${input.remoteBranch}`; - yield* runGit( - "GitVcsDriver.fetchRemoteBranch.materialize", + const fetchPullRequestBranch: GitVcsDriver.GitVcsDriverShape["fetchPullRequestBranch"] = + Effect.fn("fetchPullRequestBranch")(function* (input) { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + yield* executeGit( + "GitVcsDriver.fetchPullRequestBranch", input.cwd, - localBranchAlreadyExists - ? ["branch", "--force", input.localBranch, targetRef] - : ["branch", input.localBranch, targetRef], + [ + "fetch", + "--quiet", + "--no-tags", + remoteName, + `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, + ], + { + fallbackErrorMessage: "git fetch pull request branch failed", + }, ); - }, - ); + }); - const fetchRemoteTrackingBranch: GitVcsDriverShape["fetchRemoteTrackingBranch"] = Effect.fn( - "fetchRemoteTrackingBranch", + const fetchRemoteBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn( + "fetchRemoteBranch", )(function* (input) { - yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [ + yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [ "fetch", "--quiet", "--no-tags", input.remoteName, `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, ]); + + const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); + const targetRef = `${input.remoteName}/${input.remoteBranch}`; + yield* runGit( + "GitVcsDriver.fetchRemoteBranch.materialize", + input.cwd, + localBranchAlreadyExists + ? ["branch", "--force", input.localBranch, targetRef] + : ["branch", input.localBranch, targetRef], + ); }); - const setBranchUpstream: GitVcsDriverShape["setBranchUpstream"] = (input) => + const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteTrackingBranch"] = + Effect.fn("fetchRemoteTrackingBranch")(function* (input) { + yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [ + "fetch", + "--quiet", + "--no-tags", + input.remoteName, + `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, + ]); + }); + + const setBranchUpstream: GitVcsDriver.GitVcsDriverShape["setBranchUpstream"] = (input) => runGit("GitVcsDriver.setBranchUpstream", input.cwd, [ "branch", "--set-upstream-to", @@ -1960,31 +1953,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.branch, ]); - const removeWorktree: GitVcsDriverShape["removeWorktree"] = Effect.fn("removeWorktree")( - function* (input) { - const args = ["worktree", "remove"]; - if (input.force) { - args.push("--force"); - } - args.push(input.path); - yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { - timeoutMs: 15_000, - fallbackErrorMessage: "git worktree remove failed", - }).pipe( - Effect.mapError((error) => - createGitCommandError( - "GitVcsDriver.removeWorktree", - input.cwd, - args, - `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, - error, - ), + const removeWorktree: GitVcsDriver.GitVcsDriverShape["removeWorktree"] = Effect.fn( + "removeWorktree", + )(function* (input) { + const args = ["worktree", "remove"]; + if (input.force) { + args.push("--force"); + } + args.push(input.path); + yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { + timeoutMs: 15_000, + fallbackErrorMessage: "git worktree remove failed", + }).pipe( + Effect.mapError((error) => + createGitCommandError( + "GitVcsDriver.removeWorktree", + input.cwd, + args, + `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, + error, ), - ); - }, - ); + ), + ); + }); - const renameBranch: GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")( + const renameBranch: GitVcsDriver.GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")( function* (input) { if (input.oldBranch === input.newBranch) { return { branch: input.newBranch }; @@ -2005,105 +1998,109 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); - const switchRef: GitVcsDriverShape["switchRef"] = Effect.fn("switchRef")(function* (input) { - const [localInputExists, remoteExists] = yield* Effect.all( - [ - executeGit( - "GitVcsDriver.switchRef.localInputExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.exitCode === 0)), - executeGit( - "GitVcsDriver.switchRef.remoteExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.exitCode === 0)), - ], - { concurrency: "unbounded" }, - ); - - const localTrackingBranch = remoteExists - ? yield* executeGit( - "GitVcsDriver.switchRef.localTrackingBranch", - input.cwd, - ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.map((result) => - result.exitCode === 0 - ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName) - : null, - ), - ) - : null; + const switchRef: GitVcsDriver.GitVcsDriverShape["switchRef"] = Effect.fn("switchRef")( + function* (input) { + const [localInputExists, remoteExists] = yield* Effect.all( + [ + executeGit( + "GitVcsDriver.switchRef.localInputExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)), + executeGit( + "GitVcsDriver.switchRef.remoteExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)), + ], + { concurrency: "unbounded" }, + ); - const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName); - const localTrackedBranchTargetExists = - remoteExists && localTrackedBranchCandidate + const localTrackingBranch = remoteExists ? yield* executeGit( - "GitVcsDriver.switchRef.localTrackedBranchTargetExists", + "GitVcsDriver.switchRef.localTrackingBranch", input.cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], + ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], { timeoutMs: 5_000, allowNonZeroExit: true, }, - ).pipe(Effect.map((result) => result.exitCode === 0)) - : false; + ).pipe( + Effect.map((result) => + result.exitCode === 0 + ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName) + : null, + ), + ) + : null; - const checkoutArgs = localInputExists - ? ["checkout", input.refName] - : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists - ? ["checkout", input.refName] - : remoteExists && !localTrackingBranch - ? ["checkout", "--track", input.refName] - : remoteExists && localTrackingBranch - ? ["checkout", localTrackingBranch] - : ["checkout", input.refName]; + const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName); + const localTrackedBranchTargetExists = + remoteExists && localTrackedBranchCandidate + ? yield* executeGit( + "GitVcsDriver.switchRef.localTrackedBranchTargetExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)) + : false; - yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { - timeoutMs: 10_000, - fallbackErrorMessage: "git checkout failed", - }); + const checkoutArgs = localInputExists + ? ["checkout", input.refName] + : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists + ? ["checkout", input.refName] + : remoteExists && !localTrackingBranch + ? ["checkout", "--track", input.refName] + : remoteExists && localTrackingBranch + ? ["checkout", localTrackingBranch] + : ["checkout", input.refName]; + + yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { + timeoutMs: 10_000, + fallbackErrorMessage: "git checkout failed", + }); - const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ - "branch", - "--show-current", - ]).pipe(Effect.map((stdout) => stdout.trim() || null)); + const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ + "branch", + "--show-current", + ]).pipe(Effect.map((stdout) => stdout.trim() || null)); - return { refName }; - }); + return { refName }; + }, + ); - const createRef: GitVcsDriverShape["createRef"] = Effect.fn("createRef")(function* (input) { - yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], { - timeoutMs: 10_000, - fallbackErrorMessage: "git branch create failed", - }); - if (input.switchRef) { - yield* switchRef({ cwd: input.cwd, refName: input.refName }); - } + const createRef: GitVcsDriver.GitVcsDriverShape["createRef"] = Effect.fn("createRef")( + function* (input) { + yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], { + timeoutMs: 10_000, + fallbackErrorMessage: "git branch create failed", + }); + if (input.switchRef) { + yield* switchRef({ cwd: input.cwd, refName: input.refName }); + } - return { refName: input.refName }; - }); + return { refName: input.refName }; + }, + ); - const initRepo: GitVcsDriverShape["initRepo"] = (input) => + const initRepo: GitVcsDriver.GitVcsDriverShape["initRepo"] = (input) => executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], { timeoutMs: 10_000, fallbackErrorMessage: "git init failed", }).pipe(Effect.asVoid); - const listLocalBranchNames: GitVcsDriverShape["listLocalBranchNames"] = (cwd) => + const listLocalBranchNames: GitVcsDriver.GitVcsDriverShape["listLocalBranchNames"] = (cwd) => runGitStdout("GitVcsDriver.listLocalBranchNames", cwd, [ "branch", "--list", @@ -2118,7 +2115,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); - return { + return GitVcsDriver.GitVcsDriver.of({ execute, status, statusDetails, @@ -2143,5 +2140,5 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* switchRef, initRepo, listLocalBranchNames, - } satisfies GitVcsDriverShape; + }); }); diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index 14e5f68ef4..ae09d840f8 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -8,13 +8,13 @@ import type { VcsListWorkspaceFilesResult, VcsRepositoryIdentity, } from "@t3tools/contracts"; -import type { VcsProcessInput, VcsProcessOutput } from "./VcsProcess.ts"; +import * as VcsProcess from "./VcsProcess.ts"; export interface VcsDriverShape { readonly capabilities: VcsDriverCapabilities; readonly execute: ( - input: Omit, - ) => Effect.Effect; + input: Omit, + ) => Effect.Effect; readonly detectRepository: (cwd: string) => Effect.Effect; readonly isInsideWorkTree: (cwd: string) => Effect.Effect; readonly listWorkspaceFiles: ( diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index 8e482c46b8..b9330c885a 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -1,13 +1,12 @@ -import { assert, it } from "@effect/vitest"; +import { assert, it, describe } from "@effect/vitest"; import { Effect, Layer } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { describe } from "vitest"; -import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "./VcsProcess.ts"; -import { VcsProjectConfig } from "./VcsProjectConfig.ts"; -import { VcsDriverRegistry, make as makeVcsDriverRegistry } from "./VcsDriverRegistry.ts"; +import * as VcsProcess from "./VcsProcess.ts"; +import * as VcsProjectConfig from "./VcsProjectConfig.ts"; +import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; -const processOutput = (stdout: string): VcsProcessOutput => ({ +const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ exitCode: ChildProcessSpawner.ExitCode(0), stdout, stderr: "", @@ -17,21 +16,21 @@ const processOutput = (stdout: string): VcsProcessOutput => ({ describe("VcsDriverRegistry", () => { it.effect("routes directly by VCS driver kind for non-repository workflows", () => { - const layer = Layer.effect(VcsDriverRegistry, makeVcsDriverRegistry()).pipe( + const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe( Layer.provide( - Layer.mock(VcsProjectConfig)({ + Layer.mock(VcsProjectConfig.VcsProjectConfig)({ resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), }), ), Layer.provide( - Layer.mock(VcsProcess)({ + Layer.mock(VcsProcess.VcsProcess)({ run: () => Effect.succeed(processOutput("")), }), ), ); return Effect.gen(function* () { - const registry = yield* VcsDriverRegistry; + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; const driver = yield* registry.get("git"); assert.strictEqual(driver.capabilities.kind, "git"); @@ -39,15 +38,15 @@ describe("VcsDriverRegistry", () => { }); it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => { - const calls: VcsProcessInput[] = []; - const layer = Layer.effect(VcsDriverRegistry, makeVcsDriverRegistry()).pipe( + const calls: VcsProcess.VcsProcessInput[] = []; + const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe( Layer.provide( - Layer.mock(VcsProjectConfig)({ + Layer.mock(VcsProjectConfig.VcsProjectConfig)({ resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), }), ), Layer.provide( - Layer.mock(VcsProcess)({ + Layer.mock(VcsProcess.VcsProcess)({ run: (input) => Effect.sync(() => { calls.push(input); @@ -68,7 +67,7 @@ describe("VcsDriverRegistry", () => { ); return Effect.gen(function* () { - const registry = yield* VcsDriverRegistry; + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; const first = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); const second = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index 7798d63a08..b29fa43ed8 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -4,7 +4,7 @@ import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/co import { VcsUnsupportedOperationError } from "@t3tools/contracts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import * as VcsProjectConfig from "./VcsProjectConfig.ts"; -import type { VcsDriverShape } from "./VcsDriver.ts"; +import * as VcsDriver from "./VcsDriver.ts"; const DETECTION_CACHE_CAPACITY = 2_048; const DETECTION_CACHE_TTL = Duration.seconds(2); @@ -17,11 +17,11 @@ export interface VcsDriverResolveInput { export interface VcsDriverHandle { readonly kind: VcsDriverKind; readonly repository: VcsRepositoryIdentity; - readonly driver: VcsDriverShape; + readonly driver: VcsDriver.VcsDriverShape; } export interface VcsDriverRegistryShape { - readonly get: (kind: VcsDriverKind) => Effect.Effect; + readonly get: (kind: VcsDriverKind) => Effect.Effect; readonly detect: ( input: VcsDriverResolveInput, ) => Effect.Effect; @@ -66,7 +66,7 @@ function parseDetectionCacheKey(key: string): { export const make = Effect.fn("makeVcsDriverRegistry")(function* () { const projectConfig = yield* VcsProjectConfig.VcsProjectConfig; const git = yield* GitVcsDriver.makeVcsDriverShape(); - const drivers: Partial> = { + const drivers: Partial> = { git, }; @@ -82,7 +82,7 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* ( kind: VcsDriverKind, - driver: VcsDriverShape, + driver: VcsDriver.VcsDriverShape, cwd: string, ) { const repository = yield* driver.detectRepository(cwd); diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index 95181e94fb..b08b571673 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -1,11 +1,10 @@ -import { assert, it } from "@effect/vitest"; +import { assert, it, describe } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { Effect, FileSystem, Layer, Path } from "effect"; -import { describe } from "vitest"; -import { VcsProjectConfig, layer as VcsProjectConfigLayer } from "./VcsProjectConfig.ts"; +import * as VcsProjectConfig from "./VcsProjectConfig.ts"; -const TestLayer = VcsProjectConfigLayer.pipe( +const TestLayer = VcsProjectConfig.layer.pipe( Layer.provide(NodeServices.layer), Layer.provideMerge(NodeServices.layer), ); @@ -14,7 +13,7 @@ describe("VcsProjectConfig", () => { it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => { it.effect("returns the requested kind", () => Effect.gen(function* () { - const config = yield* VcsProjectConfig; + const config = yield* VcsProjectConfig.VcsProjectConfig; const kind = yield* config.resolveKind({ cwd: "/repo", requestedKind: "jj", @@ -42,7 +41,7 @@ describe("VcsProjectConfig", () => { JSON.stringify({ vcs: { kind: "jj" } }), ); - const config = yield* VcsProjectConfig; + const config = yield* VcsProjectConfig.VcsProjectConfig; const kind = yield* config.resolveKind({ cwd: nested }); assert.equal(kind, "jj"); @@ -57,7 +56,7 @@ describe("VcsProjectConfig", () => { const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-vcs-config-test-", }); - const config = yield* VcsProjectConfig; + const config = yield* VcsProjectConfig.VcsProjectConfig; const kind = yield* config.resolveKind({ cwd: root }); assert.equal(kind, "auto"); diff --git a/apps/server/src/vcs/VcsProvisioningService.test.ts b/apps/server/src/vcs/VcsProvisioningService.test.ts index 5f936e7a1d..b26f331a3c 100644 --- a/apps/server/src/vcs/VcsProvisioningService.test.ts +++ b/apps/server/src/vcs/VcsProvisioningService.test.ts @@ -2,13 +2,13 @@ import { assert, it } from "@effect/vitest"; import { DateTime, Effect, Layer, Option } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import type { VcsDriverShape } from "./VcsDriver.ts"; -import { VcsDriverRegistry } from "./VcsDriverRegistry.ts"; -import { VcsProvisioningService, layer } from "./VcsProvisioningService.ts"; +import * as VcsDriver from "./VcsDriver.ts"; +import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; +import * as VcsProvisioningService from "./VcsProvisioningService.ts"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); -function makeDriver(calls: string[]): VcsDriverShape { +function makeDriver(calls: string[]): VcsDriver.VcsDriverShape { return { capabilities: { kind: "git", @@ -58,16 +58,16 @@ function makeDriver(calls: string[]): VcsDriverShape { it.effect("routes repository initialization through an explicit VCS driver kind", () => { const calls: string[] = []; const driver = makeDriver(calls); - const testLayer = layer.pipe( + const testLayer = VcsProvisioningService.layer.pipe( Layer.provide( - Layer.mock(VcsDriverRegistry)({ + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), }), ), ); return Effect.gen(function* () { - const provisioning = yield* VcsProvisioningService; + const provisioning = yield* VcsProvisioningService.VcsProvisioningService; yield* provisioning.initRepository({ cwd: "/repo", kind: "git" }); assert.deepStrictEqual(calls, ["git:/repo"]); @@ -77,16 +77,16 @@ it.effect("routes repository initialization through an explicit VCS driver kind" it.effect("defaults repository initialization to Git until callers choose a VCS kind", () => { const calls: string[] = []; const driver = makeDriver(calls); - const testLayer = layer.pipe( + const testLayer = VcsProvisioningService.layer.pipe( Layer.provide( - Layer.mock(VcsDriverRegistry)({ + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), }), ), ); return Effect.gen(function* () { - const provisioning = yield* VcsProvisioningService; + const provisioning = yield* VcsProvisioningService.VcsProvisioningService; yield* provisioning.initRepository({ cwd: "/repo" }); assert.deepStrictEqual(calls, ["default:/repo"]); diff --git a/apps/server/src/vcs/VcsProvisioningService.ts b/apps/server/src/vcs/VcsProvisioningService.ts index 7d5b93dd14..9f8f822f2a 100644 --- a/apps/server/src/vcs/VcsProvisioningService.ts +++ b/apps/server/src/vcs/VcsProvisioningService.ts @@ -6,7 +6,7 @@ import { type VcsInitInput, VcsUnsupportedOperationError, } from "@t3tools/contracts"; -import { VcsDriverRegistry } from "./VcsDriverRegistry.ts"; +import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; export interface VcsProvisioningServiceShape { readonly initRepository: (input: VcsInitInput) => Effect.Effect; @@ -36,7 +36,7 @@ function resolveRequestedKind( } export const make = Effect.fn("makeVcsProvisioningService")(function* () { - const registry = yield* VcsDriverRegistry; + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; const initRepository: VcsProvisioningServiceShape["initRepository"] = Effect.fn( "VcsProvisioningService.initRepository", diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index e8bcf4bdf0..ca7cccf303 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -1,4 +1,4 @@ -import { assert, it } from "@effect/vitest"; +import { assert, it, describe } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { Deferred, Effect, Exit, FileSystem, Layer, Option, Path, Scope, Stream } from "effect"; import type { @@ -7,13 +7,9 @@ import type { VcsStatusResult, VcsStatusStreamEvent, } from "@t3tools/contracts"; -import { describe } from "vitest"; -import { - VcsStatusBroadcaster, - layer as VcsStatusBroadcasterLayer, -} from "./VcsStatusBroadcaster.ts"; -import { GitWorkflowService, type GitWorkflowServiceShape } from "../git/GitWorkflowService.ts"; +import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; const baseLocalStatus: VcsStatusLocalResult = { isRepo: true, @@ -49,9 +45,9 @@ function makeTestLayer(state: { localInvalidationCalls: number; remoteInvalidationCalls: number; }) { - return VcsStatusBroadcasterLayer.pipe( + return VcsStatusBroadcaster.layer.pipe( Layer.provide( - Layer.mock(GitWorkflowService)({ + Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => Effect.sync(() => { state.localStatusCalls += 1; @@ -87,7 +83,7 @@ describe("VcsStatusBroadcaster", () => { }; return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const first = yield* broadcaster.getStatus({ cwd: "/repo" }); const second = yield* broadcaster.getStatus({ cwd: "/repo" }); @@ -112,7 +108,7 @@ describe("VcsStatusBroadcaster", () => { }; return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); state.currentLocalStatus = { @@ -153,7 +149,7 @@ describe("VcsStatusBroadcaster", () => { }; return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); state.currentLocalStatus = { @@ -188,9 +184,9 @@ describe("VcsStatusBroadcaster", () => { localInvalidationCalls: 0, remoteInvalidationCalls: 0, }; - const testLayer = VcsStatusBroadcasterLayer.pipe( + const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provide( - Layer.mock(GitWorkflowService)({ + Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: (input) => Effect.sync(() => { seenCwds.push(input.cwd); @@ -211,7 +207,7 @@ describe("VcsStatusBroadcaster", () => { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), - } satisfies Partial), + } satisfies Partial), ), ); @@ -228,7 +224,7 @@ describe("VcsStatusBroadcaster", () => { yield* fileSystem.symlink(realDir, linkDir); const realPath = yield* fileSystem.realPath(realDir); - const broadcaster = yield* VcsStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; yield* broadcaster.getStatus({ cwd: linkDir }); yield* broadcaster.getStatus({ cwd: realDir }); @@ -249,7 +245,7 @@ describe("VcsStatusBroadcaster", () => { }; return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const snapshotDeferred = yield* Deferred.make(); const remoteUpdatedDeferred = yield* Deferred.make(); yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { @@ -289,9 +285,9 @@ describe("VcsStatusBroadcaster", () => { }; let remoteInterruptedDeferred: Deferred.Deferred | null = null; let remoteStartedDeferred: Deferred.Deferred | null = null; - const testLayer = VcsStatusBroadcasterLayer.pipe( + const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provide( - Layer.mock(GitWorkflowService)({ + Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => Effect.sync(() => { state.localStatusCalls += 1; @@ -321,7 +317,7 @@ describe("VcsStatusBroadcaster", () => { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), - } satisfies Partial), + } satisfies Partial), ), ); @@ -331,7 +327,7 @@ describe("VcsStatusBroadcaster", () => { remoteInterruptedDeferred = remoteInterrupted; remoteStartedDeferred = remoteStarted; - const broadcaster = yield* VcsStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const firstSnapshot = yield* Deferred.make(); const secondSnapshot = yield* Deferred.make(); const firstScope = yield* Scope.make(); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 028d3f1a1d..1d0cddc4c4 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -1,6 +1,7 @@ import { realpathSync } from "node:fs"; import { + Context, Duration, Effect, Exit, @@ -20,10 +21,9 @@ import type { VcsStatusResult, VcsStatusStreamEvent, } from "@t3tools/contracts"; -import { Context } from "effect"; import { mergeGitStatusParts } from "@t3tools/shared/git"; -import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; const VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); @@ -80,7 +80,7 @@ function normalizeCwd(cwd: string): string { export const layer = Layer.effect( VcsStatusBroadcaster, Effect.gen(function* () { - const workflow = yield* GitWorkflowService; + const workflow = yield* GitWorkflowService.GitWorkflowService; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), (pubsub) => PubSub.shutdown(pubsub), diff --git a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts index c0e195558b..f513f03b75 100644 --- a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts +++ b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts @@ -1,10 +1,17 @@ -import { assert, it } from "@effect/vitest"; -import { DateTime, Option } from "effect"; -import { Effect, FileSystem, Layer, Path, type PlatformError, type Scope } from "effect"; -import { describe } from "vitest"; +import { assert, it, describe } from "@effect/vitest"; +import { + Effect, + FileSystem, + Layer, + Path, + type PlatformError, + type Scope, + DateTime, + Option, +} from "effect"; import type { VcsDriverKind } from "@t3tools/contracts"; -import { VcsDriver } from "../VcsDriver.ts"; +import * as VcsDriver from "../VcsDriver.ts"; export interface VcsDriverFixture { readonly createRepo: (cwd: string) => Effect.Effect; @@ -24,7 +31,11 @@ export interface VcsDriverFixture { export interface VcsDriverContractSuiteInput { readonly name: string; readonly kind: VcsDriverKind; - readonly layer: Layer.Layer; + readonly layer: Layer.Layer< + VcsDriver.VcsDriver | R | FileSystem.FileSystem | Path.Path, + E, + never + >; readonly fixture: VcsDriverFixture; } @@ -42,7 +53,7 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp it.effect("returns null outside a repository", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); - const driver = yield* VcsDriver; + const driver = yield* VcsDriver.VcsDriver; assert.equal(yield* driver.detectRepository(cwd), null); assert.equal(yield* driver.isInsideWorkTree(cwd), false); @@ -52,7 +63,7 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp it.effect("detects repository identity inside a repository and nested directories", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); - const driver = yield* VcsDriver; + const driver = yield* VcsDriver.VcsDriver; yield* input.fixture.createRepo(cwd); yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n"); @@ -77,7 +88,7 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp it.effect("lists tracked and untracked non-ignored files", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); - const driver = yield* VcsDriver; + const driver = yield* VcsDriver.VcsDriver; yield* input.fixture.createRepo(cwd); yield* input.fixture.writeFile(cwd, "tracked.ts", "export const tracked = true;\n"); @@ -101,7 +112,7 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp it.effect("excludes ignored files from workspace listing", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); - const driver = yield* VcsDriver; + const driver = yield* VcsDriver.VcsDriver; yield* input.fixture.createRepo(cwd); yield* input.fixture.ignorePath(cwd, "*.log"); @@ -122,7 +133,7 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp it.effect("filters ignored paths", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); - const driver = yield* VcsDriver; + const driver = yield* VcsDriver.VcsDriver; yield* input.fixture.createRepo(cwd); yield* input.fixture.ignorePath(cwd, "*.log"); @@ -140,7 +151,7 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp it.effect("returns empty input unchanged", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); - const driver = yield* VcsDriver; + const driver = yield* VcsDriver.VcsDriver; yield* input.fixture.createRepo(cwd); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 665d65e335..fbefe6eac6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -56,6 +56,14 @@ import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; import { ServerAuth } from "./auth/Services/ServerAuth.ts"; import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; +import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; +import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; +import * as GitHubCli from "./sourceControl/GitHubCli.ts"; +import * as GitLabCli from "./sourceControl/GitLabCli.ts"; +import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; +import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; +import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import { BootstrapCredentialService, @@ -1134,7 +1142,25 @@ export const websocketRpcRouteLayer = Layer.unwrap( makeWsRpcLayer(session.sessionId).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide( - SourceControlDiscoveryLayer.layer.pipe(Layer.provide(VcsProcess.layer)), + SourceControlDiscoveryLayer.layer.pipe( + Layer.provide( + SourceControlProviderRegistry.layer.pipe( + Layer.provide( + Layer.mergeAll( + AzureDevOpsCli.layer, + BitbucketApi.layer, + GitHubCli.layer, + GitLabCli.layer, + ), + ), + Layer.provideMerge(GitVcsDriver.layer), + Layer.provide( + VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)), + ), + ), + ), + Layer.provide(VcsProcess.layer), + ), ), ), ), diff --git a/apps/web/src/pullRequestReference.test.ts b/apps/web/src/pullRequestReference.test.ts index 2067c41cc1..9157061a30 100644 --- a/apps/web/src/pullRequestReference.test.ts +++ b/apps/web/src/pullRequestReference.test.ts @@ -57,6 +57,10 @@ describe("parsePullRequestReference", () => { expect(parsePullRequestReference("az repos pr checkout --id 42")).toBe("42"); }); + it("accepts az repos pr checkout commands with equals-style ids", () => { + expect(parsePullRequestReference("az repos pr checkout --id=42")).toBe("42"); + }); + it("accepts az repos pr checkout commands with extra flags", () => { expect(parsePullRequestReference("az repos pr checkout --id 42 --remote-name origin")).toBe( "42", diff --git a/apps/web/src/pullRequestReference.ts b/apps/web/src/pullRequestReference.ts index 93ae7caa0f..b919e736cc 100644 --- a/apps/web/src/pullRequestReference.ts +++ b/apps/web/src/pullRequestReference.ts @@ -11,9 +11,13 @@ const AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN = /^az\s+repos\s+pr\s+checkout\s+(.+) function parseAzureDevOpsCheckoutReference(args: string): string | null { const parts = args.trim().split(/\s+/).filter(Boolean); - const idFlagIndex = parts.findIndex((part) => part === "--id" || part === "-i"); - if (idFlagIndex >= 0) { - return parts[idFlagIndex + 1] ?? null; + for (const [index, part] of parts.entries()) { + if (part === "--id" || part === "-i") { + return parts[index + 1] ?? null; + } + if (part.startsWith("--id=")) { + return part.slice("--id=".length) || null; + } } return parts.find((part) => !part.startsWith("-")) ?? null; } diff --git a/docs/source-control-providers.md b/docs/source-control-providers.md new file mode 100644 index 0000000000..ccdfd705a3 --- /dev/null +++ b/docs/source-control-providers.md @@ -0,0 +1,252 @@ +# Source Control Providers + +T3 Code can connect your local projects to source control hosting providers so you can work with +pull requests and merge requests from inside the app. + +This guide covers the providers currently supported by T3 Code: + +- GitHub +- GitLab +- Bitbucket +- Azure DevOps + +## What Provider Support Enables + +When a provider is available and authenticated, T3 Code can use it for source-control actions such +as: + +- detecting the provider for the current project +- showing whether the required provider tools are installed +- showing whether you are signed in +- finding pull requests or merge requests for the current branch +- creating pull requests or merge requests +- opening the current pull request or merge request in your browser +- checking out an existing pull request or merge request locally + +The exact wording may differ by provider. GitHub and Bitbucket call these pull requests. GitLab +calls them merge requests. + +## Check Provider Status + +Open Settings, then Source Control. + +T3 Code shows each provider with: + +- whether the required tool or credentials are available +- whether T3 Code can detect an authenticated account +- the account or host when the provider reports one +- setup hints when something is missing + +After changing authentication in your terminal, refresh the Source Control settings page or restart +T3 Code. + +## GitHub + +GitHub support uses the GitHub CLI. + +### Requirements + +Install GitHub CLI: + +```bash +brew install gh +``` + +Or use the installer from: + + + +### Sign In + +Run: + +```bash +gh auth login +``` + +Follow the prompts and choose the GitHub account you want T3 Code to use. + +To verify the login: + +```bash +gh auth status +``` + +T3 Code reads the GitHub CLI login status and shows the signed-in account in Source Control +settings. + +### Notes + +Use the same GitHub account that has access to the repositories you work with in T3 Code. If you use +SSH remotes, make sure your GitHub SSH key is set up as well. + +## GitLab + +GitLab support uses the GitLab CLI. + +### Requirements + +Install GitLab CLI: + +```bash +brew install glab +``` + +Or use the installer from: + + + +### Sign In + +Run: + +```bash +glab auth login +``` + +Follow the prompts for your GitLab host and account. + +To verify the login: + +```bash +glab auth status +``` + +T3 Code reads the GitLab CLI login status and shows the signed-in account in Source Control +settings. + +### Notes + +If your team uses a self-managed GitLab instance, authenticate `glab` against that host. T3 Code +uses your repository remote to determine which provider should handle a project. + +## Bitbucket + +Bitbucket support uses the Bitbucket Cloud REST API. + +Bitbucket does not have an official general-purpose CLI like GitHub CLI or GitLab CLI, so T3 Code +uses environment variables for authentication. + +### Requirements + +Create a Bitbucket API token for your Atlassian account. + +The token should include the Bitbucket scopes needed for the actions you want to use: + +- read access to your Bitbucket account +- read access to pull requests +- write access to pull requests + +If you want to push commits over HTTPS, your Git credentials also need write access to the +repository. Many users prefer SSH for Git push and pull. + +### Sign In + +Expose these environment variables in the shell that starts T3 Code: + +```bash +export T3CODE_BITBUCKET_EMAIL="you@example.com" +export T3CODE_BITBUCKET_API_TOKEN="your-api-token" +``` + +Use your Atlassian account email for `T3CODE_BITBUCKET_EMAIL`. + +If you normally start T3 Code from a terminal, put those exports in your shell profile, such as +`~/.zshrc`. + +If you start T3 Code from a desktop launcher, make sure the launcher receives those environment +variables too. + +To verify the token manually: + +```bash +curl -u "$T3CODE_BITBUCKET_EMAIL:$T3CODE_BITBUCKET_API_TOKEN" \ + -H "Accept: application/json" \ + "https://api.bitbucket.org/2.0/user" +``` + +T3 Code uses the same credentials to check your Bitbucket sign-in status. + +### Notes + +Bitbucket workspace billing and repository permissions can affect whether Git pushes are allowed. +If pull request creation works but pushing fails, check the repository permissions, workspace plan, +and whether your Git remote uses HTTPS credentials or SSH. + +## Azure DevOps + +Azure DevOps support uses Azure CLI with the Azure DevOps extension. + +### Requirements + +Install Azure CLI: + +```bash +brew install azure-cli +``` + +Then install the Azure DevOps extension: + +```bash +az extension add --name azure-devops +``` + +### Sign In + +Run: + +```bash +az login +``` + +Follow the browser login flow for the Azure account that has access to your Azure DevOps +organization and project. + +To verify the login: + +```bash +az account show --query user.name -o tsv +``` + +T3 Code uses Azure CLI to check your sign-in status and show the signed-in account in Source +Control settings. + +### Notes + +Azure DevOps repository remotes usually look like one of these: + +```text +https://dev.azure.com/organization/project/_git/repository +git@ssh.dev.azure.com:v3/organization/project/repository +``` + +Make sure Azure CLI can detect the organization and project for the repository you are working in. +If your team uses SSH for Git operations, your Azure DevOps SSH key must be configured separately +from `az login`. + +## Version Control Requirements + +Source control providers work with your local version control setup. + +Today, Git is the supported local version control system for provider actions. Make sure Git is +installed: + +```bash +git --version +``` + +T3 Code can also detect Jujutsu installations in Source Control settings, but provider workflows for +Jujutsu are still being built. + +## Troubleshooting + +If a provider shows as unavailable: + +1. Install the required CLI or configure the required environment variables. +2. Authenticate in your terminal. +3. Restart T3 Code or refresh Source Control settings. +4. Check that the current project's remote URL points to the provider you expect. +5. Confirm your account has access to the repository. + +If provider actions work but Git push or checkout fails, verify your Git remote and credentials +separately with normal Git commands. diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 59257a3de7..24ff3a0da8 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -7,7 +7,7 @@ export interface ChangeRequestPresentation { readonly longName: string; readonly pluralLongName: string; readonly providerLongName: string; - readonly checkoutCommandExample: string; + readonly checkoutCommandExample?: string; readonly urlExample: string; } @@ -61,7 +61,6 @@ const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { longName: "pull request", pluralLongName: "pull requests", providerLongName: "Bitbucket pull request", - checkoutCommandExample: "bb pr checkout 123", urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42", }; @@ -72,7 +71,6 @@ const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { longName: "change request", pluralLongName: "change requests", providerLongName: "change request", - checkoutCommandExample: "123", urlExample: "#42", };