From 91a03e074751e9dc732d0dddcd7b3a291caba34f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 4 May 2026 00:59:58 -0700 Subject: [PATCH] feat(source-control): add Bitbucket & Azure Devops providers (#2473) Co-authored-by: Julius Marminge Co-authored-by: Cursor Agent Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com> --- apps/server/src/git/GitManager.test.ts | 1 + .../server/src/git/GitWorkflowService.test.ts | 37 +- apps/server/src/server.test.ts | 47 +- apps/server/src/server.ts | 8 +- .../src/sourceControl/AzureDevOpsCli.test.ts | 288 ++++ .../src/sourceControl/AzureDevOpsCli.ts | 429 ++++++ .../AzureDevOpsSourceControlProvider.test.ts | 91 ++ .../AzureDevOpsSourceControlProvider.ts | 144 ++ .../src/sourceControl/BitbucketApi.test.ts | 513 +++++++ apps/server/src/sourceControl/BitbucketApi.ts | 703 ++++++++++ .../BitbucketSourceControlProvider.test.ts | 126 ++ .../BitbucketSourceControlProvider.ts | 126 ++ .../src/sourceControl/GitHubCli.test.ts | 11 +- apps/server/src/sourceControl/GitHubCli.ts | 20 +- .../GitHubSourceControlProvider.test.ts | 14 +- .../GitHubSourceControlProvider.ts | 182 ++- .../src/sourceControl/GitLabCli.test.ts | 11 +- apps/server/src/sourceControl/GitLabCli.ts | 34 +- .../GitLabSourceControlProvider.test.ts | 12 +- .../GitLabSourceControlProvider.ts | 72 +- .../SourceControlDiscovery.test.ts | 204 +-- .../sourceControl/SourceControlDiscovery.ts | 282 +--- .../sourceControl/SourceControlProvider.ts | 27 + .../SourceControlProviderDiscovery.ts | 237 ++++ .../SourceControlProviderRegistry.test.ts | 57 +- .../SourceControlProviderRegistry.ts | 126 +- .../SourceControlRepositoryService.test.ts | 38 +- .../SourceControlRepositoryService.ts | 14 +- .../sourceControl/azureDevOpsPullRequests.ts | 106 ++ .../sourceControl/bitbucketPullRequests.ts | 104 ++ apps/server/src/vcs/GitVcsDriver.ts | 228 ++-- apps/server/src/vcs/GitVcsDriverCore.test.ts | 3 +- apps/server/src/vcs/GitVcsDriverCore.ts | 1153 ++++++++-------- apps/server/src/vcs/VcsDriver.ts | 6 +- apps/server/src/vcs/VcsDriverRegistry.test.ts | 29 +- apps/server/src/vcs/VcsDriverRegistry.ts | 10 +- apps/server/src/vcs/VcsProjectConfig.test.ts | 13 +- .../src/vcs/VcsProvisioningService.test.ts | 20 +- apps/server/src/vcs/VcsProvisioningService.ts | 4 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 38 +- apps/server/src/vcs/VcsStatusBroadcaster.ts | 6 +- .../vcs/testing/VcsDriverContractHarness.ts | 35 +- apps/server/src/ws.ts | 28 +- apps/web/src/components/AnimatedHeight.tsx | 59 + apps/web/src/components/ChatView.browser.tsx | 75 +- .../src/components/CommandPalette.logic.ts | 1 + apps/web/src/components/CommandPalette.tsx | 443 +++--- .../src/components/CommandPaletteResults.tsx | 52 +- apps/web/src/components/GitActionsControl.tsx | 1205 ++++++++++------- .../settings/AddProviderInstanceDialog.tsx | 316 ++--- .../settings/ConnectionsSettings.tsx | 68 +- .../settings/SettingsPanels.browser.tsx | 14 +- .../settings/SourceControlSettings.tsx | 47 +- .../src/lib/sourceControlDiscoveryState.ts | 95 +- apps/web/src/pullRequestReference.test.ts | 4 + apps/web/src/pullRequestReference.ts | 10 +- docs/source-control-providers.md | 127 ++ packages/contracts/src/sourceControl.ts | 14 +- packages/shared/src/sourceControl.ts | 4 +- 59 files changed, 5839 insertions(+), 2332 deletions(-) create mode 100644 apps/server/src/sourceControl/AzureDevOpsCli.test.ts create mode 100644 apps/server/src/sourceControl/AzureDevOpsCli.ts create mode 100644 apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts create mode 100644 apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts create mode 100644 apps/server/src/sourceControl/BitbucketApi.test.ts create mode 100644 apps/server/src/sourceControl/BitbucketApi.ts create mode 100644 apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts create mode 100644 apps/server/src/sourceControl/BitbucketSourceControlProvider.ts create mode 100644 apps/server/src/sourceControl/SourceControlProviderDiscovery.ts create mode 100644 apps/server/src/sourceControl/azureDevOpsPullRequests.ts create mode 100644 apps/server/src/sourceControl/bitbucketPullRequests.ts create mode 100644 apps/web/src/components/AnimatedHeight.tsx create mode 100644 docs/source-control-providers.md diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 9c47e8099b39cd2b151006754f1e9f0bcc75ab87..b5c9c0338fd5ca80da49790472e70a1c6f2313b2 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 bc0624beafbdb722b5cdb9a5137b9b794c5477bc..dd7273b40c9a1e7a5b556e02fb57921b695f3fbb 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 36af229d4c8b3966dfcb2d220c298ed981cd87b7..152fba1ea27901369634ca65b8d08c65bfd10e9f 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 f06e0e78ed1a3f0f68fe90e901bc160f44df60a6..939b2c8abf87670ed6ae90674f9fa07a5a65bd3b 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 0000000000000000000000000000000000000000..406c9772f3470131d5a32ec8fca30cf1c424fc94 --- /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 0000000000000000000000000000000000000000..375bfc1a54b711fd880a0493dc568392065dd35d --- /dev/null +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -0,0 +1,429 @@ +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 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", + SourceControlProvider.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", + SourceControlProvider.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 0000000000000000000000000000000000000000..c46bc0ee7fc789fafa4bde22b5112a0ca047d303 --- /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 0000000000000000000000000000000000000000..cd4162a934e030a5786290810b889837ae584d03 --- /dev/null +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -0,0 +1,144 @@ +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, + installHint: + "Install the Azure command-line tools (`az`), then enable 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 0000000000000000000000000000000000000000..5542ea40921eedd75864b8dc049f4515be499d64 --- /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 0000000000000000000000000000000000000000..4f795e864a1a06c2599cf29585f967344bf2302d --- /dev/null +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -0,0 +1,703 @@ +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 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 = "${SourceControlProvider.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: SourceControlProvider.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 0000000000000000000000000000000000000000..4bf658f5685b86054fcd680c583d991f5f098f66 --- /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 0000000000000000000000000000000000000000..ede80e921dc20927957c20314398fefa168b2fe1 --- /dev/null +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -0,0 +1,126 @@ +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", + installHint: + "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request and repository scopes).", + 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 7f1ac441524a01ee721a92728a647c66a44d3dad..778e0c4962ee3182744872175813215a7f31e28e 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 b6466138538c65480f4622d528c70a2125820166..fe83d41ef4316ba58e8a6d00d93ad01a83e6442a 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 ac3de1d9747acce5a9d193344e3b7abf08452b57..3c4ad5ac473dbf36626447effdeb9bec42c3eed0 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 b893e70f7bc928c602c828764e076fb88bc41316..7dba48936978a97ccd97ea74466897e14b195036 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, + installHint: + "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", +} 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 d4e07efb8894997277fa201337770366c31a50c8..5c7e978d857066dd2a4694a4d95a329f876fa24d 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 6dac7f7eab717e85b40f6c23f4f5566a99c4e375..c4485bb09b72a8d2e77e414a3b21adbc37320252 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 7773a3b9056d8d88980f151009e832c635de01dd..930c1c018f516fec7fea842ea58ca5a436c5c15a 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 41c5598e67d107556d640c529c9476809e8dac79..5b0538babd8d99da2fd74046c533e21c410e5515 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,57 @@ 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, + installHint: + "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).", +} 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 +109,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 +141,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 4ac3a3ffb47964b3e41e666e88a54a67a335aea0..ce41265d33642b9dd875f862148560b5586f3a78 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, @@ -22,39 +48,53 @@ const processOutput = ( stderrTruncated: false, }); -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 +it.effect("reports implemented tools separately from locally available executables", () => { + 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( @@ -79,7 +119,6 @@ Logged in to github.com account juliusmarminge (keyring) assert.deepStrictEqual( result.sourceControlProviders.map((item) => ({ kind: item.kind, - implemented: item.implemented, status: item.status, auth: item.auth.status, account: item.auth.account, @@ -87,88 +126,89 @@ Logged in to github.com account juliusmarminge (keyring) [ { kind: "github", - implemented: true, status: "available", auth: "authenticated", account: Option.some("juliusmarminge"), }, { kind: "gitlab", - implemented: true, status: "missing", auth: "unknown", account: Option.none(), }, { kind: "azure-devops", - implemented: false, status: "missing", auth: "unknown", account: Option.none(), }, { kind: "bitbucket", - implemented: false, - status: "missing", - auth: "unknown", + 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, undefined); }).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 +216,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 +247,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 8a387a96cd7c319a78eee3fbe7ce66438bf8475e..4a44d35087a36b0a03ae41b430fed045e5c0ba7a 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 ef376eedb4a2a01819b84b997f342ef8e17f062d..12f89caf77e37d47f74de6b3d0ddf9cadea35dcc 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -21,6 +21,33 @@ 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 normalizeSourceBranch(headSelector: string): string { + return parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim(); +} + +export function sourceBranch(input: { + readonly headSelector: string; + readonly source?: SourceControlRefSelector; +}): string { + return input.source?.refName ?? normalizeSourceBranch(input.headSelector); +} + +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 0000000000000000000000000000000000000000..87c0c4756a4ab5ffaf70317a4d35107e622acbf2 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -0,0 +1,237 @@ +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 installHint: string; +} + +export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { + readonly type: "cli"; + readonly executable: string; + 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 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, + 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, + 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, + 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("Hosting integration command was not found on the server PATH."), + } 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 fbfe8a36892d6c68eebae0ae9bc1e15351da01d1..395bd9b5e87dde4f1c15d6e195a9ba7c1125a9c1 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 6b04b1f8bbd3b4a9036235117e3497c7bbeed548..c8b79f216517c6da81617d82cc496b65d1ef7eaa 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 56ab64b54b3eab22e51478791d39343d97236ceb..5280ee0e59c72aef733106e331e336330d6341ff 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 bfd19f2cdfa8c456ed58b6588aae56b143960d1e..1bf71ac12dc533357fedd4b8a97f3a59c6d9e094 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 0000000000000000000000000000000000000000..48c7a836110810a2c563e01b93ecfd20c3f76671 --- /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 0000000000000000000000000000000000000000..5313eaba9742f66a28b9a3a1c1ec6e1f80a34513 --- /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 727f7e8650a62dbe63fee754a66d34503873501b..49a135aec4cc77aac791c25a3fc880d5b9d36274 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 []; - } - return [ - { - name, - url: remote.url, - pushUrl: remote.pushUrl ? Option.some(remote.pushUrl) : Option.none(), - isPrimary: name === "origin", - }, - ]; - }); - - return { - remotes, - freshness: yield* nowFreshness(), - }; - }); - - const filterIgnoredPaths: VcsDriverShape["filterIgnoredPaths"] = Effect.fn("filterIgnoredPaths")( - function* (cwd, relativePaths) { - if (relativePaths.length === 0) { - return relativePaths; - } - - const ignoredPaths = new Set(); - const chunks = chunkPathsForGitCheckIgnore(relativePaths); - - for (const chunk of chunks) { - const result = yield* gitCommand( - process, - "GitVcsDriver.filterIgnoredPaths", + if (result.exitCode !== 0) { + return yield* new VcsProcessExitError({ + operation: "GitVcsDriver.listRemotes", + command: "git remote -v", cwd, - [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], + exitCode: result.exitCode, + detail: result.stderr.trim() || "git remote -v failed", + }); + } + + const parsed = parseGitRemoteVerboseOutput(result.stdout); + const remotes = Array.from(parsed.entries()).flatMap(([name, remote]) => { + if (!remote.url) { + return []; + } + return [ { - stdin: `${chunk.join("\0")}\0`, - allowNonZeroExit: true, - timeoutMs: 20_000, - maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, + name, + url: remote.url, + pushUrl: remote.pushUrl ? Option.some(remote.pushUrl) : Option.none(), + isPrimary: name === "origin", }, - ); + ]; + }); - 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", - }); - } - - for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { - ignoredPaths.add(ignoredPath); - } - } - - if (ignoredPaths.size === 0) { - return relativePaths; - } - - return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); + return { + remotes, + freshness: yield* nowFreshness(), + }; }, ); - const initRepository: VcsDriverShape["initRepository"] = (input) => + const filterIgnoredPaths: VcsDriver.VcsDriverShape["filterIgnoredPaths"] = Effect.fn( + "filterIgnoredPaths", + )(function* (cwd, relativePaths) { + if (relativePaths.length === 0) { + return relativePaths; + } + + const ignoredPaths = new Set(); + const chunks = chunkPathsForGitCheckIgnore(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", + }); + } + + for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { + ignoredPaths.add(ignoredPath); + } + } + + if (ignoredPaths.size === 0) { + return relativePaths; + } + + 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 25e9071b839b27a86e059c7c6b269dec5321f88a..0daf9ab95646bdc33d001844d383de615a5c09e9 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 707461cebc18c3d1076c43d93c574947998a7c28..e01a78a21f1812c8632d490fa8ffa51d86585e24 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}`, - ]); + 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 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) { - return { - status: "skipped_up_to_date" as const, - branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), - }; - } - - const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( + 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 (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.", - ); + 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, + }; } - 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 defaultBranch = + defaultRef.exitCode === 0 + ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") + : null; - 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 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 refs = paginateBranches({ - refs: filterBranchesForListQuery( - dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), - input.query, - ), - cursor: input.cursor, - limit: input.limit, - }); + 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; - return { - refs: [...refs.refs], - isRepo: true, - hasPrimaryRemote: remoteNames.includes("origin"), - nextCursor: refs.nextCursor, - totalCount: refs.totalCount, - }; - }); + 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]; + 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); + }) + : []; - yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { - fallbackErrorMessage: "git worktree add failed", + 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 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 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 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 + 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; + + const checkoutArgs = localInputExists ? ["checkout", input.refName] - : remoteExists && !localTrackingBranch - ? ["checkout", "--track", input.refName] - : remoteExists && localTrackingBranch - ? ["checkout", localTrackingBranch] - : ["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", - }); + 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 14e5f68ef45c2954dfb2148effd03b0c7d716e38..ae09d840f88040e033068ae2532e02a01dc55976 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 8e482c46b818028ae714042a2384493ca5e105cf..b9330c885a84ba64c05c5f2ab4d527fa5b1af08f 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 7798d63a087d2db073b2634df8b28baa05f9331d..b29fa43ed8ead1b719120940cc29af0dffef83e0 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 95181e94fb269d30b4d5a05bad734dc94849562d..b08b5716732614d308c185925d51902fc27b3e60 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 5f936e7a1d789152ac1fbf6951adf8930a1121a0..b26f331a3c79a8c0eab6fb424d70e49112fc5f19 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 7d5b93dd14da135587feac44bb3abc6a9b5bf764..9f8f822f2a259124a562c06b753cb7b9ab1128fb 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 e8bcf4bdf03c95829c8fc5ffa7071e8638166ce3..ca7cccf303ca34c33facbb2ab408e47ce83d49e7 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 028d3f1a1d964f9657e9d9dbde8a820b4d849e18..1d0cddc4c412a1b7a203c0bfbca7f4a08cffc4f9 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 c0e195558b506b88e4db26760fb7f0115af5b2e6..f513f03b756c0f1a08a3d13119fbfb29d6b0e73f 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 665d65e3357aebafa46cf94d88f94b9c14ce22bf..fbefe6eac6220cc315e4ed2c2c99b1b332d9efec 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/components/AnimatedHeight.tsx b/apps/web/src/components/AnimatedHeight.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dd404c49dffbc8f9855623e4d94765a6a0516727 --- /dev/null +++ b/apps/web/src/components/AnimatedHeight.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { type ReactNode, useLayoutEffect, useRef, useState } from "react"; + +export function AnimatedHeight({ children }: { readonly children: ReactNode }) { + const contentRef = useRef(null); + const [height, setHeight] = useState(null); + + useLayoutEffect(() => { + const element = contentRef.current; + if (!element) return; + let firstFrameId: number | null = null; + let secondFrameId: number | null = null; + + const updateHeight = () => { + const nextHeight = Math.ceil(element.scrollHeight || element.getBoundingClientRect().height); + setHeight((currentHeight) => (currentHeight === nextHeight ? currentHeight : nextHeight)); + }; + const cancelPendingFrames = () => { + if (firstFrameId !== null) { + window.cancelAnimationFrame(firstFrameId); + firstFrameId = null; + } + if (secondFrameId !== null) { + window.cancelAnimationFrame(secondFrameId); + secondFrameId = null; + } + }; + const updateHeightAfterPaint = () => { + cancelPendingFrames(); + updateHeight(); + firstFrameId = window.requestAnimationFrame(() => { + firstFrameId = null; + updateHeight(); + secondFrameId = window.requestAnimationFrame(() => { + secondFrameId = null; + updateHeight(); + }); + }); + }; + + updateHeightAfterPaint(); + const resizeObserver = new ResizeObserver(updateHeightAfterPaint); + resizeObserver.observe(element); + return () => { + resizeObserver.disconnect(); + cancelPendingFrames(); + }; + }, []); + + return ( +
+
{children}
+
+ ); +} diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 6c0e6113860f3a9a2ba4acef057b44732d14dfea..578dc7c045d5a7e88ef586c614162e858d270334 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -22,6 +22,7 @@ import { import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; +import { Option } from "effect"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; import { page } from "vitest/browser"; @@ -953,6 +954,73 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } + if (tag === WS_METHODS.serverDiscoverSourceControl) { + return { + versionControlSystems: [], + sourceControlProviders: [ + { + kind: "github", + label: "GitHub", + executable: "gh", + status: "available", + version: Option.some("gh version 2.0.0"), + installHint: "Install GitHub CLI.", + detail: Option.none(), + auth: { + status: "authenticated", + account: Option.some("t3-oss"), + host: Option.some("github.com"), + detail: Option.none(), + }, + }, + { + kind: "gitlab", + label: "GitLab", + executable: "glab", + status: "available", + version: Option.some("glab version 1.0.0"), + installHint: "Install GitLab CLI.", + detail: Option.none(), + auth: { + status: "authenticated", + account: Option.some("t3-oss"), + host: Option.some("gitlab.com"), + detail: Option.none(), + }, + }, + { + kind: "bitbucket", + label: "Bitbucket", + executable: "Bitbucket REST API", + status: "available", + version: Option.none(), + installHint: "Set Bitbucket API token environment variables.", + detail: Option.none(), + auth: { + status: "authenticated", + account: Option.some("t3-oss"), + host: Option.some("bitbucket.org"), + detail: Option.none(), + }, + }, + { + kind: "azure-devops", + label: "Azure DevOps", + executable: "az", + status: "available", + version: Option.some("azure-cli 2.0.0"), + installHint: "Install Azure CLI.", + detail: Option.none(), + auth: { + status: "authenticated", + account: Option.some("t3-oss"), + host: Option.some("dev.azure.com"), + detail: Option.none(), + }, + }, + ], + }; + } if (tag === WS_METHODS.vcsListRefs) { return { isRepo: true, @@ -4650,6 +4718,7 @@ describe("ChatView timeline estimator parity (full app)", () => { const palette = page.getByTestId("command-palette"); await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Local folder", { exact: true }).click(); const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); await expect.element(browseInput).toHaveValue("~/"); @@ -4712,6 +4781,7 @@ describe("ChatView timeline estimator parity (full app)", () => { const palette = page.getByTestId("command-palette"); await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Local folder", { exact: true }).click(); const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); await expect.element(browseInput).toHaveValue("~/Development/"); @@ -4771,6 +4841,7 @@ describe("ChatView timeline estimator parity (full app)", () => { await page.getByTestId("sidebar-add-project-trigger").click(); await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Local folder", { exact: true }).click(); const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Desktop/fresh-project"); @@ -4850,6 +4921,7 @@ describe("ChatView timeline estimator parity (full app)", () => { await page.getByTestId("sidebar-add-project-trigger").click(); await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Local folder", { exact: true }).click(); const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/codex/"); @@ -4976,12 +5048,12 @@ describe("ChatView timeline estimator parity (full app)", () => { await expect.element(palette).toBeInTheDocument(); await palette.getByText("Add project", { exact: true }).click(); - await palette.getByText("Local folder", { exact: true }).click(); await expect.element(palette.getByText("Environments", { exact: true })).toBeInTheDocument(); await expect .element(palette.getByText("This device", { exact: true }).first()) .toBeInTheDocument(); await palette.getByText("Staging", { exact: true }).click(); + await palette.getByText("Local folder", { exact: true }).click(); const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); await expect.element(browseInput).toHaveValue("~/workspaces/"); @@ -5075,6 +5147,7 @@ describe("ChatView timeline estimator parity (full app)", () => { const palette = page.getByTestId("command-palette"); await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Local folder", { exact: true }).click(); const browseInput = palette.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER); await browseInput.fill("~/Applications/access"); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 450f678dd5cc384a5496b77f8bac1bf8ca06473e..3f4997e215c05876f9f8526ec86369f43d40644d 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -17,6 +17,7 @@ export interface CommandPaletteItem { readonly description?: string; readonly timestamp?: string; readonly icon: ReactNode; + readonly disabled?: boolean; /** Optional content rendered inline before the title text. */ readonly titleLeadingContent?: ReactNode; /** Optional content rendered inline after the title text (before the timestamp). */ diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 6b400661478d47238953d5133b61d1c1e964576a..027688a284823370bd393ea12ab1679543fdd12b 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -7,11 +7,13 @@ import { type FilesystemBrowseResult, type ProjectId, ProviderInstanceId, + type SourceControlDiscoveryResult, type SourceControlProviderKind, type SourceControlRepositoryInfo, } from "@t3tools/contracts"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; +import { Option } from "effect"; import { ArrowDownIcon, ArrowLeftIcon, @@ -28,6 +30,7 @@ import { useCallback, useDeferredValue, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -45,6 +48,10 @@ import { import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useSettings } from "../hooks/useSettings"; import { readLocalApi } from "../localApi"; +import { + getSourceControlDiscoverySnapshot, + refreshSourceControlDiscovery, +} from "../lib/sourceControlDiscoveryState"; import { startNewThreadInProjectFromContext, startNewThreadFromContext, @@ -92,7 +99,7 @@ import { } from "./CommandPalette.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; -import { GitHubIcon, GitLabIcon } from "./Icons"; +import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { useServerKeybindings } from "../rpc/serverState"; @@ -108,6 +115,7 @@ import { import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; import { stackedThreadToast, toastManager } from "./ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; @@ -143,7 +151,10 @@ interface AddProjectEnvironmentOption { readonly isPrimary: boolean; } -type AddProjectRemoteProviderKind = Extract; +type AddProjectRemoteProviderKind = Extract< + SourceControlProviderKind, + "github" | "gitlab" | "bitbucket" | "azure-devops" +>; type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; type AddProjectCloneFlow = @@ -161,7 +172,19 @@ type AddProjectCloneFlow = readonly remoteUrl: string; }; -const REMOTE_PROJECT_SOURCES: ReadonlyArray = ["github", "gitlab", "url"]; +const REMOTE_PROJECT_SOURCES: ReadonlyArray = [ + "url", + "github", + "gitlab", + "bitbucket", + "azure-devops", +]; +const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray = [ + "github", + "gitlab", + "bitbucket", + "azure-devops", +]; function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { switch (source) { @@ -169,11 +192,30 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { return "GitHub"; case "gitlab": return "GitLab"; + case "bitbucket": + return "Bitbucket"; + case "azure-devops": + return "Azure DevOps"; case "url": return "Git URL"; } } +function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string { + switch (source) { + case "github": + return "owner/repo"; + case "gitlab": + return "group/project"; + case "bitbucket": + return "workspace/repository"; + case "azure-devops": + return "project/repository"; + case "url": + return "URL"; + } +} + function remoteProjectSourceProvider( source: AddProjectRemoteSource, ): AddProjectRemoteProviderKind | null { @@ -186,6 +228,10 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri return ; case "gitlab": return ; + case "bitbucket": + return ; + case "azure-devops": + return ; case "url": return ; } @@ -197,7 +243,80 @@ function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string if (flow.source === "url") { return "Enter Git clone URL"; } - return `Enter ${remoteProjectSourceLabel(flow.source)} repository (owner/repo)`; + return `Enter ${remoteProjectSourceLabel(flow.source)} repository (${remoteProjectSourcePathHint(flow.source)})`; +} + +function sourceProviderKind(source: AddProjectRemoteSource): AddProjectRemoteProviderKind | null { + return source === "url" ? null : source; +} + +function sortAddProjectProviderSources( + readinessBySource: AddProjectRemoteSourceReadiness, +): ReadonlyArray { + return REMOTE_PROJECT_PROVIDER_SOURCES.toSorted((left, right) => { + const leftReady = readinessBySource[left].ready; + const rightReady = readinessBySource[right].ready; + if (leftReady !== rightReady) { + return leftReady ? -1 : 1; + } + return remoteProjectSourceLabel(left).localeCompare(remoteProjectSourceLabel(right)); + }); +} + +type AddProjectRemoteSourceReadiness = Record< + AddProjectRemoteSource, + { readonly ready: boolean; readonly hint: string | null } +>; + +function buildAddProjectRemoteSourceReadiness( + discovery: SourceControlDiscoveryResult | null, +): AddProjectRemoteSourceReadiness { + const unavailable = { + ready: false, + hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", + } as const; + const defaultReadiness: AddProjectRemoteSourceReadiness = { + url: { ready: true, hint: null }, + github: unavailable, + gitlab: unavailable, + bitbucket: unavailable, + "azure-devops": unavailable, + }; + + if (!discovery) { + return defaultReadiness; + } + + const providerByKind = new Map( + discovery.sourceControlProviders.map((provider) => [provider.kind, provider]), + ); + const readiness = { ...defaultReadiness }; + + for (const source of REMOTE_PROJECT_SOURCES) { + const kind = sourceProviderKind(source); + if (!kind) continue; + const provider = providerByKind.get(kind); + if (!provider) { + readiness[source] = unavailable; + continue; + } + if (provider.status !== "available") { + readiness[source] = { ready: false, hint: provider.installHint }; + continue; + } + if (provider.auth.status === "unauthenticated") { + readiness[source] = { + ready: false, + hint: + Option.getOrNull(provider.auth.detail) ?? + `${provider.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, + }; + continue; + } + readiness[source] = { ready: true, hint: null }; + } + + return readiness; } function errorMessage(error: unknown): string { @@ -658,6 +777,145 @@ function OpenCommandPaletteDialog() { [], ); + const openSourceControlSettings = useCallback(() => { + setOpen(false); + void navigate({ to: "/settings/source-control" }); + }, [navigate, setOpen]); + + const buildAddProjectSourceGroups = useCallback( + ( + environmentId: EnvironmentId, + readinessBySource: AddProjectRemoteSourceReadiness, + ): CommandPaletteView["groups"] => { + const sourceItems: Array = [ + { + kind: "action", + value: `action:add-project:${environmentId}:local`, + searchTerms: ["local", "folder", "directory", "browse"], + title: "Local folder", + description: "Browse a folder on disk", + icon: , + keepOpen: true, + run: async () => { + startAddProjectBrowse(environmentId); + }, + }, + ]; + + const orderedSources: ReadonlyArray = [ + "url", + ...sortAddProjectProviderSources(readinessBySource), + ]; + + for (const source of orderedSources) { + const label = remoteProjectSourceLabel(source); + const title = source === "url" ? "Git URL" : `${label} repository`; + const description = + source === "url" + ? "Clone from a remote URL" + : `Clone ${label} ${remoteProjectSourcePathHint(source)}`; + const readiness = readinessBySource[source]; + const disabledHint = readiness.hint; + + const titleTrailingContent = readiness.ready ? undefined : ( + + + { + openSourceControlSettings(); + }} + > + Setup Required + + } + /> + + {disabledHint ?? "Open Settings -> Source Control to configure this provider."} + + + + ); + + if (!readiness.ready) { + sourceItems.push({ + kind: "action", + value: `action:add-project:${environmentId}:${source}:not-ready`, + searchTerms: ["clone", "remote", "repository", "repo", "git", label, "setup required"], + title, + description, + disabled: true, + icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), + ...(titleTrailingContent ? { titleTrailingContent } : {}), + run: async () => {}, + }); + continue; + } + + sourceItems.push({ + kind: "action", + value: `action:add-project:${environmentId}:${source}`, + searchTerms: ["clone", "remote", "repository", "repo", "git", label], + title, + description, + icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), + ...(titleTrailingContent ? { titleTrailingContent } : {}), + keepOpen: true, + run: async () => { + startAddProjectClone(environmentId, source); + }, + }); + } + + return [{ value: `sources:${environmentId}`, label: "Sources", items: sourceItems }]; + }, + [openSourceControlSettings, startAddProjectBrowse, startAddProjectClone], + ); + + const startAddProjectSourceSelection = useCallback( + (environmentId: EnvironmentId): void => { + setAddProjectEnvironmentId(environmentId); + setAddProjectCloneFlow(null); + const target = { environmentId }; + const initialDiscovery = getSourceControlDiscoverySnapshot(target).data; + pushPaletteView({ + addonIcon: , + groups: buildAddProjectSourceGroups( + environmentId, + buildAddProjectRemoteSourceReadiness(initialDiscovery), + ), + }); + + if (initialDiscovery) { + return; + } + + void refreshSourceControlDiscovery(target).then((discovery) => { + setViewStack((previousViews) => { + const currentTopView = previousViews.at(-1); + if (currentTopView?.groups[0]?.value !== `sources:${environmentId}`) { + return previousViews; + } + return [ + ...previousViews.slice(0, -1), + { + addonIcon: , + groups: buildAddProjectSourceGroups( + environmentId, + buildAddProjectRemoteSourceReadiness(discovery), + ), + }, + ]; + }); + }); + }, + [buildAddProjectSourceGroups], + ); + const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( (option) => ({ kind: "action", @@ -668,7 +926,7 @@ function OpenCommandPaletteDialog() { icon: , keepOpen: true, run: async () => { - startAddProjectBrowse(option.environmentId); + startAddProjectSourceSelection(option.environmentId); }, }), ); @@ -684,156 +942,6 @@ function OpenCommandPaletteDialog() { [addProjectEnvironmentItems], ); - const addProjectRemoteEnvironmentGroups = useMemo< - Record - >(() => { - const buildGroups = (source: AddProjectRemoteSource): CommandPaletteView["groups"] => [ - { - value: `environments:${source}`, - label: "Environments", - items: addProjectEnvironmentOptions.map((option) => ({ - kind: "action" as const, - value: `action:add-project:${source}:environment:${option.environmentId}`, - searchTerms: [ - option.label, - option.environmentId, - option.isPrimary ? "this device" : "", - remoteProjectSourceLabel(source), - ], - title: option.label, - description: option.isPrimary ? "This device" : option.environmentId, - icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), - keepOpen: true, - run: async () => { - startAddProjectClone(option.environmentId, source); - }, - })), - }, - ]; - - return { - github: buildGroups("github"), - gitlab: buildGroups("gitlab"), - url: buildGroups("url"), - }; - }, [addProjectEnvironmentOptions, startAddProjectClone]); - - const openAddProjectCloneFlow = useCallback( - (source: AddProjectRemoteSource) => { - if (addProjectEnvironmentOptions.length > 1) { - pushPaletteView({ - addonIcon: remoteProjectSourceIcon(source, ADDON_ICON_CLASS), - groups: addProjectRemoteEnvironmentGroups[source], - }); - return; - } - - const environmentId = defaultAddProjectEnvironmentId; - if (!environmentId) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to clone project", - description: "No environment is available.", - }), - ); - return; - } - - startAddProjectClone(environmentId, source); - }, - [ - addProjectEnvironmentOptions.length, - addProjectRemoteEnvironmentGroups, - defaultAddProjectEnvironmentId, - startAddProjectClone, - ], - ); - - const addProjectSourceGroups = useMemo(() => { - const sourceItems: Array = []; - - if (addProjectEnvironmentOptions.length > 1) { - sourceItems.push({ - kind: "submenu", - value: "action:add-project:local", - searchTerms: ["local", "folder", "directory", "browse", "environment"], - title: "Local folder", - description: "Browse a folder on disk", - icon: , - addonIcon: , - groups: addProjectEnvironmentGroups, - }); - } else { - sourceItems.push({ - kind: "action", - value: "action:add-project:local", - searchTerms: ["local", "folder", "directory", "browse"], - title: "Local folder", - description: "Browse a folder on disk", - icon: , - keepOpen: true, - run: async () => { - const environmentId = defaultAddProjectEnvironmentId; - if (!environmentId) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to browse projects", - description: "No environment is available.", - }), - ); - return; - } - startAddProjectBrowse(environmentId); - }, - }); - } - - for (const source of REMOTE_PROJECT_SOURCES) { - const label = remoteProjectSourceLabel(source); - const title = source === "url" ? "Git URL" : `${label} repository`; - const description = - source === "url" ? "Clone from a remote URL" : `Clone ${label} owner/repo`; - - if (addProjectEnvironmentOptions.length > 1) { - sourceItems.push({ - kind: "submenu", - value: `action:add-project:${source}`, - searchTerms: ["clone", "remote", "repository", "repo", "git", label], - title, - description, - icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), - addonIcon: remoteProjectSourceIcon(source, ADDON_ICON_CLASS), - groups: addProjectRemoteEnvironmentGroups[source], - }); - continue; - } - - sourceItems.push({ - kind: "action", - value: `action:add-project:${source}`, - searchTerms: ["clone", "remote", "repository", "repo", "git", label], - title, - description, - icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), - keepOpen: true, - run: async () => { - openAddProjectCloneFlow(source); - }, - }); - } - - return [{ value: "sources", label: "Sources", items: sourceItems }]; - }, [ - addProjectEnvironmentGroups, - addProjectEnvironmentOptions.length, - addProjectRemoteEnvironmentGroups, - defaultAddProjectEnvironmentId, - openAddProjectCloneFlow, - startAddProjectBrowse, - ]); - const openAddProjectFlow = useCallback(() => { if (addProjectEnvironmentOptions.length > 1) { pushPaletteView({ @@ -855,15 +963,15 @@ function OpenCommandPaletteDialog() { return; } - startAddProjectBrowse(environmentId); + void startAddProjectSourceSelection(environmentId); }, [ addProjectEnvironmentGroups, addProjectEnvironmentOptions.length, defaultAddProjectEnvironmentId, - startAddProjectBrowse, + startAddProjectSourceSelection, ]); - useEffect(() => { + useLayoutEffect(() => { if (openIntent?.kind !== "add-project") { return; } @@ -914,7 +1022,7 @@ function OpenCommandPaletteDialog() { } actionItems.push({ - kind: "submenu", + kind: "action", value: "action:add-project", searchTerms: [ "add project", @@ -928,13 +1036,18 @@ function OpenCommandPaletteDialog() { "git", "github", "gitlab", + "bitbucket", + "azure", + "devops", "url", "environment", ], title: "Add project", icon: , - addonIcon: , - groups: addProjectSourceGroups, + keepOpen: true, + run: async () => { + openAddProjectFlow(); + }, }); actionItems.push({ @@ -1355,6 +1468,10 @@ function OpenCommandPaletteDialog() { } function executeItem(item: CommandPaletteActionItem | CommandPaletteSubmenuItem): void { + if (item.disabled) { + return; + } + if (item.kind === "submenu") { pushView(item); return; @@ -1471,7 +1588,7 @@ function OpenCommandPaletteDialog() { variant="outline" size="xs" tabIndex={-1} - className="absolute end-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2" + className="absolute inset-e-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2" aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`} disabled={!canSubmitRemoteProjectFlow} onMouseDown={(event) => { @@ -1493,7 +1610,7 @@ function OpenCommandPaletteDialog() { size="xs" tabIndex={-1} className={cn( - "absolute end-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2", + "absolute inset-e-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2", hasHighlightedBrowseItem ? "gap-1" : "gap-1.5", )} aria-label={`${submitActionLabel} (${addShortcutLabel})`} diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index 8cdf0694a082f4330c85f4e9533dde0e1e6ae852..4ad08db08243b679d41151ee91f0a2ce66f8cda7 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -43,15 +43,19 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { {group.label} - {(item) => ( - - )} + {(item) => + item.disabled ? ( + + ) : ( + + ) + } ))} @@ -59,6 +63,33 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { ); } +function DisabledCommandPaletteResultRow(props: { + item: CommandPaletteActionItem | CommandPaletteSubmenuItem; +}) { + return ( +
+ {props.item.icon} + {props.item.description ? ( + + + {props.item.titleLeadingContent} + {props.item.title} + + + {props.item.description} + + + ) : ( + + {props.item.titleLeadingContent} + {props.item.title} + + )} + {props.item.titleTrailingContent} +
+ ); +} + function CommandPaletteResultRow(props: { item: CommandPaletteActionItem | CommandPaletteSubmenuItem; isActive: boolean; @@ -89,7 +120,6 @@ function CommandPaletteResultRow(props: { {props.item.titleLeadingContent} {props.item.title} - {props.item.titleTrailingContent} {props.item.description} @@ -99,9 +129,9 @@ function CommandPaletteResultRow(props: { {props.item.titleLeadingContent} {props.item.title} - {props.item.titleTrailingContent} )} + {props.item.titleTrailingContent} {props.item.timestamp ? ( {props.item.timestamp} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index f2977d4c666724a7a842f4fe71e1097227ed248f..01b84bd94a5ee08a487a4eac28a36c77b77cf92b 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -4,14 +4,17 @@ import type { GitRunStackedActionResult, GitStackedAction, SourceControlCloneProtocol, + SourceControlProviderDiscoveryItem, SourceControlProviderKind, SourceControlPublishRepositoryResult, SourceControlRepositoryVisibility, VcsStatusResult, } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; import { Option } from "effect"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { flushSync } from "react-dom"; import { CheckIcon, ChevronDownIcon, @@ -23,7 +26,7 @@ import { GlobeIcon, } from "lucide-react"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; -import { GitHubIcon, GitLabIcon } from "~/components/Icons"; +import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "~/components/Icons"; import { RadioGroup } from "~/components/ui/radio-group"; import { Spinner } from "~/components/ui/spinner"; import { cn } from "~/lib/utils"; @@ -40,6 +43,7 @@ import { resolveQuickAction, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; +import { AnimatedHeight } from "./AnimatedHeight"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; import { @@ -56,16 +60,9 @@ import { Input } from "~/components/ui/input"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { ScrollArea } from "~/components/ui/scroll-area"; -import { - Select, - SelectGroup, - SelectItem, - SelectPopup, - SelectTrigger, - SelectValue, -} from "~/components/ui/select"; import { Textarea } from "~/components/ui/textarea"; import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { openInPreferredEditor } from "~/editorPreferences"; import { gitInitMutationOptions, @@ -100,7 +97,10 @@ interface PendingDefaultBranchAction { filePaths?: string[]; } -type PublishProviderKind = Extract; +type PublishProviderKind = Extract< + SourceControlProviderKind, + "github" | "gitlab" | "bitbucket" | "azure-devops" +>; type GitActionToastId = ReturnType; @@ -129,6 +129,88 @@ interface RunGitActionWithToastInput { const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; +const PUBLISH_PROVIDER_OPTIONS = [ + { + value: "github", + label: "GitHub", + description: "github.com", + host: "github.com", + pathPlaceholder: "owner/repo", + Icon: GitHubIcon, + }, + { + value: "gitlab", + label: "GitLab", + description: "gitlab.com", + host: "gitlab.com", + pathPlaceholder: "group/project", + Icon: GitLabIcon, + }, + { + value: "bitbucket", + label: "Bitbucket", + description: "bitbucket.org", + host: "bitbucket.org", + pathPlaceholder: "workspace/repository", + Icon: BitbucketIcon, + }, + { + value: "azure-devops", + label: "Azure DevOps", + description: "dev.azure.com", + host: "dev.azure.com", + pathPlaceholder: "project/repository", + Icon: AzureDevOpsIcon, + }, +] as const satisfies ReadonlyArray<{ + readonly value: PublishProviderKind; + readonly label: string; + readonly description: string; + readonly host: string; + readonly pathPlaceholder: string; + readonly Icon: typeof GitHubIcon; +}>; + +function publishProviderOption(provider: PublishProviderKind) { + return ( + PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ?? + PUBLISH_PROVIDER_OPTIONS[0] + ); +} + +function isPublishProviderKind( + provider: SourceControlProviderKind, +): provider is PublishProviderKind { + return PUBLISH_PROVIDER_OPTIONS.some((option) => option.value === provider); +} + +function getPublishProviderReadiness(input: { + provider: PublishProviderKind; + sourceControlProviders: ReadonlyArray; +}): { readonly ready: boolean; readonly hint: string | null } { + const discovered = input.sourceControlProviders.find( + (provider) => provider.kind === input.provider, + ); + if (!discovered) { + return { + ready: false, + hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", + }; + } + if (discovered.status !== "available") { + return { ready: false, hint: discovered.installHint }; + } + if (discovered.auth.status === "unauthenticated") { + return { + ready: false, + hint: + Option.getOrNull(discovered.auth.detail) ?? + `${discovered.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, + }; + } + return { ready: true, hint: null }; +} + function formatElapsedDescription(startedAtMs: number | null): string | undefined { if (startedAtMs === null) { return undefined; @@ -256,6 +338,613 @@ function GitQuickActionIcon({ return ; } +interface PublishRepositoryDialogProps { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly environmentId: ScopedThreadRef["environmentId"] | null; + readonly gitCwd: string; +} + +function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const sourceControlDiscovery = useSourceControlDiscovery(); + const [publishProvider, setPublishProvider] = useState("github"); + const [publishRepository, setPublishRepository] = useState(""); + const [publishVisibility, setPublishVisibility] = + useState("private"); + const [publishRemoteName, setPublishRemoteName] = useState("origin"); + const [publishProtocol, setPublishProtocol] = useState("ssh"); + const [publishWizardStep, setPublishWizardStep] = useState(0); + const [publishAdvancedOpen, setPublishAdvancedOpen] = useState(false); + const [publishError, setPublishError] = useState(null); + const [publishResult, setPublishResult] = useState( + null, + ); + const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false); + const publishRepositoryMutation = useMutation( + sourceControlPublishRepositoryMutationOptions({ + environmentId: props.environmentId, + cwd: props.gitCwd, + queryClient, + }), + ); + const publishAccountByProvider = useMemo(() => { + const accounts: Record = { + github: null, + gitlab: null, + bitbucket: null, + "azure-devops": null, + }; + for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { + if (isPublishProviderKind(provider.kind)) { + accounts[provider.kind] = Option.getOrNull(provider.auth.account); + } + } + return accounts; + }, [sourceControlDiscovery.data]); + const publishProviderReadiness = useMemo(() => { + const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? []; + return Object.fromEntries( + PUBLISH_PROVIDER_OPTIONS.map((option) => [ + option.value, + getPublishProviderReadiness({ + provider: option.value, + sourceControlProviders, + }), + ]), + ) as Record; + }, [sourceControlDiscovery.data]); + const hasReadyPublishProvider = useMemo( + () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness[option.value].ready), + [publishProviderReadiness], + ); + const sortedPublishProviderOptions = useMemo( + () => + PUBLISH_PROVIDER_OPTIONS.toSorted((left, right) => { + const leftReady = publishProviderReadiness[left.value].ready; + const rightReady = publishProviderReadiness[right.value].ready; + if (leftReady !== rightReady) { + return leftReady ? -1 : 1; + } + return left.label.localeCompare(right.label); + }), + [publishProviderReadiness], + ); + const selectedPublishProviderReadiness = publishProviderReadiness[publishProvider]; + const publishRepositoryPrefill = publishAccountByProvider[publishProvider] + ? `${publishAccountByProvider[publishProvider]}/` + : ""; + const currentPublishProvider = publishProviderOption(publishProvider); + const publishHost = currentPublishProvider.host; + const publishPathPlaceholder = currentPublishProvider.pathPlaceholder; + const publishProviderLabel = currentPublishProvider.label; + const publishWizardSteps = ["Provider", "Repository", "Summary"] as const; + const publishWizardStepSummaries = [ + publishProviderLabel, + publishResult?.repository.nameWithOwner ?? null, + null, + ] as const; + + useEffect(() => { + if (!props.open || hasUserEditedPublishRepository) { + return; + } + setPublishRepository(publishRepositoryPrefill); + }, [hasUserEditedPublishRepository, props.open, publishRepositoryPrefill]); + + const canSubmitPublishRepository = useMemo(() => { + if (!selectedPublishProviderReadiness.ready) return false; + if (publishRepositoryMutation.isPending) return false; + const repositoryParts = publishRepository.trim().split("/"); + const owner = repositoryParts[0]?.trim() ?? ""; + const rest = repositoryParts.slice(1); + const name = rest.join("/").trim(); + return owner.length > 0 && name.length > 0; + }, [publishRepository, publishRepositoryMutation.isPending, selectedPublishProviderReadiness]); + + useEffect(() => { + if (!props.open) { + return; + } + if (publishProviderReadiness[publishProvider].ready) { + return; + } + const firstReadyProvider = PUBLISH_PROVIDER_OPTIONS.find( + (option) => publishProviderReadiness[option.value].ready, + ); + if (firstReadyProvider) { + setPublishProvider(firstReadyProvider.value); + } + }, [props.open, publishProvider, publishProviderReadiness]); + + const submitPublishRepository = useCallback(() => { + if (!canSubmitPublishRepository) { + return; + } + + setPublishError(null); + + void publishRepositoryMutation + .mutateAsync({ + provider: publishProvider, + repository: publishRepository.trim(), + visibility: publishVisibility, + remoteName: publishRemoteName.trim() || "origin", + protocol: publishProtocol, + }) + .then((result) => { + flushSync(() => { + setPublishResult(result); + setPublishWizardStep(2); + }); + void refreshGitStatus({ environmentId: props.environmentId, cwd: props.gitCwd }).catch( + () => undefined, + ); + }) + .catch((err: unknown) => { + setPublishError(err instanceof Error ? err.message : "An error occurred."); + }); + }, [ + canSubmitPublishRepository, + props.environmentId, + props.gitCwd, + publishProtocol, + publishProvider, + publishRemoteName, + publishRepository, + publishRepositoryMutation, + publishVisibility, + ]); + + const resetState = useCallback(() => { + setPublishRemoteName("origin"); + setPublishRepository(""); + setHasUserEditedPublishRepository(false); + setPublishWizardStep(0); + setPublishAdvancedOpen(false); + setPublishError(null); + setPublishResult(null); + }, []); + + const handleOpenChange = useCallback( + (open: boolean) => { + props.onOpenChange(open); + if (!open) { + resetState(); + } + }, + [props, resetState], + ); + + const openSourceControlSettings = useCallback(() => { + handleOpenChange(false); + void navigate({ to: "/settings/source-control" }); + }, [handleOpenChange, navigate]); + + return ( + + +
+ + Publish repository + + Pick where to host it, then point us at a repo to push to. + +
+ {publishWizardSteps.map((label, index) => { + const isComplete = index < publishWizardStep; + const isClickable = + publishWizardStep !== 2 && + index < publishWizardSteps.length - 1 && + index <= publishWizardStep; + return ( + + ); + })} +
+
+ + + +
+ + Provider + + setPublishProvider(value as PublishProviderKind)} + aria-labelledby="publish-provider-cards-label" + className="grid grid-cols-2 gap-2.5" + > + {sortedPublishProviderOptions.map((option) => { + const readiness = publishProviderReadiness[option.value]; + const isSelected = publishProvider === option.value && readiness.ready; + if (!readiness.ready) { + return ( +
+ + + {option.label} + + + { + event.preventDefault(); + event.stopPropagation(); + openSourceControlSettings(); + }} + > + Setup Required + + } + /> + + {readiness.hint ?? + "Open Settings -> Source Control to configure this provider."} + + +
+ ); + } + + return ( + + + + {option.label} + + + ); + })} +
+
+ +
+
+ +
+ + + {publishHost}/ + + { + setPublishRepository(event.target.value); + setHasUserEditedPublishRepository(true); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + submitPublishRepository(); + } + }} + placeholder={publishPathPlaceholder} + disabled={publishRepositoryMutation.isPending} + className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" + /> +
+
+ +
+ + Visibility + + + setPublishVisibility(value as SourceControlRepositoryVisibility) + } + aria-labelledby="publish-visibility-cards-label" + disabled={publishRepositoryMutation.isPending} + className="grid grid-cols-2 gap-2.5" + > + {[ + { + value: "private" as const, + label: "Private", + description: "Only invited people", + Icon: LockIcon, + }, + { + value: "public" as const, + label: "Public", + description: "Anyone on the web", + Icon: GlobeIcon, + }, + ].map((option) => { + const isSelected = publishVisibility === option.value; + return ( + + + + + {option.label} + + + {option.description} + + + + ); + })} + +
+ +
+ + {publishAdvancedOpen ? ( +
+ +
+ + Protocol + + + setPublishProtocol(value as SourceControlCloneProtocol) + } + aria-labelledby="publish-protocol-label" + disabled={publishRepositoryMutation.isPending} + className="grid grid-cols-2 gap-2" + > + {(["ssh", "https"] as const).map((value) => { + const isSelected = publishProtocol === value; + return ( + + {value === "ssh" ? "SSH" : "HTTPS"} + + ); + })} + +
+
+ ) : null} +
+ + {publishRepositoryMutation.isPending ? ( +
+ + Publishing repository to {publishProviderLabel}... +
+ ) : null} + {publishError && !publishRepositoryMutation.isPending ? ( +
+

Publish failed

+

{publishError}

+
+ ) : null} +
+ +
+ {publishResult ? ( + <> +
+ + + +

+ {publishResult.status === "pushed" + ? "Repository published" + : "Repository created"} +

+

+ {publishResult.status === "pushed" + ? `${publishResult.branch} is now live on ${publishProviderLabel}.` + : `Remote "${publishResult.remoteName}" is set up. Make a commit and push it to share your code.`} +

+
+
+ + + {publishResult.repository.nameWithOwner} + +
+ + + ) : ( +
+ Publish result unavailable. +
+ )} +
+
+
+ + + {publishWizardStep === 2 ? ( + + ) : ( + <> + + {publishWizardStep < 1 ? ( + + ) : ( + + )} + + )} + +
+
+
+ ); +} + export default function GitActionsControl({ gitCwd, activeThreadRef, @@ -286,34 +975,8 @@ export default function GitActionsControl({ const [excludedFiles, setExcludedFiles] = useState>(new Set()); const [isEditingFiles, setIsEditingFiles] = useState(false); const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false); - const [publishProvider, setPublishProvider] = useState("github"); - const [publishRepository, setPublishRepository] = useState(""); - const [publishVisibility, setPublishVisibility] = - useState("private"); - const [publishRemoteName, setPublishRemoteName] = useState("origin"); - const [publishProtocol, setPublishProtocol] = useState("ssh"); - const [publishWizardStep, setPublishWizardStep] = useState(0); - const [publishAdvancedOpen, setPublishAdvancedOpen] = useState(false); - const [publishError, setPublishError] = useState(null); - const [publishResult, setPublishResult] = useState( - null, - ); - const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState(null); - const sourceControlDiscovery = useSourceControlDiscovery(); - const publishAccountByProvider = useMemo(() => { - const accounts: Record = { github: null, gitlab: null }; - for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { - if (provider.kind === "github" || provider.kind === "gitlab") { - accounts[provider.kind] = Option.getOrNull(provider.auth.account); - } - } - return accounts; - }, [sourceControlDiscovery.data]); - const publishRepositoryPrefill = publishAccountByProvider[publishProvider] - ? `${publishAccountByProvider[publishProvider]}/` - : ""; const activeGitActionProgressRef = useRef(null); let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise; @@ -425,13 +1088,6 @@ export default function GitActionsControl({ const pullMutation = useMutation( gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), ); - const publishRepositoryMutation = useMutation( - sourceControlPublishRepositoryMutationOptions({ - environmentId: activeEnvironmentId, - cwd: gitCwd, - queryClient, - }), - ); const isRunStackedActionRunning = useIsMutating({ @@ -510,13 +1166,6 @@ export default function GitActionsControl({ }; }, [updateActiveProgressToast]); - useEffect(() => { - if (!isPublishDialogOpen || hasUserEditedPublishRepository) { - return; - } - setPublishRepository(publishRepositoryPrefill); - }, [isPublishDialogOpen, hasUserEditedPublishRepository, publishRepositoryPrefill]); - useEffect(() => { if (gitCwd === null) { return; @@ -965,45 +1614,6 @@ export default function GitActionsControl({ ); const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; - const canSubmitPublishRepository = (() => { - if (publishRepositoryMutation.isPending) return false; - const repositoryParts = publishRepository.trim().split("/"); - const owner = repositoryParts[0]?.trim() ?? ""; - const rest = repositoryParts.slice(1); - const name = rest.join("/").trim(); - return owner.length > 0 && name.length > 0; - })(); - - const publishHost = publishProvider === "github" ? "github.com" : "gitlab.com"; - const publishPathPlaceholder = publishProvider === "github" ? "owner/repo" : "group/project"; - const publishProviderLabel = publishProvider === "github" ? "GitHub" : "GitLab"; - - const submitPublishRepository = () => { - if (!canSubmitPublishRepository) { - return; - } - - setPublishError(null); - - void publishRepositoryMutation - .mutateAsync({ - provider: publishProvider, - repository: publishRepository.trim(), - visibility: publishVisibility, - remoteName: publishRemoteName.trim() || "origin", - protocol: publishProtocol, - }) - .then((result) => { - setPublishResult(result); - setPublishWizardStep(2); - void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( - () => undefined, - ); - }) - .catch((err: unknown) => { - setPublishError(err instanceof Error ? err.message : "An error occurred."); - }); - }; if (!gitCwd) return null; @@ -1322,419 +1932,12 @@ export default function GitActionsControl({ - { - setIsPublishDialogOpen(open); - if (!open) { - setPublishRemoteName("origin"); - setPublishRepository(""); - setHasUserEditedPublishRepository(false); - setPublishWizardStep(0); - setPublishAdvancedOpen(false); - setPublishError(null); - setPublishResult(null); - } - }} - > - - - Publish repository - - Pick where to host it, then point us at a repo to push to. - -
- {(["Provider", "Repository", "Summary"] as const).map((label, index) => { - const summary = - index === 0 && publishWizardStep > 0 - ? publishProviderLabel - : index === 1 && publishWizardStep > 1 && publishResult - ? publishResult.repository.nameWithOwner - : null; - const isComplete = index < publishWizardStep; - const isDoneStep = index === 2; - // The summary step is only reachable via a successful submit; - // disallow user-driven navigation to or from it. - const isClickable = - publishWizardStep !== 2 && !isDoneStep && index <= publishWizardStep; - return ( - - ); - })} -
-
- - {/* Step 1: Provider */} - {publishWizardStep === 0 && ( - -
- - Provider - - setPublishProvider(value as PublishProviderKind)} - aria-labelledby="publish-provider-cards-label" - className="grid grid-cols-2 gap-2.5" - > - {[ - { - value: "github" as const, - label: "GitHub", - description: "github.com", - Icon: GitHubIcon, - }, - { - value: "gitlab" as const, - label: "GitLab", - description: "gitlab.com", - Icon: GitLabIcon, - }, - ].map((option) => { - const isSelected = publishProvider === option.value; - return ( - - - - - {option.label} - - - {option.description} - - - - ); - })} - -
-
- )} - {publishWizardStep === 1 && ( - -
- -
- - {publishProvider === "github" ? ( - - ) : ( - - )} - {publishHost}/ - - { - setPublishRepository(event.target.value); - setHasUserEditedPublishRepository(true); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - submitPublishRepository(); - } - }} - placeholder={publishPathPlaceholder} - disabled={publishRepositoryMutation.isPending} - className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" - /> -
-
-
- - Visibility - - - setPublishVisibility(value as SourceControlRepositoryVisibility) - } - aria-labelledby="publish-visibility-cards-label" - disabled={publishRepositoryMutation.isPending} - className="grid grid-cols-2 gap-2.5" - > - {[ - { - value: "private" as const, - label: "Private", - description: "Only invited people", - Icon: LockIcon, - }, - { - value: "public" as const, - label: "Public", - description: "Anyone on the web", - Icon: GlobeIcon, - }, - ].map((option) => { - const isSelected = publishVisibility === option.value; - return ( - - - - - {option.label} - - - {option.description} - - - - ); - })} - -
-
- - {publishAdvancedOpen && ( -
- -
- - Protocol - - - setPublishProtocol(value as SourceControlCloneProtocol) - } - aria-labelledby="publish-protocol-label" - disabled={publishRepositoryMutation.isPending} - className="grid grid-cols-2 gap-2" - > - {(["ssh", "https"] as const).map((value) => { - const isSelected = publishProtocol === value; - return ( - - {value === "ssh" ? "SSH" : "HTTPS"} - - ); - })} - -
-
- )} -
- {publishRepositoryMutation.isPending && ( -
- - Publishing repository to {publishProviderLabel}... -
- )} - {publishError && !publishRepositoryMutation.isPending && ( -
-

Publish failed

-

{publishError}

-
- )} -
- )} - {publishWizardStep === 2 && publishResult && ( - -
- - - -

- {publishResult.status === "pushed" - ? "Repository published" - : "Repository created"} -

-

- {publishResult.status === "pushed" - ? `${publishResult.branch} is now live on ${publishProviderLabel}.` - : `Remote "${publishResult.remoteName}" is set up. Make a commit and push it to share your code.`} -

-
-
- {publishProvider === "github" ? ( - - ) : ( - - )} - - {publishResult.repository.nameWithOwner} - -
- -
- )} - - - {publishWizardStep === 2 ? ( - - ) : ( - <> - - {publishWizardStep < 1 ? ( - - ) : ( - - )} - - )} - -
-
+ onOpenChange={setIsPublishDialogOpen} + environmentId={activeEnvironmentId} + gitCwd={gitCwd} + /> -
- - Driver - - setDriver(ProviderDriverKind.make(value))} - aria-labelledby="add-instance-driver-label" - className="grid grid-cols-2 gap-2.5" - > - {DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - const isSelected = option.value === driver; - return ( - - - - {option.label} - - {option.badgeLabel ? ( + +
+ + Driver + + setDriver(ProviderDriverKind.make(value))} + aria-labelledby="add-instance-driver-label" + className="grid grid-cols-2 gap-2.5" + > + {DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + const isSelected = option.value === driver; + return ( + + + + {option.label} + + {option.badgeLabel ? ( + + {option.badgeLabel} + + ) : null} + + ); + })} + {COMING_SOON_DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + return ( + + + + {option.label} + - {option.badgeLabel} + Coming Soon - ) : null} - - ); - })} - {COMING_SOON_DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - return ( - - - - {option.label} - - - Coming Soon - - - ); - })} - -
- - - - - -
- Accent color -
- setAccentColor(event.target.value)} - aria-label="Provider instance accent color" - className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5" - /> -
- {PROVIDER_ACCENT_SWATCHES.map((swatch) => { - const selected = accentColor.toLowerCase() === swatch; - return ( -
- {accentColor ? ( - - ) : null} +
- - Optional marker shown in the picker. - -
- {driverSettingsFields.length > 0 ? ( -
- + Label + setLabel(event.target.value)} /> + + Shown in the provider list. Optional. + + + + + +
+ Accent color +
+ setAccentColor(event.target.value)} + aria-label="Provider instance accent color" + className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5" + /> +
+ {PROVIDER_ACCENT_SWATCHES.map((swatch) => { + const selected = accentColor.toLowerCase() === swatch; + return ( +
+ {accentColor ? ( + + ) : null} +
+ + Optional marker shown in the picker. +
- ) : wizardStep === 2 ? ( -
-

- This driver has no required configuration. You can add the instance now. -

-
- ) : null} + + {driverSettingsFields.length > 0 ? ( +
+ +
+ ) : wizardStep === 2 ? ( +
+

+ This driver has no required configuration. You can add the instance now. +

+
+ ) : null} +
diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index e2e3e765da47f913cc4a227ac6b3e8dc1a172e6f..98c02d92f648646b9da0856c03a533d1475d63b1 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -7,16 +7,7 @@ import { TerminalIcon, TriangleAlertIcon, } from "lucide-react"; -import { - type ReactNode, - memo, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; +import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react"; import { type AuthClientSession, type AuthPairingLink, @@ -68,6 +59,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; +import { AnimatedHeight } from "../AnimatedHeight"; import { Menu, MenuGroup, @@ -176,62 +168,6 @@ function ConnectionStatusDot({ ); } -function AnimatedHeight({ children }: { readonly children: ReactNode }) { - const contentRef = useRef(null); - const [height, setHeight] = useState(null); - - useLayoutEffect(() => { - const element = contentRef.current; - if (!element) return; - let firstFrameId: number | null = null; - let secondFrameId: number | null = null; - - const updateHeight = () => { - const nextHeight = Math.ceil(element.scrollHeight || element.getBoundingClientRect().height); - setHeight((currentHeight) => (currentHeight === nextHeight ? currentHeight : nextHeight)); - }; - const cancelPendingFrames = () => { - if (firstFrameId !== null) { - window.cancelAnimationFrame(firstFrameId); - firstFrameId = null; - } - if (secondFrameId !== null) { - window.cancelAnimationFrame(secondFrameId); - secondFrameId = null; - } - }; - const updateHeightAfterPaint = () => { - cancelPendingFrames(); - updateHeight(); - firstFrameId = window.requestAnimationFrame(() => { - firstFrameId = null; - updateHeight(); - secondFrameId = window.requestAnimationFrame(() => { - secondFrameId = null; - updateHeight(); - }); - }); - }; - - updateHeightAfterPaint(); - const resizeObserver = new ResizeObserver(updateHeightAfterPaint); - resizeObserver.observe(element); - return () => { - resizeObserver.disconnect(); - cancelPendingFrames(); - }; - }, []); - - return ( -
-
{children}
-
- ); -} - function getSavedBackendStatusTooltip( runtime: SavedEnvironmentRuntimeState | null, record: SavedEnvironmentRecord, diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 4630f6e7c4cf949e27b8b05eeb176b8793ac3131..85c16b70155f36dbb891ee06eb26255547c27543 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -1081,9 +1081,9 @@ describe("SourceControlSettingsPanel discovery states", () => { await expect.element(page.getByText("Version Control")).toBeInTheDocument(); await expect.element(page.getByText("Source Control Providers")).toBeInTheDocument(); await expect - .element(page.getByRole("button", { name: "Scan source control tools" })) + .element(page.getByRole("button", { name: "Rescan server environment" })) .toBeDisabled(); - await expect.element(page.getByText("No source control tools found")).not.toBeInTheDocument(); + await expect.element(page.getByText("Nothing detected yet")).not.toBeInTheDocument(); }); it("uses the shared empty state when discovery completes without tools", async () => { @@ -1098,9 +1098,13 @@ describe("SourceControlSettingsPanel discovery states", () => { , ); - await expect.element(page.getByText("No source control tools found")).toBeInTheDocument(); + await expect.element(page.getByText("Nothing detected yet")).toBeInTheDocument(); await expect - .element(page.getByText("Install a supported Git or pull request CLI, then scan again.")) + .element( + page.getByText( + "Install Git on the server, add optional hosting integrations or credentials your workspace needs, then rescan.", + ), + ) .toBeInTheDocument(); await expect.element(page.getByRole("button", { name: "Scan" })).toBeInTheDocument(); }); @@ -1129,7 +1133,7 @@ describe("SourceControlSettingsPanel discovery states", () => { ); await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument(); - await expect.element(page.getByText("No source control tools found")).not.toBeInTheDocument(); + await expect.element(page.getByText("Nothing detected yet")).not.toBeInTheDocument(); }); it("does not rescan on remount while the discovery atom is fresh", async () => { diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 947d0710621ecfafe3aada33badac2a9617a62fc..c58a4468363b479e1b434504b503ce5c3a3b376b 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -69,17 +69,21 @@ function isProviderDiscoveryItem( return "auth" in item; } +function isVcsNotReady(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): boolean { + return !isProviderDiscoveryItem(item) && !item.implemented; +} + function authPresentation(auth: SourceControlProviderAuth): { readonly label: string; readonly badge: "warning" | null; } { if (auth.status === "authenticated") { - return { label: "Signed in", badge: null }; + return { label: "Authenticated", badge: null }; } if (auth.status === "unauthenticated") { - return { label: "Sign in", badge: "warning" }; + return { label: "Not authenticated", badge: "warning" }; } - return { label: "Sign in", badge: null }; + return { label: "Status unknown", badge: null }; } function RedactedAccount(props: { readonly account: string | null }) { @@ -94,7 +98,7 @@ function RedactedAccount(props: { readonly account: string | null }) { } function itemStatusDot(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): string { - if (!item.implemented) return "bg-muted-foreground/35"; + if (isVcsNotReady(item)) return "bg-muted-foreground/35"; if (item.status !== "available") return "bg-warning"; if (isProviderDiscoveryItem(item) && item.auth.status !== "authenticated") return "bg-warning"; return "bg-success"; @@ -137,12 +141,12 @@ function itemSummary({ readonly auth: SourceControlProviderAuth | null; readonly authAccount: string | null; }) { - if (!item.implemented) { + if (isVcsNotReady(item)) { return Support for {item.label} is coming soon.; } if (item.status !== "available") { - return Not found - {item.installHint}; + return Not available on this server — {item.installHint}; } if (auth) { @@ -165,11 +169,17 @@ function itemSummary({ } if (auth.status === "unauthenticated") { - return Sign in with the {item.executable} CLI to enable pull request actions.; + return ( + + {item.label} is not authenticated on this server. Sign in or configure credentials using + the {item.executable}{" "} + tool on the server host to enable pull request features. + + ); } return ( - Install and sign in with the {item.executable} CLI to enable pull request actions. + Could not verify {item.label}. {item.installHint} ); } @@ -183,7 +193,8 @@ function DiscoveryItemRow({ readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; }) { const version = optionLabel(item.version); - const enabled = item.implemented && item.status === "available"; + const enabled = + item.status === "available" && (isProviderDiscoveryItem(item) || item.implemented); const auth = isProviderDiscoveryItem(item) ? item.auth : null; const authStatus = auth ? authPresentation(auth) : null; const authAccount = auth ? optionLabel(auth.account) : null; @@ -192,7 +203,7 @@ function DiscoveryItemRow({
@@ -204,7 +215,7 @@ function DiscoveryItemRow({ {item.label} {version ? {version} : null} - {!item.implemented ? ( + {isVcsNotReady(item) ? ( Coming Soon @@ -220,7 +231,7 @@ function DiscoveryItemRow({

- {item.implemented ? ( + {!isVcsNotReady(item) ? ( ) : null}
@@ -279,17 +290,19 @@ function EmptySourceControlDiscovery({ const hasError = error !== null; return ( - + - {hasError ? "Could not scan source control" : "No source control tools found"} + {hasError ? "Could not scan the server environment" : "Nothing detected yet"} - {hasError ? error : "Install a supported Git or pull request CLI, then scan again."} + {hasError + ? error + : "Install Git on the server, add optional hosting integrations or credentials your workspace needs, then rescan."} @@ -329,13 +342,13 @@ export function SourceControlSettingsPanel() { className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" onClick={handleScan} disabled={discovery.isPending} - aria-label="Scan source control tools" + aria-label="Rescan server environment" > } /> - Scan source control tools + Rescan Git and hosting integrations ); diff --git a/apps/web/src/lib/sourceControlDiscoveryState.ts b/apps/web/src/lib/sourceControlDiscoveryState.ts index fad4f534908aee69c69c21fb0e174a3b01803def..911517fb4e5201c3508de7156ef748e84dab9001 100644 --- a/apps/web/src/lib/sourceControlDiscoveryState.ts +++ b/apps/web/src/lib/sourceControlDiscoveryState.ts @@ -1,16 +1,17 @@ import { useAtomValue } from "@effect/atom-react"; import { - EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, - EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, + type SourceControlDiscoveryTarget, type SourceControlDiscoveryState, createSourceControlDiscoveryManager, getSourceControlDiscoveryTargetKey, sourceControlDiscoveryStateAtom, } from "@t3tools/client-runtime"; -import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; +import { EnvironmentId, type SourceControlDiscoveryResult } from "@t3tools/contracts"; import { Effect } from "effect"; import { Atom } from "effect/unstable/reactivity"; +import { readPrimaryEnvironmentDescriptor } from "../environments/primary"; +import { readEnvironmentConnection } from "../environments/runtime"; import { readLocalApi } from "../localApi"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -18,39 +19,85 @@ const SOURCE_CONTROL_DISCOVERY_TARGET = { key: "primary" } as const; const SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS = 30_000; const SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS = 5 * 60_000; +interface SourceControlDiscoveryTargetInput { + readonly environmentId?: EnvironmentId | null; +} + +function sourceControlDiscoveryTarget( + input?: SourceControlDiscoveryTargetInput, +): SourceControlDiscoveryTarget { + const environmentId = input?.environmentId ?? null; + if (!environmentId) { + return SOURCE_CONTROL_DISCOVERY_TARGET; + } + return readPrimaryEnvironmentDescriptor()?.environmentId === environmentId + ? SOURCE_CONTROL_DISCOVERY_TARGET + : { key: environmentId }; +} + export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ getRegistry: () => appAtomRegistry, - getClient: () => readLocalApi()?.server ?? null, + getClient: (key) => { + if (key === SOURCE_CONTROL_DISCOVERY_TARGET.key) { + const primaryEnvironmentId = readPrimaryEnvironmentDescriptor()?.environmentId ?? null; + const primaryConnection = primaryEnvironmentId + ? readEnvironmentConnection(primaryEnvironmentId) + : null; + if (primaryConnection) { + return primaryConnection.client.server; + } + try { + return readLocalApi()?.server ?? null; + } catch { + return null; + } + } + const environmentId = EnvironmentId.make(key); + const connection = readEnvironmentConnection(environmentId); + if (connection) { + return connection.client.server; + } + return null; + }, }); -const sourceControlDiscoveryAutoRefreshAtom = Atom.make(() => - Effect.promise(() => refreshSourceControlDiscovery()), -).pipe( - Atom.swr({ - staleTime: SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS, - revalidateOnMount: true, - }), - Atom.setIdleTTL(SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS), - Atom.withLabel("source-control-discovery:auto-refresh"), +const sourceControlDiscoveryAutoRefreshAtom = Atom.family((targetKey: string) => + Atom.make(() => + Effect.promise(() => sourceControlDiscoveryManager.refresh({ key: targetKey })), + ).pipe( + Atom.swr({ + staleTime: SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS, + revalidateOnMount: true, + }), + Atom.setIdleTTL(SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS), + Atom.withLabel(`source-control-discovery:auto-refresh:${targetKey}`), + ), ); -export function refreshSourceControlDiscovery(): Promise { - return sourceControlDiscoveryManager.refresh(SOURCE_CONTROL_DISCOVERY_TARGET); +export function refreshSourceControlDiscovery( + input?: SourceControlDiscoveryTargetInput, +): Promise { + return sourceControlDiscoveryManager.refresh(sourceControlDiscoveryTarget(input)); +} + +export function getSourceControlDiscoverySnapshot( + input?: SourceControlDiscoveryTargetInput, +): SourceControlDiscoveryState { + return sourceControlDiscoveryManager.getSnapshot(sourceControlDiscoveryTarget(input)); } export function resetSourceControlDiscoveryStateForTests(): void { sourceControlDiscoveryManager.reset(); } -export function useSourceControlDiscovery(): SourceControlDiscoveryState { - const targetKey = getSourceControlDiscoveryTargetKey(SOURCE_CONTROL_DISCOVERY_TARGET); +export function useSourceControlDiscovery( + input?: SourceControlDiscoveryTargetInput, +): SourceControlDiscoveryState { + const targetKey = + getSourceControlDiscoveryTargetKey(sourceControlDiscoveryTarget(input)) ?? + SOURCE_CONTROL_DISCOVERY_TARGET.key; - useAtomValue(sourceControlDiscoveryAutoRefreshAtom); + useAtomValue(sourceControlDiscoveryAutoRefreshAtom(targetKey)); - const state = useAtomValue( - targetKey !== null - ? sourceControlDiscoveryStateAtom(targetKey) - : EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, - ); - return targetKey === null ? EMPTY_SOURCE_CONTROL_DISCOVERY_STATE : state; + return useAtomValue(sourceControlDiscoveryStateAtom(targetKey)); } diff --git a/apps/web/src/pullRequestReference.test.ts b/apps/web/src/pullRequestReference.test.ts index 2067c41cc1a525b1ee9a9df8dbdd582a536bfea7..9157061a304bff137f6062d723694eb2a8608fb8 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 93ae7caa0f7c2e9e861378360ac58043633974f0..b919e736cc09063405f4e513b2efbf59cc504768 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 0000000000000000000000000000000000000000..c496d5516a68415317955fee99acc85978c217aa --- /dev/null +++ b/docs/source-control-providers.md @@ -0,0 +1,127 @@ +# Source Control Integrations + +T3 Code connects directly to your Git hosting provider so you can create pull requests, review code, and manage repositories without leaving your editor. Work stays in flow—no more jumping between browser tabs and terminal windows. + +## Supported Providers + +T3 Code works with the platforms your team already uses: + +- **GitHub** – Pull requests, repository creation, and clone integration +- **GitLab** – Merge requests, repository publishing, and hosted clones +- **Bitbucket** – Pull request workflows (via API token authentication) +- **Azure DevOps** – Pull request support for Microsoft-hosted repositories + +## What You Can Do + +### Start Projects from Anywhere + +**Clone repositories directly** + +- Open the Command Palette (`Cmd/Ctrl + K`) → **Add Project** +- Choose **GitHub repository**, **GitLab repository**, **Bitbucket repository**, **Azure DevOps repository**, or paste any **Git URL** +- Enter the repository path (`owner/repo`, `group/project`, `workspace/repository`, or `project/repository`) or a full Git URL, pick a destination, and start coding + +**Publish local projects to the cloud** + +- Have a local Git repository without a remote? +- Use the **Publish Repository** action to create a new hosted repository (GitHub, GitLab, Bitbucket, or Azure DevOps), add it as your origin remote, and push—all in one flow +- Perfect for turning a weekend prototype into a real project + +### Manage Code Reviews Without Context Switching + +**Create pull requests while you work** + +- Push a branch and create a pull request from the Git panel +- T3 Code can suggest titles and descriptions based on your commits +- Supports GitHub Pull Requests, GitLab Merge Requests, and Bitbucket Pull Requests + +**Stay on top of open reviews** + +- See if your current branch already has an open PR/MR +- Open the review directly in your browser with one click +- Check out a teammate's branch to review code locally + +### Know Your Setup at a Glance + +The **Source Control settings** page shows you exactly what's connected: + +- ✅ Which providers are authenticated and ready +- ⚠️ What's missing and how to fix it +- 👤 Which account is signed in (when available) + +Run a quick **Rescan** after setting up a new machine or changing credentials. + +## Getting Started + +### For GitHub (Recommended for most users) + +1. Install the GitHub CLI on the machine running T3 Code: + ```bash + brew install gh + ``` +2. Sign in: + ```bash + gh auth login + ``` +3. Open **Settings → Source Control** in T3 Code and verify GitHub shows as authenticated + +That's it—you can now clone, publish, and create pull requests. + +### For GitLab + +1. Install the GitLab CLI: + ```bash + brew install glab + ``` +2. Authenticate: + ```bash + glab auth login + ``` +3. Check **Settings → Source Control** to confirm the connection + +### For Bitbucket + +Bitbucket uses API tokens instead of a CLI tool: + +1. Create an API token in your Atlassian account with read/write access to pull requests and repositories +2. Add these environment variables to the environment running T3 Code: + ```bash + export T3CODE_BITBUCKET_EMAIL="you@example.com" + export T3CODE_BITBUCKET_API_TOKEN="your-token" + ``` +3. Restart T3 Code and verify the connection in **Source Control settings** + +### For Azure DevOps + +1. Install Azure CLI: + ```bash + brew install azure-cli + ``` +2. Add the DevOps extension: + ```bash + az extension add --name azure-devops + ``` +3. Sign in: + ```bash + az login + ``` + +--- + +## Requirements & Troubleshooting + +**Git is required** – T3 Code uses Git for all local operations. Ensure `git` is installed on your server. + +**Server-side setup** – Authentication happens on the machine running T3 Code (the server), not your local browser. If you're using a hosted or team instance, your administrator may have already configured providers. + +**Common issues:** + +- **Provider shows "Not authenticated"** – Run the login command for that provider (e.g., `gh auth login`) in a terminal on the server, then rescan in Settings +- **Bitbucket not connecting** – Double-check your environment variables are set in the correct shell profile and the server was restarted +- **Can't push to a remote** – Verify your Git remote URL matches the provider you've authenticated with (SSH vs HTTPS remotes may need different credentials) + +**Need more help?** Check your provider's CLI documentation: + +- [GitHub CLI](https://cli.github.com/) +- [GitLab CLI](https://gitlab.com/gitlab-org/cli) +- [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/) diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index acc4a733aeff003a93597ca8498654fe4e3168b9..0384b5d18da805783afaebb6b0157e14a6a1e1dd 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -121,31 +121,25 @@ export const SourceControlProviderAuth = Schema.Struct({ }); export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type; -const SourceControlDiscoveryItemFields = { +const SourceControlDiscoverySharedFields = { label: TrimmedNonEmptyString, executable: Schema.optional(TrimmedNonEmptyString), - implemented: Schema.Boolean, status: SourceControlDiscoveryStatus, version: Schema.Option(TrimmedNonEmptyString), installHint: TrimmedNonEmptyString, detail: Schema.Option(TrimmedNonEmptyString), } as const; -export const SourceControlDiscoveryItem = Schema.Struct({ - kind: Schema.String, - ...SourceControlDiscoveryItemFields, -}); -export type SourceControlDiscoveryItem = typeof SourceControlDiscoveryItem.Type; - export const VcsDiscoveryItem = Schema.Struct({ kind: VcsDriverKind, - ...SourceControlDiscoveryItemFields, + implemented: Schema.Boolean, + ...SourceControlDiscoverySharedFields, }); export type VcsDiscoveryItem = typeof VcsDiscoveryItem.Type; export const SourceControlProviderDiscoveryItem = Schema.Struct({ kind: SourceControlProviderKind, - ...SourceControlDiscoveryItemFields, + ...SourceControlDiscoverySharedFields, auth: SourceControlProviderAuth, }); export type SourceControlProviderDiscoveryItem = typeof SourceControlProviderDiscoveryItem.Type; diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 59257a3de7f8483fe2d37f68a4de235141ed094e..24ff3a0da837a906c37d678f746e40823b43d974 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", }; -- 2.54.0