| author | |
| committer | |
| log | 91a03e074751e9dc732d0dddcd7b3a291caba34f |
| tree | fd02d124bb4147f225341e3dc37632adae288603 |
| parent | d501ebe8771679b7b8173475cd0c00589d704817 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius@macmini.local>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>59 files changed, 5834 insertions(+), 2327 deletions(-)
apps/server/src/git/GitManager.test.ts+1| ... | @@ -655,6 +655,7 @@ function makeManager(input?: { | ... | @@ -655,6 +655,7 @@ function makeManager(input?: { |
| 655 | get: () => Effect.succeed(provider), | 655 | get: () => Effect.succeed(provider), |
| 656 | resolveHandle: () => Effect.succeed({ provider, context: null }), | 656 | resolveHandle: () => Effect.succeed({ provider, context: null }), |
| 657 | resolve: () => Effect.succeed(provider), | 657 | resolve: () => Effect.succeed(provider), |
| 658 | discover: Effect.succeed([]), | ||
| 658 | }), | 659 | }), |
| 659 | ), | 660 | ), |
| 660 | Effect.provide(Layer.succeed(GitHubCli, gitHubCli)), | 661 | Effect.provide(Layer.succeed(GitHubCli, gitHubCli)), |
apps/server/src/git/GitWorkflowService.test.ts+18-19| ... | @@ -1,28 +1,27 @@ | ... | @@ -1,28 +1,27 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, describe, it, vi } from "@effect/vitest"; |
| 2 | import { Effect, Layer } from "effect"; | 2 | import { Effect, Layer } from "effect"; |
| 3 | import { describe, vi } from "vitest"; | ||
| 4 | 3 | ||
| 5 | import { GitManager } from "./GitManager.ts"; | 4 | import * as GitManager from "./GitManager.ts"; |
| 6 | import { GitWorkflowService, layer as GitWorkflowServiceLayer } from "./GitWorkflowService.ts"; | 5 | import * as GitWorkflowService from "./GitWorkflowService.ts"; |
| 7 | import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; | 6 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 8 | import { VcsDriverRegistry, type VcsDriverRegistryShape } from "../vcs/VcsDriverRegistry.ts"; | 7 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 9 | 8 | ||
| 10 | function makeLayer(input: { readonly detect: VcsDriverRegistryShape["detect"] }) { | 9 | function makeLayer(input: { readonly detect: VcsDriverRegistry.VcsDriverRegistryShape["detect"] }) { |
| 11 | return GitWorkflowServiceLayer.pipe( | 10 | return GitWorkflowService.layer.pipe( |
| 12 | Layer.provide( | 11 | Layer.provide( |
| 13 | Layer.mock(VcsDriverRegistry)({ | 12 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ |
| 14 | detect: input.detect, | 13 | detect: input.detect, |
| 15 | }), | 14 | }), |
| 16 | ), | 15 | ), |
| 17 | Layer.provide(Layer.mock(GitVcsDriver)({})), | 16 | Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), |
| 18 | Layer.provide(Layer.mock(GitManager)({})), | 17 | Layer.provide(Layer.mock(GitManager.GitManager)({})), |
| 19 | ); | 18 | ); |
| 20 | } | 19 | } |
| 21 | 20 | ||
| 22 | describe("GitWorkflowService", () => { | 21 | describe("GitWorkflowService", () => { |
| 23 | it.effect("returns an empty local status when no VCS repository is detected", () => | 22 | it.effect("returns an empty local status when no VCS repository is detected", () => |
| 24 | Effect.gen(function* () { | 23 | Effect.gen(function* () { |
| 25 | const workflow = yield* GitWorkflowService; | 24 | const workflow = yield* GitWorkflowService.GitWorkflowService; |
| 26 | const status = yield* workflow.localStatus({ cwd: "/not-a-repo" }); | 25 | const status = yield* workflow.localStatus({ cwd: "/not-a-repo" }); |
| 27 | 26 | ||
| 28 | assert.deepStrictEqual(status, { | 27 | assert.deepStrictEqual(status, { |
| ... | @@ -48,7 +47,7 @@ describe("GitWorkflowService", () => { | ... | @@ -48,7 +47,7 @@ describe("GitWorkflowService", () => { |
| 48 | 47 | ||
| 49 | it.effect("returns an empty full status when no VCS repository is detected", () => | 48 | it.effect("returns an empty full status when no VCS repository is detected", () => |
| 50 | Effect.gen(function* () { | 49 | Effect.gen(function* () { |
| 51 | const workflow = yield* GitWorkflowService; | 50 | const workflow = yield* GitWorkflowService.GitWorkflowService; |
| 52 | const status = yield* workflow.status({ cwd: "/not-a-repo" }); | 51 | const status = yield* workflow.status({ cwd: "/not-a-repo" }); |
| 53 | 52 | ||
| 54 | assert.deepStrictEqual(status, { | 53 | assert.deepStrictEqual(status, { |
| ... | @@ -82,15 +81,15 @@ describe("GitWorkflowService", () => { | ... | @@ -82,15 +81,15 @@ describe("GitWorkflowService", () => { |
| 82 | const remoteStatus = vi.fn(); | 81 | const remoteStatus = vi.fn(); |
| 83 | const status = vi.fn(); | 82 | const status = vi.fn(); |
| 84 | 83 | ||
| 85 | const testLayer = GitWorkflowServiceLayer.pipe( | 84 | const testLayer = GitWorkflowService.layer.pipe( |
| 86 | Layer.provide( | 85 | Layer.provide( |
| 87 | Layer.mock(VcsDriverRegistry)({ | 86 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ |
| 88 | detect: () => Effect.succeed(null), | 87 | detect: () => Effect.succeed(null), |
| 89 | }), | 88 | }), |
| 90 | ), | 89 | ), |
| 91 | Layer.provide(Layer.mock(GitVcsDriver)({})), | 90 | Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), |
| 92 | Layer.provide( | 91 | Layer.provide( |
| 93 | Layer.mock(GitManager)({ | 92 | Layer.mock(GitManager.GitManager)({ |
| 94 | localStatus, | 93 | localStatus, |
| 95 | remoteStatus, | 94 | remoteStatus, |
| 96 | status, | 95 | status, |
| ... | @@ -99,7 +98,7 @@ describe("GitWorkflowService", () => { | ... | @@ -99,7 +98,7 @@ describe("GitWorkflowService", () => { |
| 99 | ); | 98 | ); |
| 100 | 99 | ||
| 101 | return Effect.gen(function* () { | 100 | return Effect.gen(function* () { |
| 102 | const workflow = yield* GitWorkflowService; | 101 | const workflow = yield* GitWorkflowService.GitWorkflowService; |
| 103 | yield* workflow.localStatus({ cwd: "/not-a-repo" }); | 102 | yield* workflow.localStatus({ cwd: "/not-a-repo" }); |
| 104 | yield* workflow.remoteStatus({ cwd: "/not-a-repo" }); | 103 | yield* workflow.remoteStatus({ cwd: "/not-a-repo" }); |
| 105 | yield* workflow.status({ cwd: "/not-a-repo" }); | 104 | yield* workflow.status({ cwd: "/not-a-repo" }); |
| ... | @@ -112,7 +111,7 @@ describe("GitWorkflowService", () => { | ... | @@ -112,7 +111,7 @@ describe("GitWorkflowService", () => { |
| 112 | 111 | ||
| 113 | it.effect("returns an empty ref list when no VCS repository is detected", () => | 112 | it.effect("returns an empty ref list when no VCS repository is detected", () => |
| 114 | Effect.gen(function* () { | 113 | Effect.gen(function* () { |
| 115 | const workflow = yield* GitWorkflowService; | 114 | const workflow = yield* GitWorkflowService.GitWorkflowService; |
| 116 | const refs = yield* workflow.listRefs({ cwd: "/not-a-repo" }); | 115 | const refs = yield* workflow.listRefs({ cwd: "/not-a-repo" }); |
| 117 | 116 | ||
| 118 | assert.deepStrictEqual(refs, { | 117 | assert.deepStrictEqual(refs, { |
apps/server/src/server.test.ts+18-29| ... | @@ -105,23 +105,12 @@ import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; | ... | @@ -105,23 +105,12 @@ import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; |
| 105 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; | 105 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; |
| 106 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | 106 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; |
| 107 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | 107 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| 108 | import type { VcsDriverShape } from "./vcs/VcsDriver.ts"; | 108 | import * as VcsDriver from "./vcs/VcsDriver.ts"; |
| 109 | import { | 109 | import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; |
| 110 | VcsStatusBroadcaster, | 110 | import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; |
| 111 | type VcsStatusBroadcasterShape, | 111 | import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; |
| 112 | layer as VcsStatusBroadcasterLayer, | 112 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 113 | } from "./vcs/VcsStatusBroadcaster.ts"; | 113 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 114 | import { | ||
| 115 | VcsDriverRegistry, | ||
| 116 | type VcsDriverRegistryShape, | ||
| 117 | type VcsDriverHandle, | ||
| 118 | } from "./vcs/VcsDriverRegistry.ts"; | ||
| 119 | import { layer as VcsProvisioningServiceLayer } from "./vcs/VcsProvisioningService.ts"; | ||
| 120 | import { layer as GitWorkflowServiceLayer } from "./git/GitWorkflowService.ts"; | ||
| 121 | import { | ||
| 122 | SourceControlRepositoryService, | ||
| 123 | type SourceControlRepositoryServiceShape, | ||
| 124 | } from "./sourceControl/SourceControlRepositoryService.ts"; | ||
| 125 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; | 114 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; |
| 126 | import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; | 115 | import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; |
| 127 | 116 | ||
| ... | @@ -329,12 +318,12 @@ const buildAppUnderTest = (options?: { | ... | @@ -329,12 +318,12 @@ const buildAppUnderTest = (options?: { |
| 329 | providerRegistry?: Partial<ProviderRegistryShape>; | 318 | providerRegistry?: Partial<ProviderRegistryShape>; |
| 330 | serverSettings?: Partial<ServerSettingsShape>; | 319 | serverSettings?: Partial<ServerSettingsShape>; |
| 331 | open?: Partial<OpenShape>; | 320 | open?: Partial<OpenShape>; |
| 332 | vcsDriver?: Partial<VcsDriverShape>; | 321 | vcsDriver?: Partial<VcsDriver.VcsDriverShape>; |
| 333 | vcsDriverRegistry?: Partial<VcsDriverRegistryShape>; | 322 | vcsDriverRegistry?: Partial<VcsDriverRegistry.VcsDriverRegistryShape>; |
| 334 | gitVcsDriver?: Partial<GitVcsDriver.GitVcsDriverShape>; | 323 | gitVcsDriver?: Partial<GitVcsDriver.GitVcsDriverShape>; |
| 335 | gitManager?: Partial<GitManagerShape>; | 324 | gitManager?: Partial<GitManagerShape>; |
| 336 | sourceControlRepositoryService?: Partial<SourceControlRepositoryServiceShape>; | 325 | sourceControlRepositoryService?: Partial<SourceControlRepositoryService.SourceControlRepositoryServiceShape>; |
| 337 | vcsStatusBroadcaster?: Partial<VcsStatusBroadcasterShape>; | 326 | vcsStatusBroadcaster?: Partial<VcsStatusBroadcaster.VcsStatusBroadcasterShape>; |
| 338 | projectSetupScriptRunner?: Partial<ProjectSetupScriptRunnerShape>; | 327 | projectSetupScriptRunner?: Partial<ProjectSetupScriptRunnerShape>; |
| 339 | terminalManager?: Partial<TerminalManagerShape>; | 328 | terminalManager?: Partial<TerminalManagerShape>; |
| 340 | orchestrationEngine?: Partial<OrchestrationEngineShape>; | 329 | orchestrationEngine?: Partial<OrchestrationEngineShape>; |
| ... | @@ -382,7 +371,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -382,7 +371,7 @@ const buildAppUnderTest = (options?: { |
| 382 | ...options?.config, | 371 | ...options?.config, |
| 383 | }; | 372 | }; |
| 384 | const layerConfig = Layer.succeed(ServerConfig, config); | 373 | const layerConfig = Layer.succeed(ServerConfig, config); |
| 385 | const defaultVcsDriver: VcsDriverShape = { | 374 | const defaultVcsDriver: VcsDriver.VcsDriverShape = { |
| 386 | capabilities: { | 375 | capabilities: { |
| 387 | kind: "git", | 376 | kind: "git", |
| 388 | supportsWorktrees: true, | 377 | supportsWorktrees: true, |
| ... | @@ -424,7 +413,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -424,7 +413,7 @@ const buildAppUnderTest = (options?: { |
| 424 | initRepository: () => Effect.void, | 413 | initRepository: () => Effect.void, |
| 425 | ...options?.layers?.vcsDriver, | 414 | ...options?.layers?.vcsDriver, |
| 426 | }; | 415 | }; |
| 427 | const vcsDriverRegistryLayer = Layer.mock(VcsDriverRegistry)({ | 416 | const vcsDriverRegistryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ |
| 428 | get: () => Effect.succeed(defaultVcsDriver), | 417 | get: () => Effect.succeed(defaultVcsDriver), |
| 429 | detect: (input) => | 418 | detect: (input) => |
| 430 | defaultVcsDriver.detectRepository(input.cwd).pipe( | 419 | defaultVcsDriver.detectRepository(input.cwd).pipe( |
| ... | @@ -454,7 +443,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -454,7 +443,7 @@ const buildAppUnderTest = (options?: { |
| 454 | kind: repository.kind, | 443 | kind: repository.kind, |
| 455 | repository, | 444 | repository, |
| 456 | driver: defaultVcsDriver, | 445 | driver: defaultVcsDriver, |
| 457 | } satisfies VcsDriverHandle) | 446 | } satisfies VcsDriverRegistry.VcsDriverHandle) |
| 458 | : null, | 447 | : null, |
| 459 | ), | 448 | ), |
| 460 | ), | 449 | ), |
| ... | @@ -496,19 +485,19 @@ const buildAppUnderTest = (options?: { | ... | @@ -496,19 +485,19 @@ const buildAppUnderTest = (options?: { |
| 496 | ), | 485 | ), |
| 497 | ProjectFaviconResolverLive, | 486 | ProjectFaviconResolverLive, |
| 498 | ); | 487 | ); |
| 499 | const gitWorkflowLayer = GitWorkflowServiceLayer.pipe( | 488 | const gitWorkflowLayer = GitWorkflowService.layer.pipe( |
| 500 | Layer.provideMerge(vcsDriverRegistryLayer), | 489 | Layer.provideMerge(vcsDriverRegistryLayer), |
| 501 | Layer.provideMerge(gitVcsDriverLayer), | 490 | Layer.provideMerge(gitVcsDriverLayer), |
| 502 | Layer.provideMerge(gitManagerLayer), | 491 | Layer.provideMerge(gitManagerLayer), |
| 503 | ); | 492 | ); |
| 504 | const vcsProvisioningLayer = VcsProvisioningServiceLayer.pipe( | 493 | const vcsProvisioningLayer = VcsProvisioningService.layer.pipe( |
| 505 | Layer.provide(vcsDriverRegistryLayer), | 494 | Layer.provide(vcsDriverRegistryLayer), |
| 506 | ); | 495 | ); |
| 507 | const vcsStatusBroadcasterLayer = options?.layers?.vcsStatusBroadcaster | 496 | const vcsStatusBroadcasterLayer = options?.layers?.vcsStatusBroadcaster |
| 508 | ? Layer.mock(VcsStatusBroadcaster)({ | 497 | ? Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ |
| 509 | ...options.layers.vcsStatusBroadcaster, | 498 | ...options.layers.vcsStatusBroadcaster, |
| 510 | }) | 499 | }) |
| 511 | : VcsStatusBroadcasterLayer.pipe(Layer.provide(gitWorkflowLayer)); | 500 | : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer)); |
| 512 | 501 | ||
| 513 | const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { | 502 | const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { |
| 514 | disableListenLog: true, | 503 | disableListenLog: true, |
| ... | @@ -552,7 +541,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -552,7 +541,7 @@ const buildAppUnderTest = (options?: { |
| 552 | Layer.provide(gitWorkflowLayer), | 541 | Layer.provide(gitWorkflowLayer), |
| 553 | Layer.provide(vcsProvisioningLayer), | 542 | Layer.provide(vcsProvisioningLayer), |
| 554 | Layer.provide( | 543 | Layer.provide( |
| 555 | Layer.mock(SourceControlRepositoryService)({ | 544 | Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ |
| 556 | ...options?.layers?.sourceControlRepositoryService, | 545 | ...options?.layers?.sourceControlRepositoryService, |
| 557 | }), | 546 | }), |
| 558 | ), | 547 | ), |
apps/server/src/server.ts+7-1| ... | @@ -25,6 +25,8 @@ import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReap | ... | @@ -25,6 +25,8 @@ import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReap |
| 25 | import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; | 25 | import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; |
| 26 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; | 26 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; |
| 27 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; | 27 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; |
| 28 | import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; | ||
| 29 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; | ||
| 28 | import * as GitHubCli from "./sourceControl/GitHubCli.ts"; | 30 | import * as GitHubCli from "./sourceControl/GitHubCli.ts"; |
| 29 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; | 31 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; |
| 30 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; | 32 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; |
| ... | @@ -164,7 +166,10 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( | ... | @@ -164,7 +166,10 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( |
| 164 | ); | 166 | ); |
| 165 | 167 | ||
| 166 | const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe( | 168 | const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe( |
| 167 | Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)), | 169 | Layer.provide( |
| 170 | Layer.mergeAll(AzureDevOpsCli.layer, BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer), | ||
| 171 | ), | ||
| 172 | Layer.provideMerge(GitVcsDriver.layer), | ||
| 168 | Layer.provideMerge(VcsDriverRegistryLayerLive), | 173 | Layer.provideMerge(VcsDriverRegistryLayerLive), |
| 169 | ); | 174 | ); |
| 170 | 175 | ||
| ... | @@ -235,6 +240,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( | ... | @@ -235,6 +240,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( |
| 235 | const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( | 240 | const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 236 | // Core Services | 241 | // Core Services |
| 237 | Layer.provideMerge(CheckpointingLayerLive), | 242 | Layer.provideMerge(CheckpointingLayerLive), |
| 243 | Layer.provideMerge(SourceControlProviderRegistryLayerLive), | ||
| 238 | Layer.provideMerge(GitLayerLive), | 244 | Layer.provideMerge(GitLayerLive), |
| 239 | Layer.provideMerge(VcsLayerLive), | 245 | Layer.provideMerge(VcsLayerLive), |
| 240 | Layer.provideMerge(ProviderRuntimeLayerLive), | 246 | Layer.provideMerge(ProviderRuntimeLayerLive), |
apps/server/src/sourceControl/AzureDevOpsCli.test.ts created+288| ... | @@ -0,0 +1,288 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; | ||
| 3 | import { Effect, FileSystem, Layer, Option } from "effect"; | ||
| 4 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 5 | import type { VcsError } from "@t3tools/contracts"; | ||
| 6 | |||
| 7 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 8 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | ||
| 9 | |||
| 10 | const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ | ||
| 11 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 12 | stdout, | ||
| 13 | stderr: "", | ||
| 14 | stdoutTruncated: false, | ||
| 15 | stderrTruncated: false, | ||
| 16 | }); | ||
| 17 | |||
| 18 | const mockRun = vi.fn<VcsProcess.VcsProcessShape["run"]>(); | ||
| 19 | |||
| 20 | const supportLayer = Layer.mergeAll( | ||
| 21 | Layer.mock(VcsProcess.VcsProcess)({ | ||
| 22 | run: mockRun, | ||
| 23 | }), | ||
| 24 | NodeServices.layer, | ||
| 25 | ); | ||
| 26 | const layer = Layer.mergeAll(AzureDevOpsCli.layer.pipe(Layer.provide(supportLayer)), supportLayer); | ||
| 27 | |||
| 28 | afterEach(() => { | ||
| 29 | mockRun.mockReset(); | ||
| 30 | }); | ||
| 31 | |||
| 32 | describe("AzureDevOpsCli.layer", () => { | ||
| 33 | it.effect("parses pull request view output", () => | ||
| 34 | Effect.gen(function* () { | ||
| 35 | mockRun.mockReturnValueOnce( | ||
| 36 | Effect.succeed( | ||
| 37 | processOutput( | ||
| 38 | JSON.stringify({ | ||
| 39 | pullRequestId: 42, | ||
| 40 | title: "Add Azure provider", | ||
| 41 | sourceRefName: "refs/heads/feature/source-control", | ||
| 42 | targetRefName: "refs/heads/main", | ||
| 43 | status: "active", | ||
| 44 | creationDate: "2026-01-02T00:00:00.000Z", | ||
| 45 | closedDate: null, | ||
| 46 | _links: { | ||
| 47 | web: { | ||
| 48 | href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", | ||
| 49 | }, | ||
| 50 | }, | ||
| 51 | }), | ||
| 52 | ), | ||
| 53 | ), | ||
| 54 | ); | ||
| 55 | |||
| 56 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | ||
| 57 | const result = yield* az.getPullRequest({ | ||
| 58 | cwd: "/repo", | ||
| 59 | reference: "#42", | ||
| 60 | }); | ||
| 61 | |||
| 62 | assert.strictEqual(result.number, 42); | ||
| 63 | assert.strictEqual(result.title, "Add Azure provider"); | ||
| 64 | assert.strictEqual(result.baseRefName, "main"); | ||
| 65 | assert.strictEqual(result.headRefName, "feature/source-control"); | ||
| 66 | assert.strictEqual(result.state, "open"); | ||
| 67 | assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag); | ||
| 68 | assert.deepStrictEqual(mockRun.mock.calls.at(-1)?.[0], { | ||
| 69 | operation: "AzureDevOpsCli.execute", | ||
| 70 | command: "az", | ||
| 71 | args: [ | ||
| 72 | "repos", | ||
| 73 | "pr", | ||
| 74 | "show", | ||
| 75 | "--detect", | ||
| 76 | "true", | ||
| 77 | "--id", | ||
| 78 | "42", | ||
| 79 | "--only-show-errors", | ||
| 80 | "--output", | ||
| 81 | "json", | ||
| 82 | ], | ||
| 83 | cwd: "/repo", | ||
| 84 | timeoutMs: 30_000, | ||
| 85 | }); | ||
| 86 | }).pipe(Effect.provide(layer)), | ||
| 87 | ); | ||
| 88 | |||
| 89 | it.effect("lists pull requests with Azure status and source branch arguments", () => | ||
| 90 | Effect.gen(function* () { | ||
| 91 | mockRun.mockReturnValueOnce( | ||
| 92 | Effect.succeed( | ||
| 93 | processOutput( | ||
| 94 | JSON.stringify([ | ||
| 95 | { | ||
| 96 | pullRequestId: 7, | ||
| 97 | title: "Merged work", | ||
| 98 | sourceRefName: "refs/heads/feature/merged", | ||
| 99 | targetRefName: "refs/heads/main", | ||
| 100 | status: "completed", | ||
| 101 | closedDate: "2026-01-03T00:00:00.000Z", | ||
| 102 | _links: { | ||
| 103 | web: { | ||
| 104 | href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/7", | ||
| 105 | }, | ||
| 106 | }, | ||
| 107 | }, | ||
| 108 | ]), | ||
| 109 | ), | ||
| 110 | ), | ||
| 111 | ); | ||
| 112 | |||
| 113 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | ||
| 114 | const result = yield* az.listPullRequests({ | ||
| 115 | cwd: "/repo", | ||
| 116 | headSelector: "origin:feature/merged", | ||
| 117 | state: "merged", | ||
| 118 | limit: 10, | ||
| 119 | }); | ||
| 120 | |||
| 121 | assert.strictEqual(result[0]?.state, "merged"); | ||
| 122 | expect(mockRun).toHaveBeenCalledWith({ | ||
| 123 | operation: "AzureDevOpsCli.execute", | ||
| 124 | command: "az", | ||
| 125 | args: [ | ||
| 126 | "repos", | ||
| 127 | "pr", | ||
| 128 | "list", | ||
| 129 | "--detect", | ||
| 130 | "true", | ||
| 131 | "--source-branch", | ||
| 132 | "feature/merged", | ||
| 133 | "--status", | ||
| 134 | "completed", | ||
| 135 | "--top", | ||
| 136 | "10", | ||
| 137 | "--only-show-errors", | ||
| 138 | "--output", | ||
| 139 | "json", | ||
| 140 | ], | ||
| 141 | cwd: "/repo", | ||
| 142 | timeoutMs: 30_000, | ||
| 143 | }); | ||
| 144 | }).pipe(Effect.provide(layer)), | ||
| 145 | ); | ||
| 146 | |||
| 147 | it.effect("reads repository clone URLs", () => | ||
| 148 | Effect.gen(function* () { | ||
| 149 | mockRun.mockReturnValueOnce( | ||
| 150 | Effect.succeed( | ||
| 151 | processOutput( | ||
| 152 | JSON.stringify({ | ||
| 153 | name: "repo", | ||
| 154 | webUrl: "https://dev.azure.com/acme/project/_git/repo", | ||
| 155 | remoteUrl: "https://dev.azure.com/acme/project/_git/repo", | ||
| 156 | sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", | ||
| 157 | project: { | ||
| 158 | name: "project", | ||
| 159 | }, | ||
| 160 | }), | ||
| 161 | ), | ||
| 162 | ), | ||
| 163 | ); | ||
| 164 | |||
| 165 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | ||
| 166 | const result = yield* az.getRepositoryCloneUrls({ | ||
| 167 | cwd: "/repo", | ||
| 168 | repository: "repo", | ||
| 169 | }); | ||
| 170 | |||
| 171 | assert.deepStrictEqual(result, { | ||
| 172 | nameWithOwner: "project/repo", | ||
| 173 | url: "https://dev.azure.com/acme/project/_git/repo", | ||
| 174 | sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", | ||
| 175 | }); | ||
| 176 | }).pipe(Effect.provide(layer)), | ||
| 177 | ); | ||
| 178 | |||
| 179 | it.effect("creates repositories through Azure Repos", () => | ||
| 180 | Effect.gen(function* () { | ||
| 181 | mockRun.mockReturnValueOnce( | ||
| 182 | Effect.succeed( | ||
| 183 | processOutput( | ||
| 184 | JSON.stringify({ | ||
| 185 | name: "repo", | ||
| 186 | webUrl: "https://dev.azure.com/acme/project/_git/repo", | ||
| 187 | remoteUrl: "https://dev.azure.com/acme/project/_git/repo", | ||
| 188 | sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", | ||
| 189 | project: { | ||
| 190 | name: "project", | ||
| 191 | }, | ||
| 192 | }), | ||
| 193 | ), | ||
| 194 | ), | ||
| 195 | ); | ||
| 196 | |||
| 197 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | ||
| 198 | const result = yield* az.createRepository({ | ||
| 199 | cwd: "/repo", | ||
| 200 | repository: "project/repo", | ||
| 201 | visibility: "private", | ||
| 202 | }); | ||
| 203 | |||
| 204 | assert.deepStrictEqual(result, { | ||
| 205 | nameWithOwner: "project/repo", | ||
| 206 | url: "https://dev.azure.com/acme/project/_git/repo", | ||
| 207 | sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", | ||
| 208 | }); | ||
| 209 | expect(mockRun).toHaveBeenCalledWith({ | ||
| 210 | operation: "AzureDevOpsCli.execute", | ||
| 211 | command: "az", | ||
| 212 | args: [ | ||
| 213 | "repos", | ||
| 214 | "create", | ||
| 215 | "--detect", | ||
| 216 | "true", | ||
| 217 | "--name", | ||
| 218 | "repo", | ||
| 219 | "--project", | ||
| 220 | "project", | ||
| 221 | "--only-show-errors", | ||
| 222 | "--output", | ||
| 223 | "json", | ||
| 224 | ], | ||
| 225 | cwd: "/repo", | ||
| 226 | timeoutMs: 30_000, | ||
| 227 | }); | ||
| 228 | }).pipe(Effect.provide(layer)), | ||
| 229 | ); | ||
| 230 | |||
| 231 | it.effect("creates pull requests using the body file as the Azure description", () => | ||
| 232 | Effect.gen(function* () { | ||
| 233 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 234 | const bodyFile = `/tmp/t3code-azure-devops-cli-${Date.now()}.md`; | ||
| 235 | yield* fileSystem.writeFileString(bodyFile, "Generated body"); | ||
| 236 | mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); | ||
| 237 | |||
| 238 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | ||
| 239 | yield* az.createPullRequest({ | ||
| 240 | cwd: "/repo", | ||
| 241 | baseBranch: "main", | ||
| 242 | headSelector: "feature/provider", | ||
| 243 | title: "Provider PR", | ||
| 244 | bodyFile, | ||
| 245 | }); | ||
| 246 | |||
| 247 | expect(mockRun).toHaveBeenCalledWith( | ||
| 248 | expect.objectContaining({ | ||
| 249 | command: "az", | ||
| 250 | cwd: "/repo", | ||
| 251 | args: expect.arrayContaining(["--description", `@${bodyFile}`]), | ||
| 252 | }), | ||
| 253 | ); | ||
| 254 | expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--output"); | ||
| 255 | }).pipe(Effect.provide(layer)), | ||
| 256 | ); | ||
| 257 | |||
| 258 | it.effect("does not force JSON output on checkout side-effect commands", () => | ||
| 259 | Effect.gen(function* () { | ||
| 260 | mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); | ||
| 261 | |||
| 262 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | ||
| 263 | yield* az.checkoutPullRequest({ | ||
| 264 | cwd: "/repo", | ||
| 265 | reference: "42", | ||
| 266 | }); | ||
| 267 | |||
| 268 | expect(mockRun).toHaveBeenCalledWith({ | ||
| 269 | operation: "AzureDevOpsCli.execute", | ||
| 270 | command: "az", | ||
| 271 | args: [ | ||
| 272 | "repos", | ||
| 273 | "pr", | ||
| 274 | "checkout", | ||
| 275 | "--only-show-errors", | ||
| 276 | "--detect", | ||
| 277 | "true", | ||
| 278 | "--id", | ||
| 279 | "42", | ||
| 280 | "--remote-name", | ||
| 281 | "origin", | ||
| 282 | ], | ||
| 283 | cwd: "/repo", | ||
| 284 | timeoutMs: 30_000, | ||
| 285 | }); | ||
| 286 | }).pipe(Effect.provide(layer)), | ||
| 287 | ); | ||
| 288 | }); | ||
apps/server/src/sourceControl/AzureDevOpsCli.ts created+429| ... | @@ -0,0 +1,429 @@ | ||
| 1 | import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; | ||
| 2 | import { | ||
| 3 | TrimmedNonEmptyString, | ||
| 4 | type SourceControlRepositoryVisibility, | ||
| 5 | type VcsError, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 9 | import * as AzureDevOpsPullRequests from "./azureDevOpsPullRequests.ts"; | ||
| 10 | import * as SourceControlProvider from "./SourceControlProvider.ts"; | ||
| 11 | |||
| 12 | const DEFAULT_TIMEOUT_MS = 30_000; | ||
| 13 | |||
| 14 | export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliError>()( | ||
| 15 | "AzureDevOpsCliError", | ||
| 16 | { | ||
| 17 | operation: Schema.String, | ||
| 18 | detail: Schema.String, | ||
| 19 | cause: Schema.optional(Schema.Defect), | ||
| 20 | }, | ||
| 21 | ) { | ||
| 22 | override get message(): string { | ||
| 23 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | export interface AzureDevOpsRepositoryCloneUrls { | ||
| 28 | readonly nameWithOwner: string; | ||
| 29 | readonly url: string; | ||
| 30 | readonly sshUrl: string; | ||
| 31 | } | ||
| 32 | |||
| 33 | export interface AzureDevOpsCliShape { | ||
| 34 | readonly execute: (input: { | ||
| 35 | readonly cwd: string; | ||
| 36 | readonly args: ReadonlyArray<string>; | ||
| 37 | readonly timeoutMs?: number; | ||
| 38 | }) => Effect.Effect<VcsProcess.VcsProcessOutput, AzureDevOpsCliError>; | ||
| 39 | |||
| 40 | readonly listPullRequests: (input: { | ||
| 41 | readonly cwd: string; | ||
| 42 | readonly headSelector: string; | ||
| 43 | readonly source?: SourceControlProvider.SourceControlRefSelector; | ||
| 44 | readonly state: "open" | "closed" | "merged" | "all"; | ||
| 45 | readonly limit?: number; | ||
| 46 | }) => Effect.Effect< | ||
| 47 | ReadonlyArray<AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord>, | ||
| 48 | AzureDevOpsCliError | ||
| 49 | >; | ||
| 50 | |||
| 51 | readonly getPullRequest: (input: { | ||
| 52 | readonly cwd: string; | ||
| 53 | readonly reference: string; | ||
| 54 | }) => Effect.Effect< | ||
| 55 | AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord, | ||
| 56 | AzureDevOpsCliError | ||
| 57 | >; | ||
| 58 | |||
| 59 | readonly getRepositoryCloneUrls: (input: { | ||
| 60 | readonly cwd: string; | ||
| 61 | readonly repository: string; | ||
| 62 | }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>; | ||
| 63 | |||
| 64 | readonly createRepository: (input: { | ||
| 65 | readonly cwd: string; | ||
| 66 | readonly repository: string; | ||
| 67 | readonly visibility: SourceControlRepositoryVisibility; | ||
| 68 | }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>; | ||
| 69 | |||
| 70 | readonly createPullRequest: (input: { | ||
| 71 | readonly cwd: string; | ||
| 72 | readonly baseBranch: string; | ||
| 73 | readonly headSelector: string; | ||
| 74 | readonly source?: SourceControlProvider.SourceControlRefSelector; | ||
| 75 | readonly target?: SourceControlProvider.SourceControlRefSelector; | ||
| 76 | readonly title: string; | ||
| 77 | readonly bodyFile: string; | ||
| 78 | }) => Effect.Effect<void, AzureDevOpsCliError>; | ||
| 79 | |||
| 80 | readonly getDefaultBranch: (input: { | ||
| 81 | readonly cwd: string; | ||
| 82 | }) => Effect.Effect<string | null, AzureDevOpsCliError>; | ||
| 83 | |||
| 84 | readonly checkoutPullRequest: (input: { | ||
| 85 | readonly cwd: string; | ||
| 86 | readonly reference: string; | ||
| 87 | readonly remoteName?: string; | ||
| 88 | }) => Effect.Effect<void, AzureDevOpsCliError>; | ||
| 89 | } | ||
| 90 | |||
| 91 | export class AzureDevOpsCli extends Context.Service<AzureDevOpsCli, AzureDevOpsCliShape>()( | ||
| 92 | "t3/source-control/AzureDevOpsCli", | ||
| 93 | ) {} | ||
| 94 | |||
| 95 | function errorText(error: VcsError | unknown): string { | ||
| 96 | if (typeof error === "object" && error !== null) { | ||
| 97 | const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; | ||
| 98 | const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; | ||
| 99 | const message = "message" in error && typeof error.message === "string" ? error.message : ""; | ||
| 100 | return [tag, detail, message].filter(Boolean).join("\n"); | ||
| 101 | } | ||
| 102 | |||
| 103 | return String(error); | ||
| 104 | } | ||
| 105 | |||
| 106 | function normalizeAzureDevOpsCliError( | ||
| 107 | operation: "execute", | ||
| 108 | error: VcsError | unknown, | ||
| 109 | ): AzureDevOpsCliError { | ||
| 110 | const text = errorText(error); | ||
| 111 | const lower = text.toLowerCase(); | ||
| 112 | |||
| 113 | if (lower.includes("command not found: az") || lower.includes("enoent")) { | ||
| 114 | return new AzureDevOpsCliError({ | ||
| 115 | operation, | ||
| 116 | detail: | ||
| 117 | "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH.", | ||
| 118 | cause: error, | ||
| 119 | }); | ||
| 120 | } | ||
| 121 | |||
| 122 | if ( | ||
| 123 | lower.includes("az devops login") || | ||
| 124 | lower.includes("please run az login") || | ||
| 125 | lower.includes("not logged in") || | ||
| 126 | lower.includes("authentication failed") || | ||
| 127 | lower.includes("unauthorized") | ||
| 128 | ) { | ||
| 129 | return new AzureDevOpsCliError({ | ||
| 130 | operation, | ||
| 131 | detail: "Azure DevOps CLI is not authenticated. Run `az devops login` and retry.", | ||
| 132 | cause: error, | ||
| 133 | }); | ||
| 134 | } | ||
| 135 | |||
| 136 | if ( | ||
| 137 | lower.includes("pull request") && | ||
| 138 | (lower.includes("not found") || lower.includes("does not exist")) | ||
| 139 | ) { | ||
| 140 | return new AzureDevOpsCliError({ | ||
| 141 | operation, | ||
| 142 | detail: "Pull request not found. Check the PR number or URL and try again.", | ||
| 143 | cause: error, | ||
| 144 | }); | ||
| 145 | } | ||
| 146 | |||
| 147 | return new AzureDevOpsCliError({ | ||
| 148 | operation, | ||
| 149 | detail: text, | ||
| 150 | cause: error, | ||
| 151 | }); | ||
| 152 | } | ||
| 153 | |||
| 154 | function normalizeChangeRequestId(reference: string): string { | ||
| 155 | const trimmed = reference.trim().replace(/^#/, ""); | ||
| 156 | const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed); | ||
| 157 | return urlMatch?.[1] ?? trimmed; | ||
| 158 | } | ||
| 159 | |||
| 160 | function toAzureStatus(state: "open" | "closed" | "merged" | "all"): string { | ||
| 161 | switch (state) { | ||
| 162 | case "open": | ||
| 163 | return "active"; | ||
| 164 | case "closed": | ||
| 165 | return "abandoned"; | ||
| 166 | case "merged": | ||
| 167 | return "completed"; | ||
| 168 | case "all": | ||
| 169 | return "all"; | ||
| 170 | } | ||
| 171 | } | ||
| 172 | |||
| 173 | const RawAzureDevOpsRepositorySchema = Schema.Struct({ | ||
| 174 | name: TrimmedNonEmptyString, | ||
| 175 | webUrl: TrimmedNonEmptyString, | ||
| 176 | remoteUrl: TrimmedNonEmptyString, | ||
| 177 | sshUrl: TrimmedNonEmptyString, | ||
| 178 | project: Schema.optional( | ||
| 179 | Schema.Struct({ | ||
| 180 | name: TrimmedNonEmptyString, | ||
| 181 | }), | ||
| 182 | ), | ||
| 183 | defaultBranch: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 184 | }); | ||
| 185 | |||
| 186 | function normalizeDefaultBranch(value: string | null | undefined): string | null { | ||
| 187 | const trimmed = value?.trim().replace(/^refs\/heads\//, "") ?? ""; | ||
| 188 | return trimmed.length > 0 ? trimmed : null; | ||
| 189 | } | ||
| 190 | |||
| 191 | function normalizeRepositoryCloneUrls( | ||
| 192 | raw: Schema.Schema.Type<typeof RawAzureDevOpsRepositorySchema>, | ||
| 193 | ): AzureDevOpsRepositoryCloneUrls { | ||
| 194 | const projectName = raw.project?.name.trim(); | ||
| 195 | return { | ||
| 196 | nameWithOwner: projectName ? `${projectName}/${raw.name}` : raw.name, | ||
| 197 | url: raw.remoteUrl, | ||
| 198 | sshUrl: raw.sshUrl, | ||
| 199 | }; | ||
| 200 | } | ||
| 201 | |||
| 202 | function parseRepositorySpecifier(repository: string): { | ||
| 203 | readonly project: string | null; | ||
| 204 | readonly name: string; | ||
| 205 | } { | ||
| 206 | const parts = repository | ||
| 207 | .split("/") | ||
| 208 | .map((part) => part.trim()) | ||
| 209 | .filter((part) => part.length > 0); | ||
| 210 | return { | ||
| 211 | project: parts.length > 1 ? (parts.at(-2) ?? null) : null, | ||
| 212 | name: parts.at(-1) ?? repository.trim(), | ||
| 213 | }; | ||
| 214 | } | ||
| 215 | |||
| 216 | function decodeAzureDevOpsJson<S extends Schema.Top>( | ||
| 217 | raw: string, | ||
| 218 | schema: S, | ||
| 219 | operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", | ||
| 220 | invalidDetail: string, | ||
| 221 | ): Effect.Effect<S["Type"], AzureDevOpsCliError, S["DecodingServices"]> { | ||
| 222 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( | ||
| 223 | Effect.mapError( | ||
| 224 | (error) => | ||
| 225 | new AzureDevOpsCliError({ | ||
| 226 | operation, | ||
| 227 | detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, | ||
| 228 | cause: error, | ||
| 229 | }), | ||
| 230 | ), | ||
| 231 | ); | ||
| 232 | } | ||
| 233 | |||
| 234 | export const make = Effect.fn("makeAzureDevOpsCli")(function* () { | ||
| 235 | const process = yield* VcsProcess.VcsProcess; | ||
| 236 | |||
| 237 | const execute: AzureDevOpsCliShape["execute"] = (input) => | ||
| 238 | process | ||
| 239 | .run({ | ||
| 240 | operation: "AzureDevOpsCli.execute", | ||
| 241 | command: "az", | ||
| 242 | args: input.args, | ||
| 243 | cwd: input.cwd, | ||
| 244 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, | ||
| 245 | }) | ||
| 246 | .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error))); | ||
| 247 | |||
| 248 | const executeJson = (input: Parameters<AzureDevOpsCliShape["execute"]>[0]) => | ||
| 249 | execute({ | ||
| 250 | ...input, | ||
| 251 | args: [...input.args, "--only-show-errors", "--output", "json"], | ||
| 252 | }); | ||
| 253 | |||
| 254 | return AzureDevOpsCli.of({ | ||
| 255 | execute, | ||
| 256 | listPullRequests: (input) => | ||
| 257 | executeJson({ | ||
| 258 | cwd: input.cwd, | ||
| 259 | args: [ | ||
| 260 | "repos", | ||
| 261 | "pr", | ||
| 262 | "list", | ||
| 263 | "--detect", | ||
| 264 | "true", | ||
| 265 | "--source-branch", | ||
| 266 | SourceControlProvider.sourceBranch(input), | ||
| 267 | "--status", | ||
| 268 | toAzureStatus(input.state), | ||
| 269 | "--top", | ||
| 270 | String(input.limit ?? 20), | ||
| 271 | ], | ||
| 272 | }).pipe( | ||
| 273 | Effect.map((result) => result.stdout.trim()), | ||
| 274 | Effect.flatMap((raw) => | ||
| 275 | raw.length === 0 | ||
| 276 | ? Effect.succeed([]) | ||
| 277 | : Effect.sync(() => | ||
| 278 | AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestListJson(raw), | ||
| 279 | ).pipe( | ||
| 280 | Effect.flatMap((decoded) => { | ||
| 281 | if (!Result.isSuccess(decoded)) { | ||
| 282 | return Effect.fail( | ||
| 283 | new AzureDevOpsCliError({ | ||
| 284 | operation: "listPullRequests", | ||
| 285 | detail: `Azure DevOps CLI returned invalid PR list JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, | ||
| 286 | cause: decoded.failure, | ||
| 287 | }), | ||
| 288 | ); | ||
| 289 | } | ||
| 290 | |||
| 291 | return Effect.succeed(decoded.success); | ||
| 292 | }), | ||
| 293 | ), | ||
| 294 | ), | ||
| 295 | ), | ||
| 296 | getPullRequest: (input) => | ||
| 297 | executeJson({ | ||
| 298 | cwd: input.cwd, | ||
| 299 | args: [ | ||
| 300 | "repos", | ||
| 301 | "pr", | ||
| 302 | "show", | ||
| 303 | "--detect", | ||
| 304 | "true", | ||
| 305 | "--id", | ||
| 306 | normalizeChangeRequestId(input.reference), | ||
| 307 | ], | ||
| 308 | }).pipe( | ||
| 309 | Effect.map((result) => result.stdout.trim()), | ||
| 310 | Effect.flatMap((raw) => | ||
| 311 | Effect.sync(() => AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestJson(raw)).pipe( | ||
| 312 | Effect.flatMap((decoded) => { | ||
| 313 | if (!Result.isSuccess(decoded)) { | ||
| 314 | return Effect.fail( | ||
| 315 | new AzureDevOpsCliError({ | ||
| 316 | operation: "getPullRequest", | ||
| 317 | detail: `Azure DevOps CLI returned invalid pull request JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, | ||
| 318 | cause: decoded.failure, | ||
| 319 | }), | ||
| 320 | ); | ||
| 321 | } | ||
| 322 | |||
| 323 | return Effect.succeed(decoded.success); | ||
| 324 | }), | ||
| 325 | ), | ||
| 326 | ), | ||
| 327 | ), | ||
| 328 | getRepositoryCloneUrls: (input) => | ||
| 329 | executeJson({ | ||
| 330 | cwd: input.cwd, | ||
| 331 | args: ["repos", "show", "--detect", "true", "--repository", input.repository], | ||
| 332 | }).pipe( | ||
| 333 | Effect.map((result) => result.stdout.trim()), | ||
| 334 | Effect.flatMap((raw) => | ||
| 335 | decodeAzureDevOpsJson( | ||
| 336 | raw, | ||
| 337 | RawAzureDevOpsRepositorySchema, | ||
| 338 | "getRepositoryCloneUrls", | ||
| 339 | "Azure DevOps CLI returned invalid repository JSON.", | ||
| 340 | ), | ||
| 341 | ), | ||
| 342 | Effect.map(normalizeRepositoryCloneUrls), | ||
| 343 | ), | ||
| 344 | createRepository: (input) => { | ||
| 345 | const repository = parseRepositorySpecifier(input.repository); | ||
| 346 | // Azure Repos access is governed by project/organization permissions. | ||
| 347 | // `az repos create` does not expose a per-repository visibility flag, so | ||
| 348 | // the generic source-control visibility input is intentionally not | ||
| 349 | // translated into CLI args for this provider. | ||
| 350 | return executeJson({ | ||
| 351 | cwd: input.cwd, | ||
| 352 | args: [ | ||
| 353 | "repos", | ||
| 354 | "create", | ||
| 355 | "--detect", | ||
| 356 | "true", | ||
| 357 | "--name", | ||
| 358 | repository.name, | ||
| 359 | ...(repository.project ? ["--project", repository.project] : []), | ||
| 360 | ], | ||
| 361 | }).pipe( | ||
| 362 | Effect.map((result) => result.stdout.trim()), | ||
| 363 | Effect.flatMap((raw) => | ||
| 364 | decodeAzureDevOpsJson( | ||
| 365 | raw, | ||
| 366 | RawAzureDevOpsRepositorySchema, | ||
| 367 | "createRepository", | ||
| 368 | "Azure DevOps CLI returned invalid repository JSON.", | ||
| 369 | ), | ||
| 370 | ), | ||
| 371 | Effect.map(normalizeRepositoryCloneUrls), | ||
| 372 | ); | ||
| 373 | }, | ||
| 374 | createPullRequest: (input) => | ||
| 375 | execute({ | ||
| 376 | cwd: input.cwd, | ||
| 377 | args: [ | ||
| 378 | "repos", | ||
| 379 | "pr", | ||
| 380 | "create", | ||
| 381 | "--only-show-errors", | ||
| 382 | "--detect", | ||
| 383 | "true", | ||
| 384 | "--target-branch", | ||
| 385 | input.target?.refName ?? input.baseBranch, | ||
| 386 | "--source-branch", | ||
| 387 | SourceControlProvider.sourceBranch(input), | ||
| 388 | "--title", | ||
| 389 | input.title, | ||
| 390 | "--description", | ||
| 391 | `@${input.bodyFile}`, | ||
| 392 | ], | ||
| 393 | }).pipe(Effect.asVoid), | ||
| 394 | getDefaultBranch: (input) => | ||
| 395 | executeJson({ | ||
| 396 | cwd: input.cwd, | ||
| 397 | args: ["repos", "show", "--detect", "true"], | ||
| 398 | }).pipe( | ||
| 399 | Effect.map((result) => result.stdout.trim()), | ||
| 400 | Effect.flatMap((raw) => | ||
| 401 | decodeAzureDevOpsJson( | ||
| 402 | raw, | ||
| 403 | RawAzureDevOpsRepositorySchema, | ||
| 404 | "getDefaultBranch", | ||
| 405 | "Azure DevOps CLI returned invalid repository JSON.", | ||
| 406 | ), | ||
| 407 | ), | ||
| 408 | Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), | ||
| 409 | ), | ||
| 410 | checkoutPullRequest: (input) => | ||
| 411 | execute({ | ||
| 412 | cwd: input.cwd, | ||
| 413 | args: [ | ||
| 414 | "repos", | ||
| 415 | "pr", | ||
| 416 | "checkout", | ||
| 417 | "--only-show-errors", | ||
| 418 | "--detect", | ||
| 419 | "true", | ||
| 420 | "--id", | ||
| 421 | normalizeChangeRequestId(input.reference), | ||
| 422 | "--remote-name", | ||
| 423 | input.remoteName ?? "origin", | ||
| 424 | ], | ||
| 425 | }).pipe(Effect.asVoid), | ||
| 426 | }); | ||
| 427 | }); | ||
| 428 | |||
| 429 | export const layer = Layer.effect(AzureDevOpsCli, make()); | ||
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts created+91| ... | @@ -0,0 +1,91 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer, Option } from "effect"; | ||
| 3 | |||
| 4 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | ||
| 5 | import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; | ||
| 6 | |||
| 7 | function makeProvider(azure: Partial<AzureDevOpsCli.AzureDevOpsCliShape>) { | ||
| 8 | return AzureDevOpsSourceControlProvider.make().pipe( | ||
| 9 | Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)), | ||
| 10 | ); | ||
| 11 | } | ||
| 12 | |||
| 13 | it.effect("maps Azure DevOps PR summaries into provider-neutral change requests", () => | ||
| 14 | Effect.gen(function* () { | ||
| 15 | const provider = yield* makeProvider({ | ||
| 16 | getPullRequest: () => | ||
| 17 | Effect.succeed({ | ||
| 18 | number: 42, | ||
| 19 | title: "Add Azure provider", | ||
| 20 | url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", | ||
| 21 | baseRefName: "main", | ||
| 22 | headRefName: "feature/source-control", | ||
| 23 | state: "open", | ||
| 24 | updatedAt: Option.none(), | ||
| 25 | }), | ||
| 26 | }); | ||
| 27 | |||
| 28 | const changeRequest = yield* provider.getChangeRequest({ | ||
| 29 | cwd: "/repo", | ||
| 30 | reference: "42", | ||
| 31 | }); | ||
| 32 | |||
| 33 | assert.deepStrictEqual(changeRequest, { | ||
| 34 | provider: "azure-devops", | ||
| 35 | number: 42, | ||
| 36 | title: "Add Azure provider", | ||
| 37 | url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", | ||
| 38 | baseRefName: "main", | ||
| 39 | headRefName: "feature/source-control", | ||
| 40 | state: "open", | ||
| 41 | updatedAt: Option.none(), | ||
| 42 | isCrossRepository: false, | ||
| 43 | }); | ||
| 44 | }), | ||
| 45 | ); | ||
| 46 | |||
| 47 | it.effect("creates Azure DevOps PRs through provider-neutral input names", () => | ||
| 48 | Effect.gen(function* () { | ||
| 49 | let createInput: Parameters<AzureDevOpsCli.AzureDevOpsCliShape["createPullRequest"]>[0] | null = | ||
| 50 | null; | ||
| 51 | const provider = yield* makeProvider({ | ||
| 52 | createPullRequest: (input) => { | ||
| 53 | createInput = input; | ||
| 54 | return Effect.void; | ||
| 55 | }, | ||
| 56 | }); | ||
| 57 | |||
| 58 | yield* provider.createChangeRequest({ | ||
| 59 | cwd: "/repo", | ||
| 60 | baseRefName: "main", | ||
| 61 | headSelector: "feature/provider", | ||
| 62 | title: "Provider PR", | ||
| 63 | bodyFile: "/tmp/body.md", | ||
| 64 | }); | ||
| 65 | |||
| 66 | assert.deepStrictEqual(createInput, { | ||
| 67 | cwd: "/repo", | ||
| 68 | baseBranch: "main", | ||
| 69 | headSelector: "feature/provider", | ||
| 70 | title: "Provider PR", | ||
| 71 | bodyFile: "/tmp/body.md", | ||
| 72 | }); | ||
| 73 | }), | ||
| 74 | ); | ||
| 75 | |||
| 76 | it.effect("uses Azure CLI repository detection for default branch lookup", () => | ||
| 77 | Effect.gen(function* () { | ||
| 78 | let cwdInput: string | null = null; | ||
| 79 | const provider = yield* makeProvider({ | ||
| 80 | getDefaultBranch: (input) => { | ||
| 81 | cwdInput = input.cwd; | ||
| 82 | return Effect.succeed("main"); | ||
| 83 | }, | ||
| 84 | }); | ||
| 85 | |||
| 86 | const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); | ||
| 87 | |||
| 88 | assert.strictEqual(defaultBranch, "main"); | ||
| 89 | assert.strictEqual(cwdInput, "/repo"); | ||
| 90 | }), | ||
| 91 | ); | ||
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts created+144| ... | @@ -0,0 +1,144 @@ | ||
| 1 | import { Effect, Layer } from "effect"; | ||
| 2 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | ||
| 5 | import * as SourceControlProvider from "./SourceControlProvider.ts"; | ||
| 6 | import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; | ||
| 7 | |||
| 8 | function providerError( | ||
| 9 | operation: string, | ||
| 10 | cause: AzureDevOpsCli.AzureDevOpsCliError, | ||
| 11 | ): SourceControlProviderError { | ||
| 12 | return new SourceControlProviderError({ | ||
| 13 | provider: "azure-devops", | ||
| 14 | operation, | ||
| 15 | detail: cause.detail, | ||
| 16 | cause, | ||
| 17 | }); | ||
| 18 | } | ||
| 19 | |||
| 20 | function parseAzureAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { | ||
| 21 | const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); | ||
| 22 | |||
| 23 | if (input.exitCode !== 0) { | ||
| 24 | return SourceControlProviderDiscovery.providerAuth({ | ||
| 25 | status: "unauthenticated", | ||
| 26 | detail: | ||
| 27 | SourceControlProviderDiscovery.firstSafeAuthLine( | ||
| 28 | SourceControlProviderDiscovery.combinedAuthOutput(input), | ||
| 29 | ) ?? "Run `az login` to authenticate Azure CLI.", | ||
| 30 | }); | ||
| 31 | } | ||
| 32 | |||
| 33 | if (account && account.length > 0) { | ||
| 34 | return SourceControlProviderDiscovery.providerAuth({ | ||
| 35 | status: "authenticated", | ||
| 36 | account, | ||
| 37 | host: "dev.azure.com", | ||
| 38 | }); | ||
| 39 | } | ||
| 40 | |||
| 41 | return SourceControlProviderDiscovery.providerAuth({ | ||
| 42 | status: "unknown", | ||
| 43 | host: "dev.azure.com", | ||
| 44 | detail: "Azure CLI account status could not be parsed.", | ||
| 45 | }); | ||
| 46 | } | ||
| 47 | |||
| 48 | export const discovery = { | ||
| 49 | type: "cli", | ||
| 50 | kind: "azure-devops", | ||
| 51 | label: "Azure DevOps", | ||
| 52 | executable: "az", | ||
| 53 | versionArgs: ["--version"], | ||
| 54 | authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], | ||
| 55 | parseAuth: parseAzureAuth, | ||
| 56 | installHint: | ||
| 57 | "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.", | ||
| 58 | } satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; | ||
| 59 | |||
| 60 | function toChangeRequest(summary: { | ||
| 61 | readonly number: number; | ||
| 62 | readonly title: string; | ||
| 63 | readonly url: string; | ||
| 64 | readonly baseRefName: string; | ||
| 65 | readonly headRefName: string; | ||
| 66 | readonly state: "open" | "closed" | "merged"; | ||
| 67 | readonly updatedAt: ChangeRequest["updatedAt"]; | ||
| 68 | }): ChangeRequest { | ||
| 69 | return { | ||
| 70 | provider: "azure-devops", | ||
| 71 | number: summary.number, | ||
| 72 | title: summary.title, | ||
| 73 | url: summary.url, | ||
| 74 | baseRefName: summary.baseRefName, | ||
| 75 | headRefName: summary.headRefName, | ||
| 76 | state: summary.state, | ||
| 77 | updatedAt: summary.updatedAt, | ||
| 78 | isCrossRepository: false, | ||
| 79 | }; | ||
| 80 | } | ||
| 81 | |||
| 82 | export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* () { | ||
| 83 | const azure = yield* AzureDevOpsCli.AzureDevOpsCli; | ||
| 84 | |||
| 85 | return SourceControlProvider.SourceControlProvider.of({ | ||
| 86 | kind: "azure-devops", | ||
| 87 | listChangeRequests: (input) => { | ||
| 88 | const source = SourceControlProvider.sourceControlRefFromInput(input); | ||
| 89 | return azure | ||
| 90 | .listPullRequests({ | ||
| 91 | cwd: input.cwd, | ||
| 92 | headSelector: input.headSelector, | ||
| 93 | ...(source ? { source } : {}), | ||
| 94 | state: input.state, | ||
| 95 | ...(input.limit !== undefined ? { limit: input.limit } : {}), | ||
| 96 | }) | ||
| 97 | .pipe( | ||
| 98 | Effect.map((items) => items.map(toChangeRequest)), | ||
| 99 | Effect.mapError((error) => providerError("listChangeRequests", error)), | ||
| 100 | ); | ||
| 101 | }, | ||
| 102 | getChangeRequest: (input) => | ||
| 103 | azure.getPullRequest(input).pipe( | ||
| 104 | Effect.map(toChangeRequest), | ||
| 105 | Effect.mapError((error) => providerError("getChangeRequest", error)), | ||
| 106 | ), | ||
| 107 | createChangeRequest: (input) => { | ||
| 108 | const source = SourceControlProvider.sourceControlRefFromInput(input); | ||
| 109 | return azure | ||
| 110 | .createPullRequest({ | ||
| 111 | cwd: input.cwd, | ||
| 112 | baseBranch: input.baseRefName, | ||
| 113 | headSelector: input.headSelector, | ||
| 114 | ...(source ? { source } : {}), | ||
| 115 | ...(input.target ? { target: input.target } : {}), | ||
| 116 | title: input.title, | ||
| 117 | bodyFile: input.bodyFile, | ||
| 118 | }) | ||
| 119 | .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); | ||
| 120 | }, | ||
| 121 | getRepositoryCloneUrls: (input) => | ||
| 122 | azure | ||
| 123 | .getRepositoryCloneUrls(input) | ||
| 124 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), | ||
| 125 | createRepository: (input) => | ||
| 126 | azure | ||
| 127 | .createRepository(input) | ||
| 128 | .pipe(Effect.mapError((error) => providerError("createRepository", error))), | ||
| 129 | getDefaultBranch: (input) => | ||
| 130 | azure | ||
| 131 | .getDefaultBranch({ cwd: input.cwd }) | ||
| 132 | .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), | ||
| 133 | checkoutChangeRequest: (input) => | ||
| 134 | azure | ||
| 135 | .checkoutPullRequest({ | ||
| 136 | cwd: input.cwd, | ||
| 137 | reference: input.reference, | ||
| 138 | ...(input.context ? { remoteName: input.context.remoteName } : {}), | ||
| 139 | }) | ||
| 140 | .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), | ||
| 141 | }); | ||
| 142 | }); | ||
| 143 | |||
| 144 | export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); | ||
apps/server/src/sourceControl/BitbucketApi.test.ts created+513| ... | @@ -0,0 +1,513 @@ | ||
| 1 | import { assert, it, vi } from "@effect/vitest"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { ConfigProvider, DateTime, Effect, FileSystem, Layer, Option } from "effect"; | ||
| 4 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 5 | |||
| 6 | import * as BitbucketApi from "./BitbucketApi.ts"; | ||
| 7 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | ||
| 8 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | ||
| 9 | import type * as VcsDriver from "../vcs/VcsDriver.ts"; | ||
| 10 | |||
| 11 | const bitbucketPullRequest = { | ||
| 12 | id: 42, | ||
| 13 | title: "Add Bitbucket provider", | ||
| 14 | state: "OPEN", | ||
| 15 | updated_on: "2026-01-02T00:00:00.000Z", | ||
| 16 | links: { | ||
| 17 | html: { | ||
| 18 | href: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", | ||
| 19 | }, | ||
| 20 | }, | ||
| 21 | source: { | ||
| 22 | branch: { name: "feature/source-control" }, | ||
| 23 | repository: { | ||
| 24 | full_name: "octocat/t3code", | ||
| 25 | workspace: { slug: "octocat" }, | ||
| 26 | }, | ||
| 27 | }, | ||
| 28 | destination: { | ||
| 29 | branch: { name: "main" }, | ||
| 30 | repository: { | ||
| 31 | full_name: "pingdotgg/t3code", | ||
| 32 | workspace: { slug: "pingdotgg" }, | ||
| 33 | }, | ||
| 34 | }, | ||
| 35 | }; | ||
| 36 | |||
| 37 | const repositoryJson = { | ||
| 38 | full_name: "pingdotgg/t3code", | ||
| 39 | links: { | ||
| 40 | html: { href: "https://bitbucket.org/pingdotgg/t3code" }, | ||
| 41 | clone: [ | ||
| 42 | { name: "https", href: "https://bitbucket.org/pingdotgg/t3code.git" }, | ||
| 43 | { name: "ssh", href: "git@bitbucket.org:pingdotgg/t3code.git" }, | ||
| 44 | ], | ||
| 45 | }, | ||
| 46 | mainbranch: { name: "main" }, | ||
| 47 | }; | ||
| 48 | |||
| 49 | function makeLayer(input: { | ||
| 50 | readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; | ||
| 51 | readonly git?: Partial<GitVcsDriver.GitVcsDriverShape>; | ||
| 52 | }) { | ||
| 53 | const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => | ||
| 54 | Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), | ||
| 55 | ); | ||
| 56 | const gitMock = { | ||
| 57 | readConfigValue: vi.fn<GitVcsDriver.GitVcsDriverShape["readConfigValue"]>(() => | ||
| 58 | Effect.succeed<string | null>("git@bitbucket.org:pingdotgg/t3code.git"), | ||
| 59 | ), | ||
| 60 | resolvePrimaryRemoteName: vi.fn<GitVcsDriver.GitVcsDriverShape["resolvePrimaryRemoteName"]>( | ||
| 61 | () => Effect.succeed("origin"), | ||
| 62 | ), | ||
| 63 | ensureRemote: vi.fn<GitVcsDriver.GitVcsDriverShape["ensureRemote"]>(() => | ||
| 64 | Effect.succeed("octocat"), | ||
| 65 | ), | ||
| 66 | fetchRemoteBranch: vi.fn<GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"]>( | ||
| 67 | () => Effect.void, | ||
| 68 | ), | ||
| 69 | fetchRemoteTrackingBranch: vi.fn<GitVcsDriver.GitVcsDriverShape["fetchRemoteTrackingBranch"]>( | ||
| 70 | () => Effect.void, | ||
| 71 | ), | ||
| 72 | setBranchUpstream: vi.fn<GitVcsDriver.GitVcsDriverShape["setBranchUpstream"]>( | ||
| 73 | () => Effect.void, | ||
| 74 | ), | ||
| 75 | switchRef: vi.fn<GitVcsDriver.GitVcsDriverShape["switchRef"]>((request) => | ||
| 76 | Effect.succeed({ refName: request.refName }), | ||
| 77 | ), | ||
| 78 | listLocalBranchNames: vi.fn<GitVcsDriver.GitVcsDriverShape["listLocalBranchNames"]>(() => | ||
| 79 | Effect.succeed([]), | ||
| 80 | ), | ||
| 81 | }; | ||
| 82 | const git = { | ||
| 83 | ...gitMock, | ||
| 84 | ...input.git, | ||
| 85 | } satisfies Partial<GitVcsDriver.GitVcsDriverShape>; | ||
| 86 | |||
| 87 | const driver = { | ||
| 88 | listRemotes: () => | ||
| 89 | Effect.succeed({ | ||
| 90 | remotes: [ | ||
| 91 | { | ||
| 92 | name: "origin", | ||
| 93 | url: "git@bitbucket.org:pingdotgg/t3code.git", | ||
| 94 | pushUrl: Option.none(), | ||
| 95 | isPrimary: true, | ||
| 96 | }, | ||
| 97 | ], | ||
| 98 | freshness: { | ||
| 99 | source: "live-local" as const, | ||
| 100 | observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), | ||
| 101 | expiresAt: Option.none(), | ||
| 102 | }, | ||
| 103 | }), | ||
| 104 | } satisfies Partial<VcsDriver.VcsDriverShape>; | ||
| 105 | |||
| 106 | const layer = BitbucketApi.layer.pipe( | ||
| 107 | Layer.provide( | ||
| 108 | Layer.succeed( | ||
| 109 | HttpClient.HttpClient, | ||
| 110 | HttpClient.make((request) => execute(request)), | ||
| 111 | ), | ||
| 112 | ), | ||
| 113 | Layer.provide( | ||
| 114 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ | ||
| 115 | resolve: () => | ||
| 116 | Effect.succeed({ | ||
| 117 | kind: "git", | ||
| 118 | repository: { | ||
| 119 | kind: "git", | ||
| 120 | rootPath: "/repo", | ||
| 121 | metadataPath: null, | ||
| 122 | freshness: { | ||
| 123 | source: "live-local" as const, | ||
| 124 | observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), | ||
| 125 | expiresAt: Option.none(), | ||
| 126 | }, | ||
| 127 | }, | ||
| 128 | driver: driver as unknown as VcsDriver.VcsDriverShape, | ||
| 129 | }), | ||
| 130 | }), | ||
| 131 | ), | ||
| 132 | Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(git)), | ||
| 133 | Layer.provide( | ||
| 134 | ConfigProvider.layer( | ||
| 135 | ConfigProvider.fromEnv({ | ||
| 136 | env: { | ||
| 137 | T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0", | ||
| 138 | T3CODE_BITBUCKET_EMAIL: "user@example.com", | ||
| 139 | T3CODE_BITBUCKET_API_TOKEN: "token", | ||
| 140 | }, | ||
| 141 | }), | ||
| 142 | ), | ||
| 143 | ), | ||
| 144 | Layer.provideMerge(NodeServices.layer), | ||
| 145 | ); | ||
| 146 | |||
| 147 | return { execute, git: gitMock, layer }; | ||
| 148 | } | ||
| 149 | |||
| 150 | it.effect("parses pull request responses from the Bitbucket REST API", () => { | ||
| 151 | const { execute, layer } = makeLayer({ | ||
| 152 | response: () => | ||
| 153 | Response.json({ | ||
| 154 | ...bitbucketPullRequest, | ||
| 155 | }), | ||
| 156 | }); | ||
| 157 | |||
| 158 | return Effect.gen(function* () { | ||
| 159 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 160 | const result = yield* bitbucket.getPullRequest({ | ||
| 161 | cwd: "/repo", | ||
| 162 | reference: "#42", | ||
| 163 | }); | ||
| 164 | |||
| 165 | assert.deepStrictEqual(result, { | ||
| 166 | number: 42, | ||
| 167 | title: "Add Bitbucket provider", | ||
| 168 | url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", | ||
| 169 | baseRefName: "main", | ||
| 170 | headRefName: "feature/source-control", | ||
| 171 | state: "open", | ||
| 172 | updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), | ||
| 173 | isCrossRepository: true, | ||
| 174 | headRepositoryNameWithOwner: "octocat/t3code", | ||
| 175 | headRepositoryOwnerLogin: "octocat", | ||
| 176 | }); | ||
| 177 | assert.strictEqual( | ||
| 178 | execute.mock.calls[0]?.[0].url, | ||
| 179 | "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests/42", | ||
| 180 | ); | ||
| 181 | }).pipe(Effect.provide(layer)); | ||
| 182 | }); | ||
| 183 | |||
| 184 | it.effect("lists pull requests with Bitbucket state and source branch query params", () => { | ||
| 185 | const { execute, layer } = makeLayer({ | ||
| 186 | response: () => | ||
| 187 | Response.json({ | ||
| 188 | values: [ | ||
| 189 | { | ||
| 190 | ...bitbucketPullRequest, | ||
| 191 | id: 7, | ||
| 192 | state: "MERGED", | ||
| 193 | source: { | ||
| 194 | branch: { name: "feature/merged" }, | ||
| 195 | repository: { full_name: "pingdotgg/t3code" }, | ||
| 196 | }, | ||
| 197 | }, | ||
| 198 | ], | ||
| 199 | }), | ||
| 200 | }); | ||
| 201 | |||
| 202 | return Effect.gen(function* () { | ||
| 203 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 204 | const result = yield* bitbucket.listPullRequests({ | ||
| 205 | cwd: "/repo", | ||
| 206 | headSelector: "origin:feature/merged", | ||
| 207 | state: "merged", | ||
| 208 | limit: 10, | ||
| 209 | }); | ||
| 210 | |||
| 211 | assert.strictEqual(result[0]?.state, "merged"); | ||
| 212 | const request = execute.mock.calls[0]?.[0]; | ||
| 213 | assert.strictEqual( | ||
| 214 | request?.url, | ||
| 215 | "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests", | ||
| 216 | ); | ||
| 217 | assert.deepStrictEqual(request?.urlParams.params, [ | ||
| 218 | ["pagelen", "10"], | ||
| 219 | ["sort", "-updated_on"], | ||
| 220 | ["q", 'source.branch.name = "feature/merged" AND state = "MERGED"'], | ||
| 221 | ["state", "MERGED"], | ||
| 222 | ]); | ||
| 223 | }).pipe(Effect.provide(layer)); | ||
| 224 | }); | ||
| 225 | |||
| 226 | it.effect("lists closed pull requests with both closed Bitbucket states", () => { | ||
| 227 | const { execute, layer } = makeLayer({ | ||
| 228 | response: () => | ||
| 229 | Response.json({ | ||
| 230 | values: [], | ||
| 231 | }), | ||
| 232 | }); | ||
| 233 | |||
| 234 | return Effect.gen(function* () { | ||
| 235 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 236 | yield* bitbucket.listPullRequests({ | ||
| 237 | cwd: "/repo", | ||
| 238 | headSelector: "feature/closed", | ||
| 239 | state: "closed", | ||
| 240 | limit: 10, | ||
| 241 | }); | ||
| 242 | |||
| 243 | assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [ | ||
| 244 | ["pagelen", "10"], | ||
| 245 | ["sort", "-updated_on"], | ||
| 246 | [ | ||
| 247 | "q", | ||
| 248 | 'source.branch.name = "feature/closed" AND (state = "DECLINED" OR state = "SUPERSEDED")', | ||
| 249 | ], | ||
| 250 | ["state", "DECLINED"], | ||
| 251 | ["state", "SUPERSEDED"], | ||
| 252 | ]); | ||
| 253 | }).pipe(Effect.provide(layer)); | ||
| 254 | }); | ||
| 255 | |||
| 256 | it.effect("expands all-state pull request listing instead of relying on Bitbucket defaults", () => { | ||
| 257 | const { execute, layer } = makeLayer({ | ||
| 258 | response: () => | ||
| 259 | Response.json({ | ||
| 260 | values: [], | ||
| 261 | }), | ||
| 262 | }); | ||
| 263 | |||
| 264 | return Effect.gen(function* () { | ||
| 265 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 266 | yield* bitbucket.listPullRequests({ | ||
| 267 | cwd: "/repo", | ||
| 268 | headSelector: "feature/all", | ||
| 269 | state: "all", | ||
| 270 | limit: 10, | ||
| 271 | }); | ||
| 272 | |||
| 273 | assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [ | ||
| 274 | ["pagelen", "10"], | ||
| 275 | ["sort", "-updated_on"], | ||
| 276 | [ | ||
| 277 | "q", | ||
| 278 | 'source.branch.name = "feature/all" AND (state = "OPEN" OR state = "MERGED" OR state = "DECLINED" OR state = "SUPERSEDED")', | ||
| 279 | ], | ||
| 280 | ["state", "OPEN"], | ||
| 281 | ["state", "MERGED"], | ||
| 282 | ["state", "DECLINED"], | ||
| 283 | ["state", "SUPERSEDED"], | ||
| 284 | ]); | ||
| 285 | }).pipe(Effect.provide(layer)); | ||
| 286 | }); | ||
| 287 | |||
| 288 | it.effect("reads repository clone URLs and default branch", () => { | ||
| 289 | const { layer } = makeLayer({ | ||
| 290 | response: () => Response.json(repositoryJson), | ||
| 291 | }); | ||
| 292 | |||
| 293 | return Effect.gen(function* () { | ||
| 294 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 295 | const cloneUrls = yield* bitbucket.getRepositoryCloneUrls({ | ||
| 296 | cwd: "/repo", | ||
| 297 | repository: "pingdotgg/t3code", | ||
| 298 | }); | ||
| 299 | const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" }); | ||
| 300 | |||
| 301 | assert.deepStrictEqual(cloneUrls, { | ||
| 302 | nameWithOwner: "pingdotgg/t3code", | ||
| 303 | url: "https://bitbucket.org/pingdotgg/t3code.git", | ||
| 304 | sshUrl: "git@bitbucket.org:pingdotgg/t3code.git", | ||
| 305 | }); | ||
| 306 | assert.strictEqual(defaultBranch, "main"); | ||
| 307 | }).pipe(Effect.provide(layer)); | ||
| 308 | }); | ||
| 309 | |||
| 310 | it.effect("creates repositories through the Bitbucket REST API", () => { | ||
| 311 | const { execute, layer } = makeLayer({ | ||
| 312 | response: () => Response.json(repositoryJson), | ||
| 313 | }); | ||
| 314 | |||
| 315 | return Effect.gen(function* () { | ||
| 316 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 317 | const cloneUrls = yield* bitbucket.createRepository({ | ||
| 318 | cwd: "/repo", | ||
| 319 | repository: "pingdotgg/t3code", | ||
| 320 | visibility: "private", | ||
| 321 | }); | ||
| 322 | |||
| 323 | assert.deepStrictEqual(cloneUrls, { | ||
| 324 | nameWithOwner: "pingdotgg/t3code", | ||
| 325 | url: "https://bitbucket.org/pingdotgg/t3code.git", | ||
| 326 | sshUrl: "git@bitbucket.org:pingdotgg/t3code.git", | ||
| 327 | }); | ||
| 328 | |||
| 329 | const request = execute.mock.calls[0]?.[0]; | ||
| 330 | assert.strictEqual(request?.url, "https://api.test.local/2.0/repositories/pingdotgg/t3code"); | ||
| 331 | assert.strictEqual(request?.method, "POST"); | ||
| 332 | assert.ok(request); | ||
| 333 | const rawBody = (request.body as { readonly body?: Uint8Array }).body; | ||
| 334 | assert.ok(rawBody); | ||
| 335 | assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), { | ||
| 336 | scm: "git", | ||
| 337 | is_private: true, | ||
| 338 | }); | ||
| 339 | }).pipe(Effect.provide(layer)); | ||
| 340 | }); | ||
| 341 | |||
| 342 | it.effect("creates pull requests using the official REST payload shape", () => { | ||
| 343 | const { execute, layer } = makeLayer({ | ||
| 344 | response: () => Response.json(bitbucketPullRequest), | ||
| 345 | }); | ||
| 346 | |||
| 347 | return Effect.gen(function* () { | ||
| 348 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 349 | const bodyFile = yield* fileSystem.makeTempFileScoped({ prefix: "bitbucket-pr-body-" }); | ||
| 350 | yield* fileSystem.writeFileString(bodyFile, "PR body"); | ||
| 351 | |||
| 352 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 353 | yield* bitbucket.createPullRequest({ | ||
| 354 | cwd: "/repo", | ||
| 355 | baseBranch: "main", | ||
| 356 | headSelector: "owner:feature/provider", | ||
| 357 | title: "Provider PR", | ||
| 358 | bodyFile, | ||
| 359 | }); | ||
| 360 | |||
| 361 | const request = execute.mock.calls[0]?.[0]; | ||
| 362 | assert.strictEqual( | ||
| 363 | request?.url, | ||
| 364 | "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests", | ||
| 365 | ); | ||
| 366 | assert.strictEqual(request?.method, "POST"); | ||
| 367 | assert.ok(request); | ||
| 368 | const rawBody = (request.body as { readonly body?: Uint8Array }).body; | ||
| 369 | assert.ok(rawBody); | ||
| 370 | assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), { | ||
| 371 | title: "Provider PR", | ||
| 372 | description: "PR body", | ||
| 373 | source: { | ||
| 374 | branch: { name: "feature/provider" }, | ||
| 375 | repository: { full_name: "owner/t3code" }, | ||
| 376 | }, | ||
| 377 | destination: { | ||
| 378 | branch: { name: "main" }, | ||
| 379 | }, | ||
| 380 | }); | ||
| 381 | }).pipe(Effect.provide(layer), Effect.scoped); | ||
| 382 | }); | ||
| 383 | |||
| 384 | it.effect("reports auth status through the Bitbucket REST /user endpoint", () => { | ||
| 385 | const { layer } = makeLayer({ | ||
| 386 | response: () => Response.json({ username: "bitbucket-user" }), | ||
| 387 | }); | ||
| 388 | |||
| 389 | return Effect.gen(function* () { | ||
| 390 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 391 | const auth = yield* bitbucket.probeAuth; | ||
| 392 | |||
| 393 | assert.deepStrictEqual(auth, { | ||
| 394 | status: "authenticated", | ||
| 395 | account: Option.some("bitbucket-user"), | ||
| 396 | host: Option.some("bitbucket.org"), | ||
| 397 | detail: Option.none(), | ||
| 398 | }); | ||
| 399 | }).pipe(Effect.provide(layer)); | ||
| 400 | }); | ||
| 401 | |||
| 402 | it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { | ||
| 403 | const { git, layer } = makeLayer({ | ||
| 404 | response: () => | ||
| 405 | Response.json({ | ||
| 406 | ...bitbucketPullRequest, | ||
| 407 | source: { | ||
| 408 | branch: { name: "feature/source-control" }, | ||
| 409 | repository: { | ||
| 410 | full_name: "pingdotgg/t3code", | ||
| 411 | workspace: { slug: "pingdotgg" }, | ||
| 412 | }, | ||
| 413 | }, | ||
| 414 | }), | ||
| 415 | }); | ||
| 416 | |||
| 417 | return Effect.gen(function* () { | ||
| 418 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 419 | yield* bitbucket.checkoutPullRequest({ | ||
| 420 | cwd: "/repo", | ||
| 421 | context: { | ||
| 422 | provider: { | ||
| 423 | kind: "bitbucket", | ||
| 424 | name: "Bitbucket", | ||
| 425 | baseUrl: "https://bitbucket.org", | ||
| 426 | }, | ||
| 427 | remoteName: "origin", | ||
| 428 | remoteUrl: "git@bitbucket.org:pingdotgg/t3code.git", | ||
| 429 | }, | ||
| 430 | reference: "42", | ||
| 431 | force: true, | ||
| 432 | }); | ||
| 433 | |||
| 434 | assert.strictEqual(git.ensureRemote.mock.calls.length, 0); | ||
| 435 | assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], { | ||
| 436 | cwd: "/repo", | ||
| 437 | remoteName: "origin", | ||
| 438 | remoteBranch: "feature/source-control", | ||
| 439 | localBranch: "feature/source-control", | ||
| 440 | }); | ||
| 441 | assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], { | ||
| 442 | cwd: "/repo", | ||
| 443 | branch: "feature/source-control", | ||
| 444 | remoteName: "origin", | ||
| 445 | remoteBranch: "feature/source-control", | ||
| 446 | }); | ||
| 447 | assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], { | ||
| 448 | cwd: "/repo", | ||
| 449 | refName: "feature/source-control", | ||
| 450 | }); | ||
| 451 | }).pipe(Effect.provide(layer)); | ||
| 452 | }); | ||
| 453 | |||
| 454 | it.effect("checks out fork pull requests through an ensured fork remote", () => { | ||
| 455 | const { git, layer } = makeLayer({ | ||
| 456 | response: (request) => { | ||
| 457 | if (request.url.endsWith("/repositories/octocat/t3code")) { | ||
| 458 | return Response.json({ | ||
| 459 | ...repositoryJson, | ||
| 460 | full_name: "octocat/t3code", | ||
| 461 | links: { | ||
| 462 | html: { href: "https://bitbucket.org/octocat/t3code" }, | ||
| 463 | clone: [ | ||
| 464 | { name: "https", href: "https://bitbucket.org/octocat/t3code.git" }, | ||
| 465 | { name: "ssh", href: "git@bitbucket.org:octocat/t3code.git" }, | ||
| 466 | ], | ||
| 467 | }, | ||
| 468 | }); | ||
| 469 | } | ||
| 470 | return Response.json({ | ||
| 471 | ...bitbucketPullRequest, | ||
| 472 | source: { | ||
| 473 | branch: { name: "main" }, | ||
| 474 | repository: { | ||
| 475 | full_name: "octocat/t3code", | ||
| 476 | workspace: { slug: "octocat" }, | ||
| 477 | }, | ||
| 478 | }, | ||
| 479 | }); | ||
| 480 | }, | ||
| 481 | }); | ||
| 482 | |||
| 483 | return Effect.gen(function* () { | ||
| 484 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 485 | yield* bitbucket.checkoutPullRequest({ | ||
| 486 | cwd: "/repo", | ||
| 487 | reference: "42", | ||
| 488 | force: true, | ||
| 489 | }); | ||
| 490 | |||
| 491 | assert.deepStrictEqual(git.ensureRemote.mock.calls[0]?.[0], { | ||
| 492 | cwd: "/repo", | ||
| 493 | preferredName: "octocat", | ||
| 494 | url: "git@bitbucket.org:octocat/t3code.git", | ||
| 495 | }); | ||
| 496 | assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], { | ||
| 497 | cwd: "/repo", | ||
| 498 | remoteName: "octocat", | ||
| 499 | remoteBranch: "main", | ||
| 500 | localBranch: "t3code/pr-42/main", | ||
| 501 | }); | ||
| 502 | assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], { | ||
| 503 | cwd: "/repo", | ||
| 504 | branch: "t3code/pr-42/main", | ||
| 505 | remoteName: "octocat", | ||
| 506 | remoteBranch: "main", | ||
| 507 | }); | ||
| 508 | assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], { | ||
| 509 | cwd: "/repo", | ||
| 510 | refName: "t3code/pr-42/main", | ||
| 511 | }); | ||
| 512 | }).pipe(Effect.provide(layer)); | ||
| 513 | }); | ||
apps/server/src/sourceControl/BitbucketApi.ts created+703| ... | @@ -0,0 +1,703 @@ | ||
| 1 | import { Config, Context, Effect, FileSystem, Layer, Option, Schema } from "effect"; | ||
| 2 | import { | ||
| 3 | TrimmedNonEmptyString, | ||
| 4 | type SourceControlProviderAuth, | ||
| 5 | type SourceControlRepositoryCloneUrls, | ||
| 6 | type SourceControlRepositoryVisibility, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 9 | import { sanitizeBranchFragment } from "@t3tools/shared/git"; | ||
| 10 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; | ||
| 11 | |||
| 12 | import * as BitbucketPullRequests from "./bitbucketPullRequests.ts"; | ||
| 13 | import * as SourceControlProvider from "./SourceControlProvider.ts"; | ||
| 14 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | ||
| 15 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | ||
| 16 | |||
| 17 | const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; | ||
| 18 | |||
| 19 | const BitbucketApiEnvConfig = Config.all({ | ||
| 20 | baseUrl: Config.string("T3CODE_BITBUCKET_API_BASE_URL").pipe( | ||
| 21 | Config.withDefault(DEFAULT_API_BASE_URL), | ||
| 22 | ), | ||
| 23 | accessToken: Config.string("T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option), | ||
| 24 | email: Config.string("T3CODE_BITBUCKET_EMAIL").pipe(Config.option), | ||
| 25 | apiToken: Config.string("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), | ||
| 26 | }); | ||
| 27 | |||
| 28 | export class BitbucketApiError extends Schema.TaggedErrorClass<BitbucketApiError>()( | ||
| 29 | "BitbucketApiError", | ||
| 30 | { | ||
| 31 | operation: Schema.String, | ||
| 32 | detail: Schema.String, | ||
| 33 | status: Schema.optional(Schema.Number), | ||
| 34 | cause: Schema.optional(Schema.Defect), | ||
| 35 | }, | ||
| 36 | ) { | ||
| 37 | override get message(): string { | ||
| 38 | return `Bitbucket API failed in ${this.operation}: ${this.detail}`; | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | const RawBitbucketRepositorySchema = Schema.Struct({ | ||
| 43 | full_name: TrimmedNonEmptyString, | ||
| 44 | links: Schema.Struct({ | ||
| 45 | html: Schema.optional( | ||
| 46 | Schema.Struct({ | ||
| 47 | href: TrimmedNonEmptyString, | ||
| 48 | }), | ||
| 49 | ), | ||
| 50 | clone: Schema.optional( | ||
| 51 | Schema.Array( | ||
| 52 | Schema.Struct({ | ||
| 53 | name: TrimmedNonEmptyString, | ||
| 54 | href: TrimmedNonEmptyString, | ||
| 55 | }), | ||
| 56 | ), | ||
| 57 | ), | ||
| 58 | }), | ||
| 59 | mainbranch: Schema.optional( | ||
| 60 | Schema.NullOr( | ||
| 61 | Schema.Struct({ | ||
| 62 | name: TrimmedNonEmptyString, | ||
| 63 | }), | ||
| 64 | ), | ||
| 65 | ), | ||
| 66 | }); | ||
| 67 | |||
| 68 | const BitbucketUserSchema = Schema.Struct({ | ||
| 69 | username: Schema.optional(TrimmedNonEmptyString), | ||
| 70 | display_name: Schema.optional(TrimmedNonEmptyString), | ||
| 71 | account_id: Schema.optional(TrimmedNonEmptyString), | ||
| 72 | }); | ||
| 73 | |||
| 74 | export interface BitbucketRepositoryLocator { | ||
| 75 | readonly workspace: string; | ||
| 76 | readonly repoSlug: string; | ||
| 77 | } | ||
| 78 | |||
| 79 | export interface BitbucketApiShape { | ||
| 80 | readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>; | ||
| 81 | readonly listPullRequests: (input: { | ||
| 82 | readonly cwd: string; | ||
| 83 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 84 | readonly headSelector: string; | ||
| 85 | readonly source?: SourceControlProvider.SourceControlRefSelector; | ||
| 86 | readonly state: "open" | "closed" | "merged" | "all"; | ||
| 87 | readonly limit?: number; | ||
| 88 | }) => Effect.Effect< | ||
| 89 | ReadonlyArray<BitbucketPullRequests.NormalizedBitbucketPullRequestRecord>, | ||
| 90 | BitbucketApiError | ||
| 91 | >; | ||
| 92 | readonly getPullRequest: (input: { | ||
| 93 | readonly cwd: string; | ||
| 94 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 95 | readonly reference: string; | ||
| 96 | }) => Effect.Effect< | ||
| 97 | BitbucketPullRequests.NormalizedBitbucketPullRequestRecord, | ||
| 98 | BitbucketApiError | ||
| 99 | >; | ||
| 100 | readonly getRepositoryCloneUrls: (input: { | ||
| 101 | readonly cwd: string; | ||
| 102 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 103 | readonly repository: string; | ||
| 104 | }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>; | ||
| 105 | readonly createRepository: (input: { | ||
| 106 | readonly cwd: string; | ||
| 107 | readonly repository: string; | ||
| 108 | readonly visibility: SourceControlRepositoryVisibility; | ||
| 109 | }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>; | ||
| 110 | readonly createPullRequest: (input: { | ||
| 111 | readonly cwd: string; | ||
| 112 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 113 | readonly baseBranch: string; | ||
| 114 | readonly headSelector: string; | ||
| 115 | readonly source?: SourceControlProvider.SourceControlRefSelector; | ||
| 116 | readonly target?: SourceControlProvider.SourceControlRefSelector; | ||
| 117 | readonly title: string; | ||
| 118 | readonly bodyFile: string; | ||
| 119 | }) => Effect.Effect<void, BitbucketApiError>; | ||
| 120 | readonly getDefaultBranch: (input: { | ||
| 121 | readonly cwd: string; | ||
| 122 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 123 | }) => Effect.Effect<string | null, BitbucketApiError>; | ||
| 124 | readonly checkoutPullRequest: (input: { | ||
| 125 | readonly cwd: string; | ||
| 126 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 127 | readonly reference: string; | ||
| 128 | readonly force?: boolean; | ||
| 129 | }) => Effect.Effect<void, BitbucketApiError>; | ||
| 130 | } | ||
| 131 | |||
| 132 | export class BitbucketApi extends Context.Service<BitbucketApi, BitbucketApiShape>()( | ||
| 133 | "t3/source-control/BitbucketApi", | ||
| 134 | ) {} | ||
| 135 | |||
| 136 | function nonEmpty(value: string | undefined): Option.Option<string> { | ||
| 137 | const trimmed = value?.trim(); | ||
| 138 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | ||
| 139 | } | ||
| 140 | |||
| 141 | function normalizeChangeRequestId(reference: string): string { | ||
| 142 | const trimmed = reference.trim().replace(/^#/, ""); | ||
| 143 | const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec( | ||
| 144 | trimmed, | ||
| 145 | ); | ||
| 146 | return urlMatch?.[1] ?? trimmed; | ||
| 147 | } | ||
| 148 | |||
| 149 | function sourceWorkspace(input: { | ||
| 150 | readonly headSelector: string; | ||
| 151 | readonly source?: SourceControlProvider.SourceControlRefSelector; | ||
| 152 | }): string | undefined { | ||
| 153 | if (input.source?.owner) return input.source.owner; | ||
| 154 | return SourceControlProvider.parseSourceControlOwnerRef(input.headSelector)?.owner; | ||
| 155 | } | ||
| 156 | |||
| 157 | function toBitbucketStates(state: "open" | "closed" | "merged" | "all"): ReadonlyArray<string> { | ||
| 158 | switch (state) { | ||
| 159 | case "open": | ||
| 160 | return ["OPEN"]; | ||
| 161 | case "closed": | ||
| 162 | return ["DECLINED", "SUPERSEDED"]; | ||
| 163 | case "merged": | ||
| 164 | return ["MERGED"]; | ||
| 165 | case "all": | ||
| 166 | return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]; | ||
| 167 | } | ||
| 168 | } | ||
| 169 | |||
| 170 | function bitbucketQueryString(filters: ReadonlyArray<string>): string { | ||
| 171 | return filters.join(" AND "); | ||
| 172 | } | ||
| 173 | |||
| 174 | function bitbucketStateFilter(states: ReadonlyArray<string>): string { | ||
| 175 | return states.length === 1 | ||
| 176 | ? `state = "${states[0]}"` | ||
| 177 | : `(${states.map((state) => `state = "${state}"`).join(" OR ")})`; | ||
| 178 | } | ||
| 179 | |||
| 180 | function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator | null { | ||
| 181 | const normalized = value.trim().replace(/\.git$/u, ""); | ||
| 182 | const parts = normalized.split("/").filter((part) => part.length > 0); | ||
| 183 | if (parts.length < 2) return null; | ||
| 184 | const workspace = parts.at(-2); | ||
| 185 | const repoSlug = parts.at(-1); | ||
| 186 | return workspace && repoSlug ? { workspace, repoSlug } : null; | ||
| 187 | } | ||
| 188 | |||
| 189 | function requireRepositoryLocator( | ||
| 190 | operation: string, | ||
| 191 | repository: string, | ||
| 192 | ): Effect.Effect<BitbucketRepositoryLocator, BitbucketApiError> { | ||
| 193 | const locator = parseBitbucketRepositorySlug(repository); | ||
| 194 | return locator | ||
| 195 | ? Effect.succeed(locator) | ||
| 196 | : Effect.fail( | ||
| 197 | new BitbucketApiError({ | ||
| 198 | operation, | ||
| 199 | detail: "Bitbucket repositories must be specified as workspace/repository.", | ||
| 200 | }), | ||
| 201 | ); | ||
| 202 | } | ||
| 203 | |||
| 204 | function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null { | ||
| 205 | const trimmed = remoteUrl.trim(); | ||
| 206 | if (trimmed.startsWith("git@")) { | ||
| 207 | const pathStart = trimmed.indexOf(":"); | ||
| 208 | return pathStart < 0 ? null : parseBitbucketRepositorySlug(trimmed.slice(pathStart + 1)); | ||
| 209 | } | ||
| 210 | |||
| 211 | try { | ||
| 212 | return parseBitbucketRepositorySlug(new URL(trimmed).pathname); | ||
| 213 | } catch { | ||
| 214 | return null; | ||
| 215 | } | ||
| 216 | } | ||
| 217 | |||
| 218 | function normalizeRepositoryCloneUrls( | ||
| 219 | raw: Schema.Schema.Type<typeof RawBitbucketRepositorySchema>, | ||
| 220 | ): SourceControlRepositoryCloneUrls { | ||
| 221 | const httpClone = | ||
| 222 | raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ?? | ||
| 223 | raw.links.html?.href; | ||
| 224 | const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href; | ||
| 225 | |||
| 226 | return { | ||
| 227 | nameWithOwner: raw.full_name, | ||
| 228 | url: httpClone ?? raw.links.html?.href ?? raw.full_name, | ||
| 229 | sshUrl: sshClone ?? httpClone ?? raw.full_name, | ||
| 230 | }; | ||
| 231 | } | ||
| 232 | |||
| 233 | function shouldPreferSshRemote(originRemoteUrl: string | null): boolean { | ||
| 234 | const trimmed = originRemoteUrl?.trim() ?? ""; | ||
| 235 | return trimmed.startsWith("git@") || trimmed.startsWith("ssh://"); | ||
| 236 | } | ||
| 237 | |||
| 238 | function selectCloneUrl(input: { | ||
| 239 | readonly cloneUrls: SourceControlRepositoryCloneUrls; | ||
| 240 | readonly originRemoteUrl: string | null; | ||
| 241 | }): string { | ||
| 242 | return shouldPreferSshRemote(input.originRemoteUrl) | ||
| 243 | ? input.cloneUrls.sshUrl | ||
| 244 | : input.cloneUrls.url; | ||
| 245 | } | ||
| 246 | |||
| 247 | function checkoutBranchName(input: { | ||
| 248 | readonly pullRequestId: number; | ||
| 249 | readonly headBranch: string; | ||
| 250 | readonly isCrossRepository: boolean; | ||
| 251 | }): string { | ||
| 252 | if (!input.isCrossRepository) { | ||
| 253 | return input.headBranch; | ||
| 254 | } | ||
| 255 | |||
| 256 | return `t3code/pr-${input.pullRequestId}/${sanitizeBranchFragment(input.headBranch)}`; | ||
| 257 | } | ||
| 258 | |||
| 259 | function repositoryNameWithOwner( | ||
| 260 | repository: Schema.Schema.Type< | ||
| 261 | typeof BitbucketPullRequests.BitbucketPullRequestSchema | ||
| 262 | >["source"]["repository"], | ||
| 263 | ): string | null { | ||
| 264 | const fullName = repository?.full_name?.trim() ?? ""; | ||
| 265 | return fullName.length > 0 ? fullName : null; | ||
| 266 | } | ||
| 267 | |||
| 268 | function repositoryOwnerName(repositoryName: string): string { | ||
| 269 | return repositoryName.split("/")[0]?.trim() || "bitbucket"; | ||
| 270 | } | ||
| 271 | |||
| 272 | function authFromConfig( | ||
| 273 | config: Config.Success<typeof BitbucketApiEnvConfig>, | ||
| 274 | ): SourceControlProviderAuth { | ||
| 275 | if (Option.isSome(config.accessToken)) { | ||
| 276 | return { | ||
| 277 | status: "unknown", | ||
| 278 | account: Option.none(), | ||
| 279 | host: Option.some("bitbucket.org"), | ||
| 280 | detail: Option.some("Bitbucket access token is configured."), | ||
| 281 | }; | ||
| 282 | } | ||
| 283 | |||
| 284 | if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { | ||
| 285 | return { | ||
| 286 | status: "unknown", | ||
| 287 | account: config.email, | ||
| 288 | host: Option.some("bitbucket.org"), | ||
| 289 | detail: Option.some("Bitbucket API token is configured."), | ||
| 290 | }; | ||
| 291 | } | ||
| 292 | |||
| 293 | return { | ||
| 294 | status: "unauthenticated", | ||
| 295 | account: Option.none(), | ||
| 296 | host: Option.some("bitbucket.org"), | ||
| 297 | detail: Option.some( | ||
| 298 | "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", | ||
| 299 | ), | ||
| 300 | }; | ||
| 301 | } | ||
| 302 | |||
| 303 | function requestError(operation: string, cause: unknown): BitbucketApiError { | ||
| 304 | return new BitbucketApiError({ | ||
| 305 | operation, | ||
| 306 | detail: cause instanceof Error ? cause.message : String(cause), | ||
| 307 | cause, | ||
| 308 | }); | ||
| 309 | } | ||
| 310 | |||
| 311 | function isBitbucketApiError(cause: unknown): cause is BitbucketApiError { | ||
| 312 | return Schema.is(BitbucketApiError)(cause); | ||
| 313 | } | ||
| 314 | |||
| 315 | function responseError( | ||
| 316 | operation: string, | ||
| 317 | response: HttpClientResponse.HttpClientResponse, | ||
| 318 | ): Effect.Effect<never, BitbucketApiError> { | ||
| 319 | return response.text.pipe( | ||
| 320 | Effect.catch(() => Effect.succeed("")), | ||
| 321 | Effect.flatMap((body) => | ||
| 322 | Effect.fail( | ||
| 323 | new BitbucketApiError({ | ||
| 324 | operation, | ||
| 325 | status: response.status, | ||
| 326 | detail: | ||
| 327 | body.trim().length > 0 | ||
| 328 | ? `Bitbucket returned HTTP ${response.status}: ${body.trim()}` | ||
| 329 | : `Bitbucket returned HTTP ${response.status}.`, | ||
| 330 | }), | ||
| 331 | ), | ||
| 332 | ), | ||
| 333 | ); | ||
| 334 | } | ||
| 335 | |||
| 336 | export const make = Effect.fn("makeBitbucketApi")(function* () { | ||
| 337 | const config = yield* BitbucketApiEnvConfig; | ||
| 338 | const httpClient = yield* HttpClient.HttpClient; | ||
| 339 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 340 | const git = yield* GitVcsDriver.GitVcsDriver; | ||
| 341 | const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; | ||
| 342 | |||
| 343 | const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; | ||
| 344 | |||
| 345 | const withAuth = (request: HttpClientRequest.HttpClientRequest) => { | ||
| 346 | if (Option.isSome(config.accessToken)) { | ||
| 347 | return request.pipe(HttpClientRequest.bearerToken(config.accessToken.value)); | ||
| 348 | } | ||
| 349 | if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { | ||
| 350 | return request.pipe(HttpClientRequest.basicAuth(config.email.value, config.apiToken.value)); | ||
| 351 | } | ||
| 352 | return request; | ||
| 353 | }; | ||
| 354 | |||
| 355 | const decodeResponse = <S extends Schema.Top>( | ||
| 356 | operation: string, | ||
| 357 | schema: S, | ||
| 358 | response: HttpClientResponse.HttpClientResponse, | ||
| 359 | ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> => | ||
| 360 | HttpClientResponse.matchStatus({ | ||
| 361 | "2xx": (success) => | ||
| 362 | HttpClientResponse.schemaBodyJson(schema)(success).pipe( | ||
| 363 | Effect.mapError( | ||
| 364 | (cause) => | ||
| 365 | new BitbucketApiError({ | ||
| 366 | operation, | ||
| 367 | detail: "Bitbucket returned invalid JSON for the requested resource.", | ||
| 368 | cause, | ||
| 369 | }), | ||
| 370 | ), | ||
| 371 | ), | ||
| 372 | orElse: (failed) => responseError(operation, failed), | ||
| 373 | })(response); | ||
| 374 | |||
| 375 | const executeJson = <S extends Schema.Top>( | ||
| 376 | operation: string, | ||
| 377 | request: HttpClientRequest.HttpClientRequest, | ||
| 378 | schema: S, | ||
| 379 | ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> => | ||
| 380 | httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( | ||
| 381 | Effect.mapError((cause) => requestError(operation, cause)), | ||
| 382 | Effect.flatMap((response) => decodeResponse(operation, schema, response)), | ||
| 383 | ); | ||
| 384 | |||
| 385 | const resolveRepository = Effect.fn("BitbucketApi.resolveRepository")(function* (input: { | ||
| 386 | readonly cwd: string; | ||
| 387 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 388 | readonly repository?: string; | ||
| 389 | }) { | ||
| 390 | const fromRepository = | ||
| 391 | input.repository !== undefined ? parseBitbucketRepositorySlug(input.repository) : null; | ||
| 392 | if (fromRepository) return fromRepository; | ||
| 393 | |||
| 394 | const fromContext = | ||
| 395 | input.context?.provider.kind === "bitbucket" | ||
| 396 | ? parseBitbucketRemoteUrl(input.context.remoteUrl) | ||
| 397 | : null; | ||
| 398 | if (fromContext) return fromContext; | ||
| 399 | |||
| 400 | const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe( | ||
| 401 | Effect.mapError( | ||
| 402 | (cause) => | ||
| 403 | new BitbucketApiError({ | ||
| 404 | operation: "resolveRepository", | ||
| 405 | detail: `Failed to resolve VCS repository for ${input.cwd}.`, | ||
| 406 | cause, | ||
| 407 | }), | ||
| 408 | ), | ||
| 409 | ); | ||
| 410 | const remotes = yield* handle.driver.listRemotes(input.cwd).pipe( | ||
| 411 | Effect.mapError( | ||
| 412 | (cause) => | ||
| 413 | new BitbucketApiError({ | ||
| 414 | operation: "resolveRepository", | ||
| 415 | detail: `Failed to list remotes for ${input.cwd}.`, | ||
| 416 | cause, | ||
| 417 | }), | ||
| 418 | ), | ||
| 419 | ); | ||
| 420 | |||
| 421 | for (const remote of remotes.remotes) { | ||
| 422 | if (detectSourceControlProviderFromRemoteUrl(remote.url)?.kind !== "bitbucket") continue; | ||
| 423 | const parsed = parseBitbucketRemoteUrl(remote.url); | ||
| 424 | if (parsed) return parsed; | ||
| 425 | } | ||
| 426 | |||
| 427 | return yield* new BitbucketApiError({ | ||
| 428 | operation: "resolveRepository", | ||
| 429 | detail: `No Bitbucket repository remote was detected for ${input.cwd}.`, | ||
| 430 | }); | ||
| 431 | }); | ||
| 432 | |||
| 433 | const getRepository = (input: { | ||
| 434 | readonly cwd: string; | ||
| 435 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 436 | readonly repository?: string; | ||
| 437 | }) => | ||
| 438 | resolveRepository(input).pipe( | ||
| 439 | Effect.flatMap((repository) => | ||
| 440 | executeJson( | ||
| 441 | "getRepository", | ||
| 442 | HttpClientRequest.get( | ||
| 443 | apiUrl( | ||
| 444 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, | ||
| 445 | ), | ||
| 446 | ), | ||
| 447 | RawBitbucketRepositorySchema, | ||
| 448 | ), | ||
| 449 | ), | ||
| 450 | ); | ||
| 451 | |||
| 452 | const getRawPullRequestFromRepository = ( | ||
| 453 | repository: BitbucketRepositoryLocator, | ||
| 454 | reference: string, | ||
| 455 | ) => | ||
| 456 | executeJson( | ||
| 457 | "getPullRequest", | ||
| 458 | HttpClientRequest.get( | ||
| 459 | apiUrl( | ||
| 460 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`, | ||
| 461 | ), | ||
| 462 | ), | ||
| 463 | BitbucketPullRequests.BitbucketPullRequestSchema, | ||
| 464 | ); | ||
| 465 | |||
| 466 | const getRawPullRequest = (input: { | ||
| 467 | readonly cwd: string; | ||
| 468 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 469 | readonly reference: string; | ||
| 470 | }) => | ||
| 471 | resolveRepository(input).pipe( | ||
| 472 | Effect.flatMap((repository) => getRawPullRequestFromRepository(repository, input.reference)), | ||
| 473 | ); | ||
| 474 | |||
| 475 | const readConfigValueNullable = (cwd: string, key: string) => | ||
| 476 | git.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 477 | |||
| 478 | const resolveCheckoutRemote = Effect.fn("BitbucketApi.resolveCheckoutRemote")(function* (input: { | ||
| 479 | readonly cwd: string; | ||
| 480 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 481 | readonly destinationRepository: BitbucketRepositoryLocator; | ||
| 482 | readonly sourceRepositoryName: string; | ||
| 483 | readonly isCrossRepository: boolean; | ||
| 484 | }) { | ||
| 485 | if ( | ||
| 486 | input.context?.provider.kind === "bitbucket" && | ||
| 487 | !input.isCrossRepository && | ||
| 488 | parseBitbucketRemoteUrl(input.context.remoteUrl) !== null | ||
| 489 | ) { | ||
| 490 | return input.context.remoteName; | ||
| 491 | } | ||
| 492 | |||
| 493 | if (!input.isCrossRepository) { | ||
| 494 | const remoteName = yield* git | ||
| 495 | .resolvePrimaryRemoteName(input.cwd) | ||
| 496 | .pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 497 | if (remoteName) return remoteName; | ||
| 498 | } | ||
| 499 | |||
| 500 | const cloneUrls = yield* getRepository({ | ||
| 501 | cwd: input.cwd, | ||
| 502 | repository: input.sourceRepositoryName, | ||
| 503 | ...(input.context ? { context: input.context } : {}), | ||
| 504 | }).pipe(Effect.map(normalizeRepositoryCloneUrls)); | ||
| 505 | const originRemoteUrl = yield* readConfigValueNullable(input.cwd, "remote.origin.url"); | ||
| 506 | return yield* git.ensureRemote({ | ||
| 507 | cwd: input.cwd, | ||
| 508 | preferredName: input.isCrossRepository | ||
| 509 | ? repositoryOwnerName(input.sourceRepositoryName) | ||
| 510 | : input.destinationRepository.workspace, | ||
| 511 | url: selectCloneUrl({ cloneUrls, originRemoteUrl }), | ||
| 512 | }); | ||
| 513 | }); | ||
| 514 | |||
| 515 | return BitbucketApi.of({ | ||
| 516 | probeAuth: executeJson( | ||
| 517 | "probeAuth", | ||
| 518 | HttpClientRequest.get(apiUrl("/user")), | ||
| 519 | BitbucketUserSchema, | ||
| 520 | ).pipe( | ||
| 521 | Effect.map((user) => ({ | ||
| 522 | status: "authenticated" as const, | ||
| 523 | account: nonEmpty(user.username ?? user.display_name ?? user.account_id), | ||
| 524 | host: Option.some("bitbucket.org"), | ||
| 525 | detail: Option.none<string>(), | ||
| 526 | })), | ||
| 527 | Effect.catch(() => Effect.succeed(authFromConfig(config))), | ||
| 528 | ), | ||
| 529 | listPullRequests: (input) => | ||
| 530 | resolveRepository(input).pipe( | ||
| 531 | Effect.flatMap((repository) => { | ||
| 532 | const states = toBitbucketStates(input.state); | ||
| 533 | const query: Record<string, string | ReadonlyArray<string>> = { | ||
| 534 | pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))), | ||
| 535 | sort: "-updated_on", | ||
| 536 | q: bitbucketQueryString([ | ||
| 537 | `source.branch.name = "${SourceControlProvider.sourceBranch(input).replaceAll('"', '\\"')}"`, | ||
| 538 | bitbucketStateFilter(states), | ||
| 539 | ]), | ||
| 540 | state: states, | ||
| 541 | }; | ||
| 542 | |||
| 543 | return executeJson( | ||
| 544 | "listPullRequests", | ||
| 545 | HttpClientRequest.get( | ||
| 546 | apiUrl( | ||
| 547 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, | ||
| 548 | ), | ||
| 549 | { urlParams: query }, | ||
| 550 | ), | ||
| 551 | BitbucketPullRequests.BitbucketPullRequestListSchema, | ||
| 552 | ); | ||
| 553 | }), | ||
| 554 | Effect.map((list) => | ||
| 555 | list.values.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord), | ||
| 556 | ), | ||
| 557 | ), | ||
| 558 | getPullRequest: (input) => | ||
| 559 | getRawPullRequest(input).pipe( | ||
| 560 | Effect.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord), | ||
| 561 | ), | ||
| 562 | getRepositoryCloneUrls: (input) => | ||
| 563 | getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), | ||
| 564 | createRepository: (input) => | ||
| 565 | requireRepositoryLocator("createRepository", input.repository).pipe( | ||
| 566 | Effect.flatMap((repository) => | ||
| 567 | executeJson( | ||
| 568 | "createRepository", | ||
| 569 | HttpClientRequest.post( | ||
| 570 | apiUrl( | ||
| 571 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, | ||
| 572 | ), | ||
| 573 | ).pipe( | ||
| 574 | HttpClientRequest.bodyJsonUnsafe({ | ||
| 575 | scm: "git", | ||
| 576 | is_private: input.visibility === "private", | ||
| 577 | }), | ||
| 578 | ), | ||
| 579 | RawBitbucketRepositorySchema, | ||
| 580 | ), | ||
| 581 | ), | ||
| 582 | Effect.map(normalizeRepositoryCloneUrls), | ||
| 583 | ), | ||
| 584 | createPullRequest: (input) => | ||
| 585 | Effect.gen(function* () { | ||
| 586 | const repository = yield* resolveRepository(input); | ||
| 587 | const description = yield* fileSystem.readFileString(input.bodyFile).pipe( | ||
| 588 | Effect.mapError( | ||
| 589 | (cause) => | ||
| 590 | new BitbucketApiError({ | ||
| 591 | operation: "createPullRequest", | ||
| 592 | detail: `Failed to read pull request body file ${input.bodyFile}.`, | ||
| 593 | cause, | ||
| 594 | }), | ||
| 595 | ), | ||
| 596 | ); | ||
| 597 | const sourceOwner = sourceWorkspace(input); | ||
| 598 | const body = { | ||
| 599 | title: input.title, | ||
| 600 | description, | ||
| 601 | source: { | ||
| 602 | branch: { | ||
| 603 | name: SourceControlProvider.sourceBranch(input), | ||
| 604 | }, | ||
| 605 | ...(sourceOwner | ||
| 606 | ? { | ||
| 607 | repository: { | ||
| 608 | full_name: `${sourceOwner}/${input.source?.repository ?? repository.repoSlug}`, | ||
| 609 | }, | ||
| 610 | } | ||
| 611 | : {}), | ||
| 612 | }, | ||
| 613 | destination: { | ||
| 614 | branch: { | ||
| 615 | name: input.target?.refName ?? input.baseBranch, | ||
| 616 | }, | ||
| 617 | }, | ||
| 618 | }; | ||
| 619 | |||
| 620 | yield* executeJson( | ||
| 621 | "createPullRequest", | ||
| 622 | HttpClientRequest.post( | ||
| 623 | apiUrl( | ||
| 624 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, | ||
| 625 | ), | ||
| 626 | ).pipe(HttpClientRequest.bodyJsonUnsafe(body)), | ||
| 627 | BitbucketPullRequests.BitbucketPullRequestSchema, | ||
| 628 | ); | ||
| 629 | }), | ||
| 630 | getDefaultBranch: (input) => | ||
| 631 | getRepository(input).pipe(Effect.map((repository) => repository.mainbranch?.name ?? null)), | ||
| 632 | // Bitbucket Cloud pull requests are Git-backed and Bitbucket does not provide | ||
| 633 | // an official checkout CLI. This provider-local path uses GitVcsDriver as a | ||
| 634 | // narrow escape hatch to materialize Bitbucket PR refs. Do not generalize this | ||
| 635 | // as the source-control provider model: if we support non-Git-compatible | ||
| 636 | // hosting providers or native JJ/Sapling checkout flows, move this into a | ||
| 637 | // VCS-specific change-request checkout capability. | ||
| 638 | checkoutPullRequest: (input) => | ||
| 639 | Effect.gen(function* () { | ||
| 640 | const destinationRepository = yield* resolveRepository(input); | ||
| 641 | const pullRequest = yield* getRawPullRequestFromRepository( | ||
| 642 | destinationRepository, | ||
| 643 | input.reference, | ||
| 644 | ); | ||
| 645 | const destinationRepositoryName = | ||
| 646 | repositoryNameWithOwner(pullRequest.destination.repository) ?? | ||
| 647 | `${destinationRepository.workspace}/${destinationRepository.repoSlug}`; | ||
| 648 | const sourceRepositoryName = | ||
| 649 | repositoryNameWithOwner(pullRequest.source.repository) ?? destinationRepositoryName; | ||
| 650 | const isCrossRepository = sourceRepositoryName !== destinationRepositoryName; | ||
| 651 | const remoteName = yield* resolveCheckoutRemote({ | ||
| 652 | cwd: input.cwd, | ||
| 653 | destinationRepository, | ||
| 654 | sourceRepositoryName, | ||
| 655 | isCrossRepository, | ||
| 656 | ...(input.context ? { context: input.context } : {}), | ||
| 657 | }); | ||
| 658 | const remoteBranch = pullRequest.source.branch.name; | ||
| 659 | const localBranch = checkoutBranchName({ | ||
| 660 | pullRequestId: pullRequest.id, | ||
| 661 | headBranch: remoteBranch, | ||
| 662 | isCrossRepository, | ||
| 663 | }); | ||
| 664 | const localBranchNames = yield* git.listLocalBranchNames(input.cwd); | ||
| 665 | const localBranchExists = localBranchNames.includes(localBranch); | ||
| 666 | |||
| 667 | if (input.force === true || !localBranchExists) { | ||
| 668 | yield* git.fetchRemoteBranch({ | ||
| 669 | cwd: input.cwd, | ||
| 670 | remoteName, | ||
| 671 | remoteBranch, | ||
| 672 | localBranch, | ||
| 673 | }); | ||
| 674 | } else { | ||
| 675 | yield* git.fetchRemoteTrackingBranch({ | ||
| 676 | cwd: input.cwd, | ||
| 677 | remoteName, | ||
| 678 | remoteBranch, | ||
| 679 | }); | ||
| 680 | } | ||
| 681 | |||
| 682 | yield* git.setBranchUpstream({ | ||
| 683 | cwd: input.cwd, | ||
| 684 | branch: localBranch, | ||
| 685 | remoteName, | ||
| 686 | remoteBranch, | ||
| 687 | }); | ||
| 688 | yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); | ||
| 689 | }).pipe( | ||
| 690 | Effect.mapError((cause) => | ||
| 691 | isBitbucketApiError(cause) | ||
| 692 | ? cause | ||
| 693 | : new BitbucketApiError({ | ||
| 694 | operation: "checkoutPullRequest", | ||
| 695 | detail: cause instanceof Error ? cause.message : String(cause), | ||
| 696 | cause, | ||
| 697 | }), | ||
| 698 | ), | ||
| 699 | ), | ||
| 700 | }); | ||
| 701 | }); | ||
| 702 | |||
| 703 | export const layer = Layer.effect(BitbucketApi, make()); | ||
apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts created+126| ... | @@ -0,0 +1,126 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer, Option } from "effect"; | ||
| 3 | |||
| 4 | import * as BitbucketApi from "./BitbucketApi.ts"; | ||
| 5 | import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; | ||
| 6 | |||
| 7 | function makeProvider(bitbucket: Partial<BitbucketApi.BitbucketApiShape>) { | ||
| 8 | return BitbucketSourceControlProvider.make().pipe( | ||
| 9 | Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)), | ||
| 10 | ); | ||
| 11 | } | ||
| 12 | |||
| 13 | it.effect("maps Bitbucket PR summaries into provider-neutral change requests", () => | ||
| 14 | Effect.gen(function* () { | ||
| 15 | const provider = yield* makeProvider({ | ||
| 16 | getPullRequest: () => | ||
| 17 | Effect.succeed({ | ||
| 18 | number: 42, | ||
| 19 | title: "Add Bitbucket provider", | ||
| 20 | url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", | ||
| 21 | baseRefName: "main", | ||
| 22 | headRefName: "feature/source-control", | ||
| 23 | state: "open", | ||
| 24 | updatedAt: Option.none(), | ||
| 25 | isCrossRepository: true, | ||
| 26 | headRepositoryNameWithOwner: "fork/t3code", | ||
| 27 | headRepositoryOwnerLogin: "fork", | ||
| 28 | }), | ||
| 29 | }); | ||
| 30 | |||
| 31 | const changeRequest = yield* provider.getChangeRequest({ | ||
| 32 | cwd: "/repo", | ||
| 33 | reference: "42", | ||
| 34 | }); | ||
| 35 | |||
| 36 | assert.deepStrictEqual(changeRequest, { | ||
| 37 | provider: "bitbucket", | ||
| 38 | number: 42, | ||
| 39 | title: "Add Bitbucket provider", | ||
| 40 | url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", | ||
| 41 | baseRefName: "main", | ||
| 42 | headRefName: "feature/source-control", | ||
| 43 | state: "open", | ||
| 44 | updatedAt: Option.none(), | ||
| 45 | isCrossRepository: true, | ||
| 46 | headRepositoryNameWithOwner: "fork/t3code", | ||
| 47 | headRepositoryOwnerLogin: "fork", | ||
| 48 | }); | ||
| 49 | }), | ||
| 50 | ); | ||
| 51 | |||
| 52 | it.effect("lists Bitbucket PRs through provider-neutral input names", () => | ||
| 53 | Effect.gen(function* () { | ||
| 54 | let listInput: Parameters<BitbucketApi.BitbucketApiShape["listPullRequests"]>[0] | null = null; | ||
| 55 | const provider = yield* makeProvider({ | ||
| 56 | listPullRequests: (input) => { | ||
| 57 | listInput = input; | ||
| 58 | return Effect.succeed([]); | ||
| 59 | }, | ||
| 60 | }); | ||
| 61 | |||
| 62 | yield* provider.listChangeRequests({ | ||
| 63 | cwd: "/repo", | ||
| 64 | headSelector: "feature/provider", | ||
| 65 | state: "all", | ||
| 66 | limit: 10, | ||
| 67 | }); | ||
| 68 | |||
| 69 | assert.deepStrictEqual(listInput, { | ||
| 70 | cwd: "/repo", | ||
| 71 | headSelector: "feature/provider", | ||
| 72 | state: "all", | ||
| 73 | limit: 10, | ||
| 74 | }); | ||
| 75 | }), | ||
| 76 | ); | ||
| 77 | |||
| 78 | it.effect("creates Bitbucket PRs through provider-neutral input names", () => | ||
| 79 | Effect.gen(function* () { | ||
| 80 | let createInput: Parameters<BitbucketApi.BitbucketApiShape["createPullRequest"]>[0] | null = | ||
| 81 | null; | ||
| 82 | const provider = yield* makeProvider({ | ||
| 83 | createPullRequest: (input) => { | ||
| 84 | createInput = input; | ||
| 85 | return Effect.void; | ||
| 86 | }, | ||
| 87 | }); | ||
| 88 | |||
| 89 | yield* provider.createChangeRequest({ | ||
| 90 | cwd: "/repo", | ||
| 91 | baseRefName: "main", | ||
| 92 | headSelector: "owner:feature/provider", | ||
| 93 | title: "Provider PR", | ||
| 94 | bodyFile: "/tmp/body.md", | ||
| 95 | }); | ||
| 96 | |||
| 97 | assert.deepStrictEqual(createInput, { | ||
| 98 | cwd: "/repo", | ||
| 99 | baseBranch: "main", | ||
| 100 | headSelector: "owner:feature/provider", | ||
| 101 | source: { | ||
| 102 | owner: "owner", | ||
| 103 | refName: "feature/provider", | ||
| 104 | }, | ||
| 105 | title: "Provider PR", | ||
| 106 | bodyFile: "/tmp/body.md", | ||
| 107 | }); | ||
| 108 | }), | ||
| 109 | ); | ||
| 110 | |||
| 111 | it.effect("uses Bitbucket API repository detection for default branch lookup", () => | ||
| 112 | Effect.gen(function* () { | ||
| 113 | let cwdInput: string | null = null; | ||
| 114 | const provider = yield* makeProvider({ | ||
| 115 | getDefaultBranch: (input) => { | ||
| 116 | cwdInput = input.cwd; | ||
| 117 | return Effect.succeed("main"); | ||
| 118 | }, | ||
| 119 | }); | ||
| 120 | |||
| 121 | const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); | ||
| 122 | |||
| 123 | assert.strictEqual(defaultBranch, "main"); | ||
| 124 | assert.strictEqual(cwdInput, "/repo"); | ||
| 125 | }), | ||
| 126 | ); | ||
apps/server/src/sourceControl/BitbucketSourceControlProvider.ts created+126| ... | @@ -0,0 +1,126 @@ | ||
| 1 | import { Effect, Layer, Option } from "effect"; | ||
| 2 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import * as BitbucketApi from "./BitbucketApi.ts"; | ||
| 5 | import * as BitbucketPullRequests from "./bitbucketPullRequests.ts"; | ||
| 6 | import * as SourceControlProvider from "./SourceControlProvider.ts"; | ||
| 7 | import type * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; | ||
| 8 | |||
| 9 | function providerError( | ||
| 10 | operation: string, | ||
| 11 | cause: BitbucketApi.BitbucketApiError, | ||
| 12 | ): SourceControlProviderError { | ||
| 13 | return new SourceControlProviderError({ | ||
| 14 | provider: "bitbucket", | ||
| 15 | operation, | ||
| 16 | detail: cause.detail, | ||
| 17 | cause, | ||
| 18 | }); | ||
| 19 | } | ||
| 20 | |||
| 21 | function toChangeRequest( | ||
| 22 | summary: BitbucketPullRequests.NormalizedBitbucketPullRequestRecord, | ||
| 23 | ): ChangeRequest { | ||
| 24 | return { | ||
| 25 | provider: "bitbucket", | ||
| 26 | number: summary.number, | ||
| 27 | title: summary.title, | ||
| 28 | url: summary.url, | ||
| 29 | baseRefName: summary.baseRefName, | ||
| 30 | headRefName: summary.headRefName, | ||
| 31 | state: summary.state, | ||
| 32 | updatedAt: summary.updatedAt ?? Option.none(), | ||
| 33 | ...(summary.isCrossRepository !== undefined | ||
| 34 | ? { isCrossRepository: summary.isCrossRepository } | ||
| 35 | : {}), | ||
| 36 | ...(summary.headRepositoryNameWithOwner !== undefined | ||
| 37 | ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } | ||
| 38 | : {}), | ||
| 39 | ...(summary.headRepositoryOwnerLogin !== undefined | ||
| 40 | ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } | ||
| 41 | : {}), | ||
| 42 | }; | ||
| 43 | } | ||
| 44 | |||
| 45 | export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () { | ||
| 46 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 47 | |||
| 48 | return SourceControlProvider.SourceControlProvider.of({ | ||
| 49 | kind: "bitbucket", | ||
| 50 | listChangeRequests: (input) => { | ||
| 51 | const source = SourceControlProvider.sourceControlRefFromInput(input); | ||
| 52 | return bitbucket | ||
| 53 | .listPullRequests({ | ||
| 54 | cwd: input.cwd, | ||
| 55 | ...(input.context ? { context: input.context } : {}), | ||
| 56 | headSelector: input.headSelector, | ||
| 57 | ...(source ? { source } : {}), | ||
| 58 | state: input.state, | ||
| 59 | ...(input.limit !== undefined ? { limit: input.limit } : {}), | ||
| 60 | }) | ||
| 61 | .pipe( | ||
| 62 | Effect.map((items) => items.map(toChangeRequest)), | ||
| 63 | Effect.mapError((error) => providerError("listChangeRequests", error)), | ||
| 64 | ); | ||
| 65 | }, | ||
| 66 | getChangeRequest: (input) => | ||
| 67 | bitbucket.getPullRequest(input).pipe( | ||
| 68 | Effect.map(toChangeRequest), | ||
| 69 | Effect.mapError((error) => providerError("getChangeRequest", error)), | ||
| 70 | ), | ||
| 71 | createChangeRequest: (input) => { | ||
| 72 | const source = SourceControlProvider.sourceControlRefFromInput(input); | ||
| 73 | return bitbucket | ||
| 74 | .createPullRequest({ | ||
| 75 | cwd: input.cwd, | ||
| 76 | ...(input.context ? { context: input.context } : {}), | ||
| 77 | baseBranch: input.baseRefName, | ||
| 78 | headSelector: input.headSelector, | ||
| 79 | ...(source ? { source } : {}), | ||
| 80 | ...(input.target ? { target: input.target } : {}), | ||
| 81 | title: input.title, | ||
| 82 | bodyFile: input.bodyFile, | ||
| 83 | }) | ||
| 84 | .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); | ||
| 85 | }, | ||
| 86 | getRepositoryCloneUrls: (input) => | ||
| 87 | bitbucket | ||
| 88 | .getRepositoryCloneUrls(input) | ||
| 89 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), | ||
| 90 | createRepository: (input) => | ||
| 91 | bitbucket | ||
| 92 | .createRepository(input) | ||
| 93 | .pipe(Effect.mapError((error) => providerError("createRepository", error))), | ||
| 94 | getDefaultBranch: (input) => | ||
| 95 | bitbucket | ||
| 96 | .getDefaultBranch({ | ||
| 97 | cwd: input.cwd, | ||
| 98 | ...(input.context ? { context: input.context } : {}), | ||
| 99 | }) | ||
| 100 | .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), | ||
| 101 | checkoutChangeRequest: (input) => | ||
| 102 | bitbucket | ||
| 103 | .checkoutPullRequest({ | ||
| 104 | cwd: input.cwd, | ||
| 105 | ...(input.context ? { context: input.context } : {}), | ||
| 106 | reference: input.reference, | ||
| 107 | ...(input.force !== undefined ? { force: input.force } : {}), | ||
| 108 | }) | ||
| 109 | .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), | ||
| 110 | }); | ||
| 111 | }); | ||
| 112 | |||
| 113 | export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); | ||
| 114 | |||
| 115 | export const makeDiscovery = Effect.fn("makeBitbucketSourceControlProviderDiscovery")(function* () { | ||
| 116 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 117 | |||
| 118 | return { | ||
| 119 | type: "api", | ||
| 120 | kind: "bitbucket", | ||
| 121 | label: "Bitbucket", | ||
| 122 | installHint: | ||
| 123 | "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request and repository scopes).", | ||
| 124 | probeAuth: bitbucket.probeAuth, | ||
| 125 | } satisfies SourceControlProviderDiscovery.SourceControlApiDiscoverySpec; | ||
| 126 | }); | ||
apps/server/src/sourceControl/GitHubCli.test.ts+5-6| ... | @@ -1,13 +1,12 @@ | ... | @@ -1,13 +1,12 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; |
| 2 | import { Effect, Layer } from "effect"; | 2 | import { Effect, Layer } from "effect"; |
| 3 | import { ChildProcessSpawner } from "effect/unstable/process"; | 3 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 4 | import { VcsProcessExitError, type VcsError } from "@t3tools/contracts"; | 4 | import { VcsProcessExitError, type VcsError } from "@t3tools/contracts"; |
| 5 | import { afterEach, describe, expect, vi } from "vitest"; | ||
| 6 | 5 | ||
| 7 | import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; | 6 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 8 | import * as GitHubCli from "./GitHubCli.ts"; | 7 | import * as GitHubCli from "./GitHubCli.ts"; |
| 9 | 8 | ||
| 10 | const processOutput = (stdout: string): VcsProcessOutput => ({ | 9 | const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ |
| 11 | exitCode: ChildProcessSpawner.ExitCode(0), | 10 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 12 | stdout, | 11 | stdout, |
| 13 | stderr: "", | 12 | stderr: "", |
| ... | @@ -15,11 +14,11 @@ const processOutput = (stdout: string): VcsProcessOutput => ({ | ... | @@ -15,11 +14,11 @@ const processOutput = (stdout: string): VcsProcessOutput => ({ |
| 15 | stderrTruncated: false, | 14 | stderrTruncated: false, |
| 16 | }); | 15 | }); |
| 17 | 16 | ||
| 18 | const mockRun = vi.fn<(input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>>(); | 17 | const mockRun = vi.fn<VcsProcess.VcsProcessShape["run"]>(); |
| 19 | 18 | ||
| 20 | const layer = GitHubCli.layer.pipe( | 19 | const layer = GitHubCli.layer.pipe( |
| 21 | Layer.provide( | 20 | Layer.provide( |
| 22 | Layer.mock(VcsProcess)({ | 21 | Layer.mock(VcsProcess.VcsProcess)({ |
| 23 | run: mockRun, | 22 | run: mockRun, |
| 24 | }), | 23 | }), |
| 25 | ), | 24 | ), |
apps/server/src/sourceControl/GitHubCli.ts+8-12| ... | @@ -6,12 +6,8 @@ import { | ... | @@ -6,12 +6,8 @@ import { |
| 6 | type VcsError, | 6 | type VcsError, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | 8 | ||
| 9 | import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; | 9 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 10 | import { | 10 | import * as GitHubPullRequests from "./gitHubPullRequests.ts"; |
| 11 | decodeGitHubPullRequestJson, | ||
| 12 | decodeGitHubPullRequestListJson, | ||
| 13 | formatGitHubJsonDecodeError, | ||
| 14 | } from "./gitHubPullRequests.ts"; | ||
| 15 | 11 | ||
| 16 | const DEFAULT_TIMEOUT_MS = 30_000; | 12 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 17 | 13 | ||
| ... | @@ -48,7 +44,7 @@ export interface GitHubCliShape { | ... | @@ -48,7 +44,7 @@ export interface GitHubCliShape { |
| 48 | readonly cwd: string; | 44 | readonly cwd: string; |
| 49 | readonly args: ReadonlyArray<string>; | 45 | readonly args: ReadonlyArray<string>; |
| 50 | readonly timeoutMs?: number; | 46 | readonly timeoutMs?: number; |
| 51 | }) => Effect.Effect<VcsProcessOutput, GitHubCliError>; | 47 | }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCliError>; |
| 52 | 48 | ||
| 53 | readonly listOpenPullRequests: (input: { | 49 | readonly listOpenPullRequests: (input: { |
| 54 | readonly cwd: string; | 50 | readonly cwd: string; |
| ... | @@ -226,7 +222,7 @@ function decodeGitHubJson<S extends Schema.Top>( | ... | @@ -226,7 +222,7 @@ function decodeGitHubJson<S extends Schema.Top>( |
| 226 | } | 222 | } |
| 227 | 223 | ||
| 228 | export const make = Effect.fn("makeGitHubCli")(function* () { | 224 | export const make = Effect.fn("makeGitHubCli")(function* () { |
| 229 | const process = yield* VcsProcess; | 225 | const process = yield* VcsProcess.VcsProcess; |
| 230 | 226 | ||
| 231 | const execute: GitHubCliShape["execute"] = (input) => | 227 | const execute: GitHubCliShape["execute"] = (input) => |
| 232 | process | 228 | process |
| ... | @@ -261,13 +257,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { | ... | @@ -261,13 +257,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { |
| 261 | Effect.flatMap((raw) => | 257 | Effect.flatMap((raw) => |
| 262 | raw.length === 0 | 258 | raw.length === 0 |
| 263 | ? Effect.succeed([]) | 259 | ? Effect.succeed([]) |
| 264 | : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( | 260 | : Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( |
| 265 | Effect.flatMap((decoded) => { | 261 | Effect.flatMap((decoded) => { |
| 266 | if (!Result.isSuccess(decoded)) { | 262 | if (!Result.isSuccess(decoded)) { |
| 267 | return Effect.fail( | 263 | return Effect.fail( |
| 268 | new GitHubCliError({ | 264 | new GitHubCliError({ |
| 269 | operation: "listOpenPullRequests", | 265 | operation: "listOpenPullRequests", |
| 270 | detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, | 266 | detail: `GitHub CLI returned invalid PR list JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`, |
| 271 | cause: decoded.failure, | 267 | cause: decoded.failure, |
| 272 | }), | 268 | }), |
| 273 | ); | 269 | ); |
| ... | @@ -293,13 +289,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { | ... | @@ -293,13 +289,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { |
| 293 | }).pipe( | 289 | }).pipe( |
| 294 | Effect.map((result) => result.stdout.trim()), | 290 | Effect.map((result) => result.stdout.trim()), |
| 295 | Effect.flatMap((raw) => | 291 | Effect.flatMap((raw) => |
| 296 | Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( | 292 | Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestJson(raw)).pipe( |
| 297 | Effect.flatMap((decoded) => { | 293 | Effect.flatMap((decoded) => { |
| 298 | if (!Result.isSuccess(decoded)) { | 294 | if (!Result.isSuccess(decoded)) { |
| 299 | return Effect.fail( | 295 | return Effect.fail( |
| 300 | new GitHubCliError({ | 296 | new GitHubCliError({ |
| 301 | operation: "getPullRequest", | 297 | operation: "getPullRequest", |
| 302 | detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, | 298 | detail: `GitHub CLI returned invalid pull request JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`, |
| 303 | cause: decoded.failure, | 299 | cause: decoded.failure, |
| 304 | }), | 300 | }), |
| 305 | ); | 301 | ); |
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+8-6| ... | @@ -2,11 +2,11 @@ import { assert, it } from "@effect/vitest"; | ... | @@ -2,11 +2,11 @@ import { assert, it } from "@effect/vitest"; |
| 2 | import { DateTime, Effect, Layer, Option } from "effect"; | 2 | import { DateTime, Effect, Layer, Option } from "effect"; |
| 3 | import { ChildProcessSpawner } from "effect/unstable/process"; | 3 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 4 | 4 | ||
| 5 | import { GitHubCli, type GitHubCliShape } from "./GitHubCli.ts"; | 5 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 6 | import type { VcsProcessOutput } from "../vcs/VcsProcess.ts"; | 6 | import * as GitHubCli from "./GitHubCli.ts"; |
| 7 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; | 7 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 8 | 8 | ||
| 9 | const processResult = (stdout: string): VcsProcessOutput => ({ | 9 | const processResult = (stdout: string): VcsProcess.VcsProcessOutput => ({ |
| 10 | exitCode: ChildProcessSpawner.ExitCode(0), | 10 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 11 | stdout, | 11 | stdout, |
| 12 | stderr: "", | 12 | stderr: "", |
| ... | @@ -14,8 +14,10 @@ const processResult = (stdout: string): VcsProcessOutput => ({ | ... | @@ -14,8 +14,10 @@ const processResult = (stdout: string): VcsProcessOutput => ({ |
| 14 | stderrTruncated: false, | 14 | stderrTruncated: false, |
| 15 | }); | 15 | }); |
| 16 | 16 | ||
| 17 | function makeProvider(github: Partial<GitHubCliShape>) { | 17 | function makeProvider(github: Partial<GitHubCli.GitHubCliShape>) { |
| 18 | return GitHubSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitHubCli)(github))); | 18 | return GitHubSourceControlProvider.make().pipe( |
| 19 | Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)), | ||
| 20 | ); | ||
| 19 | } | 21 | } |
| 20 | 22 | ||
| 21 | it.effect("maps GitHub PR summaries into provider-neutral change requests", () => | 23 | 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", ( | ... | @@ -127,7 +129,7 @@ it.effect("treats empty non-open change request listing output as no results", ( |
| 127 | 129 | ||
| 128 | it.effect("creates GitHub PRs through provider-neutral input names", () => | 130 | it.effect("creates GitHub PRs through provider-neutral input names", () => |
| 129 | Effect.gen(function* () { | 131 | Effect.gen(function* () { |
| 130 | let createInput: Parameters<GitHubCliShape["createPullRequest"]>[0] | null = null; | 132 | let createInput: Parameters<GitHubCli.GitHubCliShape["createPullRequest"]>[0] | null = null; |
| 131 | const provider = yield* makeProvider({ | 133 | const provider = yield* makeProvider({ |
| 132 | createPullRequest: (input) => { | 134 | createPullRequest: (input) => { |
| 133 | createInput = input; | 135 | createInput = input; |
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+115-67| ... | @@ -5,11 +5,15 @@ import { | ... | @@ -5,11 +5,15 @@ import { |
| 5 | type ChangeRequestState, | 5 | type ChangeRequestState, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | ||
| 8 | import { GitHubCli, type GitHubCliError, type GitHubPullRequestSummary } from "./GitHubCli.ts"; | 8 | import * as GitHubCli from "./GitHubCli.ts"; |
| 9 | import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts"; | 9 | import * as GitHubPullRequests from "./gitHubPullRequests.ts"; |
| 10 | import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts"; | 10 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 11 | import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; | ||
| 11 | 12 | ||
| 12 | function providerError(operation: string, cause: GitHubCliError): SourceControlProviderError { | 13 | function providerError( |
| 14 | operation: string, | ||
| 15 | cause: GitHubCli.GitHubCliError, | ||
| 16 | ): SourceControlProviderError { | ||
| 13 | return new SourceControlProviderError({ | 17 | return new SourceControlProviderError({ |
| 14 | provider: "github", | 18 | provider: "github", |
| 15 | operation, | 19 | operation, |
| ... | @@ -18,7 +22,7 @@ function providerError(operation: string, cause: GitHubCliError): SourceControlP | ... | @@ -18,7 +22,7 @@ function providerError(operation: string, cause: GitHubCliError): SourceControlP |
| 18 | }); | 22 | }); |
| 19 | } | 23 | } |
| 20 | 24 | ||
| 21 | function toChangeRequest(summary: GitHubPullRequestSummary): ChangeRequest { | 25 | function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { |
| 22 | return { | 26 | return { |
| 23 | provider: "github", | 27 | provider: "github", |
| 24 | number: summary.number, | 28 | number: summary.number, |
| ... | @@ -40,75 +44,119 @@ function toChangeRequest(summary: GitHubPullRequestSummary): ChangeRequest { | ... | @@ -40,75 +44,119 @@ function toChangeRequest(summary: GitHubPullRequestSummary): ChangeRequest { |
| 40 | }; | 44 | }; |
| 41 | } | 45 | } |
| 42 | 46 | ||
| 47 | function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { | ||
| 48 | const output = SourceControlProviderDiscovery.combinedAuthOutput(input); | ||
| 49 | const account = SourceControlProviderDiscovery.matchFirst(output, [ | ||
| 50 | /Logged in to .* account\s+([^\s(]+)/iu, | ||
| 51 | /Logged in to .* as\s+([^\s(]+)/iu, | ||
| 52 | ]); | ||
| 53 | const host = SourceControlProviderDiscovery.parseCliHost(output); | ||
| 54 | |||
| 55 | if (input.exitCode !== 0) { | ||
| 56 | return SourceControlProviderDiscovery.providerAuth({ | ||
| 57 | status: "unauthenticated", | ||
| 58 | host, | ||
| 59 | detail: | ||
| 60 | SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? | ||
| 61 | "Run `gh auth login` to authenticate GitHub CLI.", | ||
| 62 | }); | ||
| 63 | } | ||
| 64 | |||
| 65 | if (account) { | ||
| 66 | return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host }); | ||
| 67 | } | ||
| 68 | |||
| 69 | return SourceControlProviderDiscovery.providerAuth({ | ||
| 70 | status: "unknown", | ||
| 71 | host, | ||
| 72 | detail: | ||
| 73 | SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? | ||
| 74 | "GitHub CLI auth status could not be parsed.", | ||
| 75 | }); | ||
| 76 | } | ||
| 77 | |||
| 78 | export const discovery = { | ||
| 79 | type: "cli", | ||
| 80 | kind: "github", | ||
| 81 | label: "GitHub", | ||
| 82 | executable: "gh", | ||
| 83 | versionArgs: ["--version"], | ||
| 84 | authArgs: ["auth", "status"], | ||
| 85 | parseAuth: parseGitHubAuth, | ||
| 86 | installHint: | ||
| 87 | "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", | ||
| 88 | } satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; | ||
| 89 | |||
| 43 | export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { | 90 | export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { |
| 44 | const github = yield* GitHubCli; | 91 | const github = yield* GitHubCli.GitHubCli; |
| 45 | 92 | ||
| 46 | const listChangeRequests: SourceControlProviderShape["listChangeRequests"] = (input) => { | 93 | const listChangeRequests: SourceControlProvider.SourceControlProviderShape["listChangeRequests"] = |
| 47 | if (input.state === "open") { | 94 | (input) => { |
| 95 | if (input.state === "open") { | ||
| 96 | return github | ||
| 97 | .listOpenPullRequests({ | ||
| 98 | cwd: input.cwd, | ||
| 99 | headSelector: input.headSelector, | ||
| 100 | ...(input.limit !== undefined ? { limit: input.limit } : {}), | ||
| 101 | }) | ||
| 102 | .pipe( | ||
| 103 | Effect.map((items) => items.map(toChangeRequest)), | ||
| 104 | Effect.mapError((error) => providerError("listChangeRequests", error)), | ||
| 105 | ); | ||
| 106 | } | ||
| 107 | |||
| 108 | const stateArg: ChangeRequestState | "all" = input.state; | ||
| 48 | return github | 109 | return github |
| 49 | .listOpenPullRequests({ | 110 | .execute({ |
| 50 | cwd: input.cwd, | 111 | cwd: input.cwd, |
| 51 | headSelector: input.headSelector, | 112 | args: [ |
| 52 | ...(input.limit !== undefined ? { limit: input.limit } : {}), | 113 | "pr", |
| 114 | "list", | ||
| 115 | "--head", | ||
| 116 | input.headSelector, | ||
| 117 | "--state", | ||
| 118 | stateArg, | ||
| 119 | "--limit", | ||
| 120 | String(input.limit ?? 20), | ||
| 121 | "--json", | ||
| 122 | "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 123 | ], | ||
| 53 | }) | 124 | }) |
| 54 | .pipe( | 125 | .pipe( |
| 55 | Effect.map((items) => items.map(toChangeRequest)), | 126 | Effect.flatMap((result) => { |
| 56 | Effect.mapError((error) => providerError("listChangeRequests", error)), | 127 | const raw = result.stdout.trim(); |
| 128 | if (raw.length === 0) { | ||
| 129 | return Effect.succeed([]); | ||
| 130 | } | ||
| 131 | return Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( | ||
| 132 | Effect.flatMap((decoded) => | ||
| 133 | Result.isSuccess(decoded) | ||
| 134 | ? Effect.succeed( | ||
| 135 | decoded.success.map((item) => ({ | ||
| 136 | ...toChangeRequest(item), | ||
| 137 | updatedAt: item.updatedAt, | ||
| 138 | })), | ||
| 139 | ) | ||
| 140 | : Effect.fail( | ||
| 141 | new SourceControlProviderError({ | ||
| 142 | provider: "github", | ||
| 143 | operation: "listChangeRequests", | ||
| 144 | detail: "GitHub CLI returned invalid change request JSON.", | ||
| 145 | cause: decoded.failure, | ||
| 146 | }), | ||
| 147 | ), | ||
| 148 | ), | ||
| 149 | ); | ||
| 150 | }), | ||
| 151 | Effect.mapError((error) => | ||
| 152 | Schema.is(SourceControlProviderError)(error) | ||
| 153 | ? error | ||
| 154 | : providerError("listChangeRequests", error), | ||
| 155 | ), | ||
| 57 | ); | 156 | ); |
| 58 | } | 157 | }; |
| 59 | |||
| 60 | const stateArg: ChangeRequestState | "all" = input.state; | ||
| 61 | return github | ||
| 62 | .execute({ | ||
| 63 | cwd: input.cwd, | ||
| 64 | args: [ | ||
| 65 | "pr", | ||
| 66 | "list", | ||
| 67 | "--head", | ||
| 68 | input.headSelector, | ||
| 69 | "--state", | ||
| 70 | stateArg, | ||
| 71 | "--limit", | ||
| 72 | String(input.limit ?? 20), | ||
| 73 | "--json", | ||
| 74 | "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 75 | ], | ||
| 76 | }) | ||
| 77 | .pipe( | ||
| 78 | Effect.flatMap((result) => { | ||
| 79 | const raw = result.stdout.trim(); | ||
| 80 | if (raw.length === 0) { | ||
| 81 | return Effect.succeed([]); | ||
| 82 | } | ||
| 83 | return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( | ||
| 84 | Effect.flatMap((decoded) => | ||
| 85 | Result.isSuccess(decoded) | ||
| 86 | ? Effect.succeed( | ||
| 87 | decoded.success.map((item) => ({ | ||
| 88 | ...toChangeRequest(item), | ||
| 89 | updatedAt: item.updatedAt, | ||
| 90 | })), | ||
| 91 | ) | ||
| 92 | : Effect.fail( | ||
| 93 | new SourceControlProviderError({ | ||
| 94 | provider: "github", | ||
| 95 | operation: "listChangeRequests", | ||
| 96 | detail: "GitHub CLI returned invalid change request JSON.", | ||
| 97 | cause: decoded.failure, | ||
| 98 | }), | ||
| 99 | ), | ||
| 100 | ), | ||
| 101 | ); | ||
| 102 | }), | ||
| 103 | Effect.mapError((error) => | ||
| 104 | Schema.is(SourceControlProviderError)(error) | ||
| 105 | ? error | ||
| 106 | : providerError("listChangeRequests", error), | ||
| 107 | ), | ||
| 108 | ); | ||
| 109 | }; | ||
| 110 | 158 | ||
| 111 | return SourceControlProvider.of({ | 159 | return SourceControlProvider.SourceControlProvider.of({ |
| 112 | kind: "github", | 160 | kind: "github", |
| 113 | listChangeRequests, | 161 | listChangeRequests, |
| 114 | getChangeRequest: (input) => | 162 | getChangeRequest: (input) => |
| ... | @@ -145,4 +193,4 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { | ... | @@ -145,4 +193,4 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { |
| 145 | }); | 193 | }); |
| 146 | }); | 194 | }); |
| 147 | 195 | ||
| 148 | export const layer = Layer.effect(SourceControlProvider, make()); | 196 | export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); |
apps/server/src/sourceControl/GitLabCli.test.ts+5-6| ... | @@ -1,25 +1,24 @@ | ... | @@ -1,25 +1,24 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it, afterEach, expect, vi } from "@effect/vitest"; |
| 2 | import { Effect, Layer } from "effect"; | 2 | import { Effect, Layer } from "effect"; |
| 3 | import { ChildProcessSpawner } from "effect/unstable/process"; | 3 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 4 | import { afterEach, expect, vi } from "vitest"; | ||
| 5 | 4 | ||
| 6 | import { VcsProcessExitError } from "@t3tools/contracts"; | 5 | import { VcsProcessExitError } from "@t3tools/contracts"; |
| 7 | 6 | ||
| 8 | import { VcsProcess, type VcsProcessOutput, type VcsProcessShape } from "../vcs/VcsProcess.ts"; | 7 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 9 | import * as GitLabCli from "./GitLabCli.ts"; | 8 | import * as GitLabCli from "./GitLabCli.ts"; |
| 10 | 9 | ||
| 11 | const mockedRun = vi.fn<VcsProcessShape["run"]>(); | 10 | const mockedRun = vi.fn<VcsProcess.VcsProcessShape["run"]>(); |
| 12 | const layer = it.layer( | 11 | const layer = it.layer( |
| 13 | GitLabCli.layer.pipe( | 12 | GitLabCli.layer.pipe( |
| 14 | Layer.provide( | 13 | Layer.provide( |
| 15 | Layer.mock(VcsProcess)({ | 14 | Layer.mock(VcsProcess.VcsProcess)({ |
| 16 | run: mockedRun, | 15 | run: mockedRun, |
| 17 | }), | 16 | }), |
| 18 | ), | 17 | ), |
| 19 | ), | 18 | ), |
| 20 | ); | 19 | ); |
| 21 | 20 | ||
| 22 | function processOutput(stdout: string): VcsProcessOutput { | 21 | function processOutput(stdout: string): VcsProcess.VcsProcessOutput { |
| 23 | return { | 22 | return { |
| 24 | exitCode: ChildProcessSpawner.ExitCode(0), | 23 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 25 | stdout, | 24 | stdout, |
apps/server/src/sourceControl/GitLabCli.ts+16-18| ... | @@ -2,13 +2,9 @@ import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateT | ... | @@ -2,13 +2,9 @@ import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateT |
| 2 | 2 | ||
| 3 | import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; | 3 | import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; |
| 4 | 4 | ||
| 5 | import { | 5 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 6 | decodeGitLabMergeRequestJson, | 6 | import * as GitLabMergeRequests from "./gitLabMergeRequests.ts"; |
| 7 | decodeGitLabMergeRequestListJson, | 7 | import type * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 8 | formatGitLabJsonDecodeError, | ||
| 9 | } from "./gitLabMergeRequests.ts"; | ||
| 10 | import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; | ||
| 11 | import type { SourceControlRefSelector } from "./SourceControlProvider.ts"; | ||
| 12 | 8 | ||
| 13 | const DEFAULT_TIMEOUT_MS = 30_000; | 9 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 14 | 10 | ||
| ... | @@ -46,12 +42,12 @@ export interface GitLabCliShape { | ... | @@ -46,12 +42,12 @@ export interface GitLabCliShape { |
| 46 | readonly cwd: string; | 42 | readonly cwd: string; |
| 47 | readonly args: ReadonlyArray<string>; | 43 | readonly args: ReadonlyArray<string>; |
| 48 | readonly timeoutMs?: number; | 44 | readonly timeoutMs?: number; |
| 49 | }) => Effect.Effect<VcsProcessOutput, GitLabCliError>; | 45 | }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitLabCliError>; |
| 50 | 46 | ||
| 51 | readonly listMergeRequests: (input: { | 47 | readonly listMergeRequests: (input: { |
| 52 | readonly cwd: string; | 48 | readonly cwd: string; |
| 53 | readonly headSelector: string; | 49 | readonly headSelector: string; |
| 54 | readonly source?: SourceControlRefSelector; | 50 | readonly source?: SourceControlProvider.SourceControlRefSelector; |
| 55 | readonly state: "open" | "closed" | "merged" | "all"; | 51 | readonly state: "open" | "closed" | "merged" | "all"; |
| 56 | readonly limit?: number; | 52 | readonly limit?: number; |
| 57 | }) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>; | 53 | }) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>; |
| ... | @@ -76,8 +72,8 @@ export interface GitLabCliShape { | ... | @@ -76,8 +72,8 @@ export interface GitLabCliShape { |
| 76 | readonly cwd: string; | 72 | readonly cwd: string; |
| 77 | readonly baseBranch: string; | 73 | readonly baseBranch: string; |
| 78 | readonly headSelector: string; | 74 | readonly headSelector: string; |
| 79 | readonly source?: SourceControlRefSelector; | 75 | readonly source?: SourceControlProvider.SourceControlRefSelector; |
| 80 | readonly target?: SourceControlRefSelector; | 76 | readonly target?: SourceControlProvider.SourceControlRefSelector; |
| 81 | readonly title: string; | 77 | readonly title: string; |
| 82 | readonly bodyFile: string; | 78 | readonly bodyFile: string; |
| 83 | }) => Effect.Effect<void, GitLabCliError>; | 79 | }) => Effect.Effect<void, GitLabCliError>; |
| ... | @@ -220,12 +216,14 @@ function normalizeHeadSelector(headSelector: string): string { | ... | @@ -220,12 +216,14 @@ function normalizeHeadSelector(headSelector: string): string { |
| 220 | 216 | ||
| 221 | function sourceRefName(input: { | 217 | function sourceRefName(input: { |
| 222 | readonly headSelector: string; | 218 | readonly headSelector: string; |
| 223 | readonly source?: SourceControlRefSelector; | 219 | readonly source?: SourceControlProvider.SourceControlRefSelector; |
| 224 | }): string { | 220 | }): string { |
| 225 | return input.source?.refName ?? normalizeHeadSelector(input.headSelector); | 221 | return input.source?.refName ?? normalizeHeadSelector(input.headSelector); |
| 226 | } | 222 | } |
| 227 | 223 | ||
| 228 | function sourceProjectIdentifier(source: SourceControlRefSelector | undefined): string | null { | 224 | function sourceProjectIdentifier( |
| 225 | source: SourceControlProvider.SourceControlRefSelector | undefined, | ||
| 226 | ): string | null { | ||
| 229 | return source?.repository ?? source?.owner ?? null; | 227 | return source?.repository ?? source?.owner ?? null; |
| 230 | } | 228 | } |
| 231 | 229 | ||
| ... | @@ -252,7 +250,7 @@ function parseRepositoryPath(repository: string): { | ... | @@ -252,7 +250,7 @@ function parseRepositoryPath(repository: string): { |
| 252 | } | 250 | } |
| 253 | 251 | ||
| 254 | export const make = Effect.fn("makeGitLabCli")(function* () { | 252 | export const make = Effect.fn("makeGitLabCli")(function* () { |
| 255 | const process = yield* VcsProcess; | 253 | const process = yield* VcsProcess.VcsProcess; |
| 256 | 254 | ||
| 257 | const execute: GitLabCliShape["execute"] = (input) => | 255 | const execute: GitLabCliShape["execute"] = (input) => |
| 258 | process | 256 | process |
| ... | @@ -286,13 +284,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () { | ... | @@ -286,13 +284,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () { |
| 286 | Effect.flatMap((raw) => | 284 | Effect.flatMap((raw) => |
| 287 | raw.length === 0 | 285 | raw.length === 0 |
| 288 | ? Effect.succeed([]) | 286 | ? Effect.succeed([]) |
| 289 | : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe( | 287 | : Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestListJson(raw)).pipe( |
| 290 | Effect.flatMap((decoded) => { | 288 | Effect.flatMap((decoded) => { |
| 291 | if (!Result.isSuccess(decoded)) { | 289 | if (!Result.isSuccess(decoded)) { |
| 292 | return Effect.fail( | 290 | return Effect.fail( |
| 293 | new GitLabCliError({ | 291 | new GitLabCliError({ |
| 294 | operation: "listMergeRequests", | 292 | operation: "listMergeRequests", |
| 295 | detail: `GitLab CLI returned invalid MR list JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, | 293 | detail: `GitLab CLI returned invalid MR list JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, |
| 296 | cause: decoded.failure, | 294 | cause: decoded.failure, |
| 297 | }), | 295 | }), |
| 298 | ); | 296 | ); |
| ... | @@ -310,13 +308,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () { | ... | @@ -310,13 +308,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () { |
| 310 | }).pipe( | 308 | }).pipe( |
| 311 | Effect.map((result) => result.stdout.trim()), | 309 | Effect.map((result) => result.stdout.trim()), |
| 312 | Effect.flatMap((raw) => | 310 | Effect.flatMap((raw) => |
| 313 | Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe( | 311 | Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestJson(raw)).pipe( |
| 314 | Effect.flatMap((decoded) => { | 312 | Effect.flatMap((decoded) => { |
| 315 | if (!Result.isSuccess(decoded)) { | 313 | if (!Result.isSuccess(decoded)) { |
| 316 | return Effect.fail( | 314 | return Effect.fail( |
| 317 | new GitLabCliError({ | 315 | new GitLabCliError({ |
| 318 | operation: "getMergeRequest", | 316 | operation: "getMergeRequest", |
| 319 | detail: `GitLab CLI returned invalid merge request JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, | 317 | detail: `GitLab CLI returned invalid merge request JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, |
| 320 | cause: decoded.failure, | 318 | cause: decoded.failure, |
| 321 | }), | 319 | }), |
| 322 | ); | 320 | ); |
apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts+7-5| ... | @@ -1,11 +1,13 @@ | ... | @@ -1,11 +1,13 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import { Effect, Layer, Option } from "effect"; | 2 | import { Effect, Layer, Option } from "effect"; |
| 3 | 3 | ||
| 4 | import { GitLabCli, type GitLabCliShape } from "./GitLabCli.ts"; | 4 | import * as GitLabCli from "./GitLabCli.ts"; |
| 5 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; | 5 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; |
| 6 | 6 | ||
| 7 | function makeProvider(gitlab: Partial<GitLabCliShape>) { | 7 | function makeProvider(gitlab: Partial<GitLabCli.GitLabCliShape>) { |
| 8 | return GitLabSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitLabCli)(gitlab))); | 8 | return GitLabSourceControlProvider.make().pipe( |
| 9 | Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)), | ||
| 10 | ); | ||
| 9 | } | 11 | } |
| 10 | 12 | ||
| 11 | it.effect("maps GitLab MR summaries into provider-neutral change requests", () => | 13 | 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", () = | ... | @@ -48,7 +50,7 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = |
| 48 | 50 | ||
| 49 | it.effect("lists GitLab MRs through provider-neutral input names", () => | 51 | it.effect("lists GitLab MRs through provider-neutral input names", () => |
| 50 | Effect.gen(function* () { | 52 | Effect.gen(function* () { |
| 51 | let listInput: Parameters<GitLabCliShape["listMergeRequests"]>[0] | null = null; | 53 | let listInput: Parameters<GitLabCli.GitLabCliShape["listMergeRequests"]>[0] | null = null; |
| 52 | const provider = yield* makeProvider({ | 54 | const provider = yield* makeProvider({ |
| 53 | listMergeRequests: (input) => { | 55 | listMergeRequests: (input) => { |
| 54 | listInput = input; | 56 | listInput = input; |
| ... | @@ -74,7 +76,7 @@ it.effect("lists GitLab MRs through provider-neutral input names", () => | ... | @@ -74,7 +76,7 @@ it.effect("lists GitLab MRs through provider-neutral input names", () => |
| 74 | 76 | ||
| 75 | it.effect("creates GitLab MRs through provider-neutral input names", () => | 77 | it.effect("creates GitLab MRs through provider-neutral input names", () => |
| 76 | Effect.gen(function* () { | 78 | Effect.gen(function* () { |
| 77 | let createInput: Parameters<GitLabCliShape["createMergeRequest"]>[0] | null = null; | 79 | let createInput: Parameters<GitLabCli.GitLabCliShape["createMergeRequest"]>[0] | null = null; |
| 78 | const provider = yield* makeProvider({ | 80 | const provider = yield* makeProvider({ |
| 79 | createMergeRequest: (input) => { | 81 | createMergeRequest: (input) => { |
| 80 | createInput = input; | 82 | createInput = input; |
apps/server/src/sourceControl/GitLabSourceControlProvider.ts+53-19| ... | @@ -1,10 +1,14 @@ | ... | @@ -1,10 +1,14 @@ |
| 1 | import { Effect, Layer, Option } from "effect"; | 1 | import { Effect, Layer, Option } from "effect"; |
| 2 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; | 2 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; |
| 3 | 3 | ||
| 4 | import { GitLabCli, type GitLabCliError, type GitLabMergeRequestSummary } from "./GitLabCli.ts"; | 4 | import * as GitLabCli from "./GitLabCli.ts"; |
| 5 | import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts"; | 5 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 6 | import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; | ||
| 6 | 7 | ||
| 7 | function providerError(operation: string, cause: GitLabCliError): SourceControlProviderError { | 8 | function providerError( |
| 9 | operation: string, | ||
| 10 | cause: GitLabCli.GitLabCliError, | ||
| 11 | ): SourceControlProviderError { | ||
| 8 | return new SourceControlProviderError({ | 12 | return new SourceControlProviderError({ |
| 9 | provider: "gitlab", | 13 | provider: "gitlab", |
| 10 | operation, | 14 | operation, |
| ... | @@ -13,7 +17,7 @@ function providerError(operation: string, cause: GitLabCliError): SourceControlP | ... | @@ -13,7 +17,7 @@ function providerError(operation: string, cause: GitLabCliError): SourceControlP |
| 13 | }); | 17 | }); |
| 14 | } | 18 | } |
| 15 | 19 | ||
| 16 | function toChangeRequest(summary: GitLabMergeRequestSummary): ChangeRequest { | 20 | function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest { |
| 17 | return { | 21 | return { |
| 18 | provider: "gitlab", | 22 | provider: "gitlab", |
| 19 | number: summary.number, | 23 | number: summary.number, |
| ... | @@ -35,27 +39,57 @@ function toChangeRequest(summary: GitLabMergeRequestSummary): ChangeRequest { | ... | @@ -35,27 +39,57 @@ function toChangeRequest(summary: GitLabMergeRequestSummary): ChangeRequest { |
| 35 | }; | 39 | }; |
| 36 | } | 40 | } |
| 37 | 41 | ||
| 38 | function sourceFromInput(input: { | 42 | function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { |
| 39 | readonly headSelector: string; | 43 | const output = SourceControlProviderDiscovery.combinedAuthOutput(input); |
| 40 | readonly source?: SourceControlRefSelector; | 44 | const account = SourceControlProviderDiscovery.matchFirst(output, [ |
| 41 | }): SourceControlRefSelector | undefined { | 45 | /Logged in to .* as\s+([^\s(]+)/iu, |
| 42 | if (input.source) { | 46 | /Logged in to .* account\s+([^\s(]+)/iu, |
| 43 | return input.source; | 47 | /account:\s*([^\s(]+)/iu, |
| 48 | ]); | ||
| 49 | const host = SourceControlProviderDiscovery.parseCliHost(output); | ||
| 50 | |||
| 51 | if (input.exitCode !== 0) { | ||
| 52 | return SourceControlProviderDiscovery.providerAuth({ | ||
| 53 | status: "unauthenticated", | ||
| 54 | host, | ||
| 55 | detail: | ||
| 56 | SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? | ||
| 57 | "Run `glab auth login` to authenticate GitLab CLI.", | ||
| 58 | }); | ||
| 59 | } | ||
| 60 | |||
| 61 | if (account) { | ||
| 62 | return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host }); | ||
| 44 | } | 63 | } |
| 45 | 64 | ||
| 46 | const match = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); | 65 | return SourceControlProviderDiscovery.providerAuth({ |
| 47 | const owner = match?.[1]?.trim(); | 66 | status: "unknown", |
| 48 | const refName = match?.[2]?.trim(); | 67 | host, |
| 49 | return owner && refName ? { owner, refName } : undefined; | 68 | detail: |
| 69 | SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? | ||
| 70 | "GitLab CLI auth status could not be parsed.", | ||
| 71 | }); | ||
| 50 | } | 72 | } |
| 51 | 73 | ||
| 74 | export const discovery = { | ||
| 75 | type: "cli", | ||
| 76 | kind: "gitlab", | ||
| 77 | label: "GitLab", | ||
| 78 | executable: "glab", | ||
| 79 | versionArgs: ["--version"], | ||
| 80 | authArgs: ["auth", "status"], | ||
| 81 | parseAuth: parseGitLabAuth, | ||
| 82 | installHint: | ||
| 83 | "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).", | ||
| 84 | } satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; | ||
| 85 | |||
| 52 | export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { | 86 | export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { |
| 53 | const gitlab = yield* GitLabCli; | 87 | const gitlab = yield* GitLabCli.GitLabCli; |
| 54 | 88 | ||
| 55 | return SourceControlProvider.of({ | 89 | return SourceControlProvider.SourceControlProvider.of({ |
| 56 | kind: "gitlab", | 90 | kind: "gitlab", |
| 57 | listChangeRequests: (input) => { | 91 | listChangeRequests: (input) => { |
| 58 | const source = sourceFromInput(input); | 92 | const source = SourceControlProvider.sourceControlRefFromInput(input); |
| 59 | return gitlab | 93 | return gitlab |
| 60 | .listMergeRequests({ | 94 | .listMergeRequests({ |
| 61 | cwd: input.cwd, | 95 | cwd: input.cwd, |
| ... | @@ -75,7 +109,7 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { | ... | @@ -75,7 +109,7 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { |
| 75 | Effect.mapError((error) => providerError("getChangeRequest", error)), | 109 | Effect.mapError((error) => providerError("getChangeRequest", error)), |
| 76 | ), | 110 | ), |
| 77 | createChangeRequest: (input) => { | 111 | createChangeRequest: (input) => { |
| 78 | const source = sourceFromInput(input); | 112 | const source = SourceControlProvider.sourceControlRefFromInput(input); |
| 79 | return gitlab | 113 | return gitlab |
| 80 | .createMergeRequest({ | 114 | .createMergeRequest({ |
| 81 | cwd: input.cwd, | 115 | cwd: input.cwd, |
| ... | @@ -107,4 +141,4 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { | ... | @@ -107,4 +141,4 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { |
| 107 | }); | 141 | }); |
| 108 | }); | 142 | }); |
| 109 | 143 | ||
| 110 | export const layer = Layer.effect(SourceControlProvider, make()); | 144 | export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); |
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+122-82| ... | @@ -5,8 +5,34 @@ import { ChildProcessSpawner } from "effect/unstable/process"; | ... | @@ -5,8 +5,34 @@ import { ChildProcessSpawner } from "effect/unstable/process"; |
| 5 | import { VcsProcessSpawnError } from "@t3tools/contracts"; | 5 | import { VcsProcessSpawnError } from "@t3tools/contracts"; |
| 6 | 6 | ||
| 7 | import { ServerConfig } from "../config.ts"; | 7 | import { ServerConfig } from "../config.ts"; |
| 8 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | ||
| 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 9 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 9 | import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts"; | 10 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; |
| 11 | import * as BitbucketApi from "./BitbucketApi.ts"; | ||
| 12 | import * as GitHubCli from "./GitHubCli.ts"; | ||
| 13 | import * as GitLabCli from "./GitLabCli.ts"; | ||
| 14 | import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; | ||
| 15 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; | ||
| 16 | |||
| 17 | const sourceControlProviderRegistryTestLayer = (input: { | ||
| 18 | readonly bitbucket: Partial<BitbucketApi.BitbucketApiShape>; | ||
| 19 | readonly process: Partial<VcsProcess.VcsProcessShape>; | ||
| 20 | }) => | ||
| 21 | SourceControlProviderRegistry.layer.pipe( | ||
| 22 | Layer.provide( | ||
| 23 | Layer.mergeAll( | ||
| 24 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( | ||
| 25 | Layer.provide(NodeServices.layer), | ||
| 26 | ), | ||
| 27 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), | ||
| 28 | Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), | ||
| 29 | Layer.mock(GitHubCli.GitHubCli)({}), | ||
| 30 | Layer.mock(GitLabCli.GitLabCli)({}), | ||
| 31 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), | ||
| 32 | Layer.mock(VcsProcess.VcsProcess)(input.process), | ||
| 33 | ), | ||
| 34 | ), | ||
| 35 | ); | ||
| 10 | 36 | ||
| 11 | const processOutput = ( | 37 | const processOutput = ( |
| 12 | stdout: string, | 38 | stdout: string, |
| ... | @@ -22,39 +48,53 @@ const processOutput = ( | ... | @@ -22,39 +48,53 @@ const processOutput = ( |
| 22 | stderrTruncated: false, | 48 | stderrTruncated: false, |
| 23 | }); | 49 | }); |
| 24 | 50 | ||
| 25 | it.effect("reports implemented tools separately from locally available CLIs", () => { | 51 | it.effect("reports implemented tools separately from locally available executables", () => { |
| 26 | const testLayer = layer.pipe( | 52 | const processMock = { |
| 27 | Layer.provide( | 53 | run: (input: VcsProcess.VcsProcessInput) => { |
| 28 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), | 54 | if (input.command === "git") { |
| 29 | ), | 55 | return Effect.succeed(processOutput("git version 2.51.0\n")); |
| 30 | Layer.provide( | 56 | } |
| 31 | Layer.mock(VcsProcess.VcsProcess)({ | 57 | if (input.command === "gh" && input.args[0] === "--version") { |
| 32 | run: (input) => { | 58 | return Effect.succeed(processOutput("gh version 2.83.0\n")); |
| 33 | if (input.command === "git") { | 59 | } |
| 34 | return Effect.succeed(processOutput("git version 2.51.0\n")); | 60 | if (input.command === "gh" && input.args.join(" ") === "auth status") { |
| 35 | } | 61 | return Effect.succeed( |
| 36 | if (input.command === "gh" && input.args[0] === "--version") { | 62 | processOutput(`github.com |
| 37 | return Effect.succeed(processOutput("gh version 2.83.0\n")); | ||
| 38 | } | ||
| 39 | if (input.command === "gh" && input.args.join(" ") === "auth status") { | ||
| 40 | return Effect.succeed( | ||
| 41 | processOutput(`github.com | ||
| 42 | Logged in to github.com account juliusmarminge (keyring) | 63 | Logged in to github.com account juliusmarminge (keyring) |
| 43 | - Active account: true | 64 | - Active account: true |
| 44 | - Git operations protocol: ssh | 65 | - Git operations protocol: ssh |
| 45 | - Token: gho_************************************ | 66 | - Token: gho_************************************ |
| 46 | - Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo' | 67 | - Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo' |
| 47 | `), | 68 | `), |
| 48 | ); | 69 | ); |
| 49 | } | 70 | } |
| 50 | return Effect.fail( | 71 | return Effect.fail( |
| 51 | new VcsProcessSpawnError({ | 72 | new VcsProcessSpawnError({ |
| 52 | operation: input.operation, | 73 | operation: input.operation, |
| 53 | command: input.command, | 74 | command: input.command, |
| 54 | cwd: input.cwd, | 75 | cwd: input.cwd, |
| 55 | cause: new Error(`${input.command} not found`), | 76 | cause: new Error(`${input.command} not found`), |
| 56 | }), | 77 | }), |
| 57 | ); | 78 | ); |
| 79 | }, | ||
| 80 | } satisfies Partial<VcsProcess.VcsProcessShape>; | ||
| 81 | const testLayer = SourceControlDiscovery.layer.pipe( | ||
| 82 | Layer.provide( | ||
| 83 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), | ||
| 84 | ), | ||
| 85 | Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), | ||
| 86 | Layer.provide( | ||
| 87 | sourceControlProviderRegistryTestLayer({ | ||
| 88 | process: processMock, | ||
| 89 | bitbucket: { | ||
| 90 | probeAuth: Effect.succeed({ | ||
| 91 | status: "unauthenticated", | ||
| 92 | account: Option.none(), | ||
| 93 | host: Option.some("bitbucket.org"), | ||
| 94 | detail: Option.some( | ||
| 95 | "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", | ||
| 96 | ), | ||
| 97 | }), | ||
| 58 | }, | 98 | }, |
| 59 | }), | 99 | }), |
| 60 | ), | 100 | ), |
| ... | @@ -62,7 +102,7 @@ Logged in to github.com account juliusmarminge (keyring) | ... | @@ -62,7 +102,7 @@ Logged in to github.com account juliusmarminge (keyring) |
| 62 | ); | 102 | ); |
| 63 | 103 | ||
| 64 | return Effect.gen(function* () { | 104 | return Effect.gen(function* () { |
| 65 | const discovery = yield* SourceControlDiscovery; | 105 | const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; |
| 66 | const result = yield* discovery.discover; | 106 | const result = yield* discovery.discover; |
| 67 | 107 | ||
| 68 | assert.deepStrictEqual( | 108 | assert.deepStrictEqual( |
| ... | @@ -79,7 +119,6 @@ Logged in to github.com account juliusmarminge (keyring) | ... | @@ -79,7 +119,6 @@ Logged in to github.com account juliusmarminge (keyring) |
| 79 | assert.deepStrictEqual( | 119 | assert.deepStrictEqual( |
| 80 | result.sourceControlProviders.map((item) => ({ | 120 | result.sourceControlProviders.map((item) => ({ |
| 81 | kind: item.kind, | 121 | kind: item.kind, |
| 82 | implemented: item.implemented, | ||
| 83 | status: item.status, | 122 | status: item.status, |
| 84 | auth: item.auth.status, | 123 | auth: item.auth.status, |
| 85 | account: item.auth.account, | 124 | account: item.auth.account, |
| ... | @@ -87,88 +126,89 @@ Logged in to github.com account juliusmarminge (keyring) | ... | @@ -87,88 +126,89 @@ Logged in to github.com account juliusmarminge (keyring) |
| 87 | [ | 126 | [ |
| 88 | { | 127 | { |
| 89 | kind: "github", | 128 | kind: "github", |
| 90 | implemented: true, | ||
| 91 | status: "available", | 129 | status: "available", |
| 92 | auth: "authenticated", | 130 | auth: "authenticated", |
| 93 | account: Option.some("juliusmarminge"), | 131 | account: Option.some("juliusmarminge"), |
| 94 | }, | 132 | }, |
| 95 | { | 133 | { |
| 96 | kind: "gitlab", | 134 | kind: "gitlab", |
| 97 | implemented: true, | ||
| 98 | status: "missing", | 135 | status: "missing", |
| 99 | auth: "unknown", | 136 | auth: "unknown", |
| 100 | account: Option.none(), | 137 | account: Option.none(), |
| 101 | }, | 138 | }, |
| 102 | { | 139 | { |
| 103 | kind: "azure-devops", | 140 | kind: "azure-devops", |
| 104 | implemented: false, | ||
| 105 | status: "missing", | 141 | status: "missing", |
| 106 | auth: "unknown", | 142 | auth: "unknown", |
| 107 | account: Option.none(), | 143 | account: Option.none(), |
| 108 | }, | 144 | }, |
| 109 | { | 145 | { |
| 110 | kind: "bitbucket", | 146 | kind: "bitbucket", |
| 111 | implemented: false, | 147 | status: "available", |
| 112 | status: "missing", | 148 | auth: "unauthenticated", |
| 113 | auth: "unknown", | ||
| 114 | account: Option.none(), | 149 | account: Option.none(), |
| 115 | }, | 150 | }, |
| 116 | ], | 151 | ], |
| 117 | ); | 152 | ); |
| 118 | const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); | 153 | const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); |
| 119 | assert.ok(bitbucket); | 154 | assert.ok(bitbucket); |
| 120 | assert.strictEqual("executable" in bitbucket, false); | 155 | assert.strictEqual(bitbucket.executable, undefined); |
| 121 | }).pipe(Effect.provide(testLayer)); | 156 | }).pipe(Effect.provide(testLayer)); |
| 122 | }); | 157 | }); |
| 123 | 158 | ||
| 124 | it.effect("probes provider authentication without exposing token details", () => { | 159 | it.effect("probes provider authentication without exposing token details", () => { |
| 125 | const testLayer = layer.pipe( | 160 | const processMock = { |
| 126 | Layer.provide( | 161 | run: (input: VcsProcess.VcsProcessInput) => { |
| 127 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), | 162 | if (input.args[0] === "--version") { |
| 128 | ), | 163 | return Effect.succeed(processOutput(`${input.command} version test\n`)); |
| 129 | Layer.provide( | 164 | } |
| 130 | Layer.mock(VcsProcess.VcsProcess)({ | 165 | if (input.command === "gh" && input.args.join(" ") === "auth status") { |
| 131 | run: (input) => { | 166 | return Effect.succeed( |
| 132 | if (input.args[0] === "--version") { | 167 | processOutput(`github.com |
| 133 | return Effect.succeed(processOutput(`${input.command} version test\n`)); | ||
| 134 | } | ||
| 135 | if (input.command === "gh" && input.args.join(" ") === "auth status") { | ||
| 136 | return Effect.succeed( | ||
| 137 | processOutput(`github.com | ||
| 138 | Logged in to github.com account octocat (keyring) | 168 | Logged in to github.com account octocat (keyring) |
| 139 | - Token: gho_************************************ | 169 | - Token: gho_************************************ |
| 140 | - Token scopes: 'repo' | 170 | - Token scopes: 'repo' |
| 141 | `), | 171 | `), |
| 142 | ); | 172 | ); |
| 143 | } | 173 | } |
| 144 | if (input.command === "glab" && input.args.join(" ") === "auth status") { | 174 | if (input.command === "glab" && input.args.join(" ") === "auth status") { |
| 145 | return Effect.succeed( | 175 | return Effect.succeed( |
| 146 | processOutput(`gitlab.com | 176 | processOutput(`gitlab.com |
| 147 | Logged in to gitlab.com as gitlab-user | 177 | Logged in to gitlab.com as gitlab-user |
| 148 | `), | 178 | `), |
| 149 | ); | 179 | ); |
| 150 | } | 180 | } |
| 151 | if ( | 181 | if ( |
| 152 | input.command === "az" && | 182 | input.command === "az" && |
| 153 | input.args.join(" ") === "account show --query user.name -o tsv" | 183 | input.args.join(" ") === "account show --query user.name -o tsv" |
| 154 | ) { | 184 | ) { |
| 155 | return Effect.succeed(processOutput("azure-user@example.com\n")); | 185 | return Effect.succeed(processOutput("azure-user@example.com\n")); |
| 156 | } | 186 | } |
| 157 | if (input.command === "bb" && input.args.join(" ") === "auth status") { | 187 | return Effect.fail( |
| 158 | return Effect.succeed( | 188 | new VcsProcessSpawnError({ |
| 159 | processOutput(`bitbucket.org | 189 | operation: input.operation, |
| 160 | Logged in as bitbucket-user | 190 | command: input.command, |
| 161 | `), | 191 | cwd: input.cwd, |
| 162 | ); | 192 | cause: new Error(`${input.command} not found`), |
| 163 | } | 193 | }), |
| 164 | return Effect.fail( | 194 | ); |
| 165 | new VcsProcessSpawnError({ | 195 | }, |
| 166 | operation: input.operation, | 196 | } satisfies Partial<VcsProcess.VcsProcessShape>; |
| 167 | command: input.command, | 197 | const testLayer = SourceControlDiscovery.layer.pipe( |
| 168 | cwd: input.cwd, | 198 | Layer.provide( |
| 169 | cause: new Error(`${input.command} not found`), | 199 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), |
| 170 | }), | 200 | ), |
| 171 | ); | 201 | Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), |
| 202 | Layer.provide( | ||
| 203 | sourceControlProviderRegistryTestLayer({ | ||
| 204 | process: processMock, | ||
| 205 | bitbucket: { | ||
| 206 | probeAuth: Effect.succeed({ | ||
| 207 | status: "authenticated", | ||
| 208 | account: Option.some("bitbucket-user"), | ||
| 209 | host: Option.some("bitbucket.org"), | ||
| 210 | detail: Option.none(), | ||
| 211 | }), | ||
| 172 | }, | 212 | }, |
| 173 | }), | 213 | }), |
| 174 | ), | 214 | ), |
| ... | @@ -176,7 +216,7 @@ Logged in as bitbucket-user | ... | @@ -176,7 +216,7 @@ Logged in as bitbucket-user |
| 176 | ); | 216 | ); |
| 177 | 217 | ||
| 178 | return Effect.gen(function* () { | 218 | return Effect.gen(function* () { |
| 179 | const discovery = yield* SourceControlDiscovery; | 219 | const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; |
| 180 | const result = yield* discovery.discover; | 220 | const result = yield* discovery.discover; |
| 181 | 221 | ||
| 182 | assert.deepStrictEqual( | 222 | assert.deepStrictEqual( |
| ... | @@ -207,9 +247,9 @@ Logged in as bitbucket-user | ... | @@ -207,9 +247,9 @@ Logged in as bitbucket-user |
| 207 | }, | 247 | }, |
| 208 | { | 248 | { |
| 209 | kind: "bitbucket", | 249 | kind: "bitbucket", |
| 210 | auth: "unknown", | 250 | auth: "authenticated", |
| 211 | account: Option.none(), | 251 | account: Option.some("bitbucket-user"), |
| 212 | detail: Option.some("Bitbucket provider support is not available yet."), | 252 | detail: Option.none(), |
| 213 | }, | 253 | }, |
| 214 | ], | 254 | ], |
| 215 | ); | 255 | ); |
apps/server/src/sourceControl/SourceControlDiscovery.ts+10-272| ... | @@ -1,8 +1,5 @@ | ... | @@ -1,8 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | type SourceControlProviderAuth, | ||
| 3 | type SourceControlDiscoveryResult, | 2 | type SourceControlDiscoveryResult, |
| 4 | type SourceControlProviderDiscoveryItem, | ||
| 5 | type SourceControlProviderKind, | ||
| 6 | type VcsDiscoveryItem, | 3 | type VcsDiscoveryItem, |
| 7 | type VcsDriverKind, | 4 | type VcsDriverKind, |
| 8 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| ... | @@ -10,6 +7,8 @@ import { Context, Effect, Layer, Option } from "effect"; | ... | @@ -10,6 +7,8 @@ import { Context, Effect, Layer, Option } from "effect"; |
| 10 | 7 | ||
| 11 | import { ServerConfig } from "../config.ts"; | 8 | import { ServerConfig } from "../config.ts"; |
| 12 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 9 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 10 | import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; | ||
| 11 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; | ||
| 13 | 12 | ||
| 14 | interface DiscoveryProbe { | 13 | interface DiscoveryProbe { |
| 15 | readonly label: string; | 14 | readonly label: string; |
| ... | @@ -25,18 +24,6 @@ type VcsProbe = DiscoveryProbe & { | ... | @@ -25,18 +24,6 @@ type VcsProbe = DiscoveryProbe & { |
| 25 | readonly versionArgs: ReadonlyArray<string>; | 24 | readonly versionArgs: ReadonlyArray<string>; |
| 26 | }; | 25 | }; |
| 27 | 26 | ||
| 28 | type ProviderProbe = DiscoveryProbe & { | ||
| 29 | readonly kind: SourceControlProviderKind; | ||
| 30 | readonly authArgs?: ReadonlyArray<string>; | ||
| 31 | readonly parseAuth?: (input: AuthProbeInput) => SourceControlProviderAuth; | ||
| 32 | }; | ||
| 33 | |||
| 34 | interface AuthProbeInput { | ||
| 35 | readonly stdout: string; | ||
| 36 | readonly stderr: string; | ||
| 37 | readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"]; | ||
| 38 | } | ||
| 39 | |||
| 40 | interface DiscoveryProbeResult<Kind extends string> { | 27 | interface DiscoveryProbeResult<Kind extends string> { |
| 41 | readonly kind: Kind; | 28 | readonly kind: Kind; |
| 42 | readonly label: string; | 29 | readonly label: string; |
| ... | @@ -67,203 +54,6 @@ const VCS_PROBES: ReadonlyArray<VcsProbe> = [ | ... | @@ -67,203 +54,6 @@ const VCS_PROBES: ReadonlyArray<VcsProbe> = [ |
| 67 | }, | 54 | }, |
| 68 | ]; | 55 | ]; |
| 69 | 56 | ||
| 70 | const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ | ||
| 71 | { | ||
| 72 | kind: "github", | ||
| 73 | label: "GitHub", | ||
| 74 | executable: "gh", | ||
| 75 | versionArgs: ["--version"], | ||
| 76 | authArgs: ["auth", "status"], | ||
| 77 | parseAuth: parseGitHubAuth, | ||
| 78 | implemented: true, | ||
| 79 | installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.", | ||
| 80 | }, | ||
| 81 | { | ||
| 82 | kind: "gitlab", | ||
| 83 | label: "GitLab", | ||
| 84 | executable: "glab", | ||
| 85 | versionArgs: ["--version"], | ||
| 86 | authArgs: ["auth", "status"], | ||
| 87 | parseAuth: parseGitLabAuth, | ||
| 88 | implemented: true, | ||
| 89 | installHint: | ||
| 90 | "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.", | ||
| 91 | }, | ||
| 92 | { | ||
| 93 | kind: "azure-devops", | ||
| 94 | label: "Azure DevOps", | ||
| 95 | executable: "az", | ||
| 96 | versionArgs: ["--version"], | ||
| 97 | authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], | ||
| 98 | parseAuth: parseAzureAuth, | ||
| 99 | implemented: false, | ||
| 100 | installHint: | ||
| 101 | "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.", | ||
| 102 | }, | ||
| 103 | { | ||
| 104 | kind: "bitbucket", | ||
| 105 | label: "Bitbucket", | ||
| 106 | implemented: false, | ||
| 107 | installHint: "Bitbucket provider support is not available yet.", | ||
| 108 | }, | ||
| 109 | ]; | ||
| 110 | |||
| 111 | function firstNonEmptyLine(text: string): Option.Option<string> { | ||
| 112 | const line = text | ||
| 113 | .split(/\r?\n/) | ||
| 114 | .map((entry) => entry.trim()) | ||
| 115 | .find((entry) => entry.length > 0); | ||
| 116 | return line === undefined ? Option.none() : Option.some(line); | ||
| 117 | } | ||
| 118 | |||
| 119 | function detailFromCause(cause: unknown): Option.Option<string> { | ||
| 120 | if (cause instanceof Error && cause.message.trim().length > 0) { | ||
| 121 | return Option.some(cause.message.trim()); | ||
| 122 | } | ||
| 123 | return Option.none(); | ||
| 124 | } | ||
| 125 | |||
| 126 | function authAccount(account: string | undefined): Option.Option<string> { | ||
| 127 | const trimmed = account?.trim(); | ||
| 128 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | ||
| 129 | } | ||
| 130 | |||
| 131 | function authHost(host: string | undefined): Option.Option<string> { | ||
| 132 | const trimmed = host?.trim(); | ||
| 133 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | ||
| 134 | } | ||
| 135 | |||
| 136 | function authDetail(detail: string | undefined): Option.Option<string> { | ||
| 137 | const trimmed = detail?.trim(); | ||
| 138 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | ||
| 139 | } | ||
| 140 | |||
| 141 | function providerAuth(input: { | ||
| 142 | readonly status: SourceControlProviderAuth["status"]; | ||
| 143 | readonly account?: string | undefined; | ||
| 144 | readonly host?: string | undefined; | ||
| 145 | readonly detail?: string | undefined; | ||
| 146 | }): SourceControlProviderAuth { | ||
| 147 | return { | ||
| 148 | status: input.status, | ||
| 149 | account: authAccount(input.account), | ||
| 150 | host: authHost(input.host), | ||
| 151 | detail: authDetail(input.detail), | ||
| 152 | }; | ||
| 153 | } | ||
| 154 | |||
| 155 | function unknownAuth(detail?: string): SourceControlProviderAuth { | ||
| 156 | return providerAuth({ status: "unknown", detail }); | ||
| 157 | } | ||
| 158 | |||
| 159 | function combinedAuthOutput(input: AuthProbeInput): string { | ||
| 160 | return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n"); | ||
| 161 | } | ||
| 162 | |||
| 163 | function sanitizedAuthLines(text: string): ReadonlyArray<string> { | ||
| 164 | return text | ||
| 165 | .split(/\r?\n/) | ||
| 166 | .map((entry) => entry.trim()) | ||
| 167 | .filter((entry) => entry.length > 0) | ||
| 168 | .filter((entry) => !/^[-\s]*token(?:\s+scopes?)?:/iu.test(entry)); | ||
| 169 | } | ||
| 170 | |||
| 171 | function firstSafeAuthLine(text: string): string | undefined { | ||
| 172 | return sanitizedAuthLines(text)[0]; | ||
| 173 | } | ||
| 174 | |||
| 175 | function parseCliHost(text: string): string | undefined { | ||
| 176 | return sanitizedAuthLines(text) | ||
| 177 | .map((line) => line.replace(/^[^a-z0-9]+/iu, "")) | ||
| 178 | .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line)); | ||
| 179 | } | ||
| 180 | |||
| 181 | function matchFirst(text: string, patterns: ReadonlyArray<RegExp>): string | undefined { | ||
| 182 | for (const pattern of patterns) { | ||
| 183 | const match = pattern.exec(text); | ||
| 184 | const value = match?.[1]?.trim(); | ||
| 185 | if (value && value.length > 0) return value; | ||
| 186 | } | ||
| 187 | return undefined; | ||
| 188 | } | ||
| 189 | |||
| 190 | function parseGitHubAuth(input: AuthProbeInput): SourceControlProviderAuth { | ||
| 191 | const output = combinedAuthOutput(input); | ||
| 192 | const account = matchFirst(output, [ | ||
| 193 | /Logged in to .* account\s+([^\s(]+)/iu, | ||
| 194 | /Logged in to .* as\s+([^\s(]+)/iu, | ||
| 195 | ]); | ||
| 196 | const host = parseCliHost(output); | ||
| 197 | |||
| 198 | if (input.exitCode !== 0) { | ||
| 199 | return providerAuth({ | ||
| 200 | status: "unauthenticated", | ||
| 201 | host, | ||
| 202 | detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", | ||
| 203 | }); | ||
| 204 | } | ||
| 205 | |||
| 206 | if (account) { | ||
| 207 | return providerAuth({ status: "authenticated", account, host }); | ||
| 208 | } | ||
| 209 | |||
| 210 | return providerAuth({ | ||
| 211 | status: "unknown", | ||
| 212 | host, | ||
| 213 | detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", | ||
| 214 | }); | ||
| 215 | } | ||
| 216 | |||
| 217 | function parseGitLabAuth(input: AuthProbeInput): SourceControlProviderAuth { | ||
| 218 | const output = combinedAuthOutput(input); | ||
| 219 | const account = matchFirst(output, [ | ||
| 220 | /Logged in to .* as\s+([^\s(]+)/iu, | ||
| 221 | /Logged in to .* account\s+([^\s(]+)/iu, | ||
| 222 | /account:\s*([^\s(]+)/iu, | ||
| 223 | ]); | ||
| 224 | const host = parseCliHost(output); | ||
| 225 | |||
| 226 | if (input.exitCode !== 0) { | ||
| 227 | return providerAuth({ | ||
| 228 | status: "unauthenticated", | ||
| 229 | host, | ||
| 230 | detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.", | ||
| 231 | }); | ||
| 232 | } | ||
| 233 | |||
| 234 | if (account) { | ||
| 235 | return providerAuth({ status: "authenticated", account, host }); | ||
| 236 | } | ||
| 237 | |||
| 238 | return providerAuth({ | ||
| 239 | status: "unknown", | ||
| 240 | host, | ||
| 241 | detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.", | ||
| 242 | }); | ||
| 243 | } | ||
| 244 | |||
| 245 | function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth { | ||
| 246 | const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); | ||
| 247 | |||
| 248 | if (input.exitCode !== 0) { | ||
| 249 | return providerAuth({ | ||
| 250 | status: "unauthenticated", | ||
| 251 | detail: | ||
| 252 | firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.", | ||
| 253 | }); | ||
| 254 | } | ||
| 255 | |||
| 256 | if (account && account.length > 0) { | ||
| 257 | return providerAuth({ status: "authenticated", account, host: "dev.azure.com" }); | ||
| 258 | } | ||
| 259 | |||
| 260 | return providerAuth({ | ||
| 261 | status: "unknown", | ||
| 262 | host: "dev.azure.com", | ||
| 263 | detail: "Azure CLI account status could not be parsed.", | ||
| 264 | }); | ||
| 265 | } | ||
| 266 | |||
| 267 | export interface SourceControlDiscoveryShape { | 57 | export interface SourceControlDiscoveryShape { |
| 268 | readonly discover: Effect.Effect<SourceControlDiscoveryResult>; | 58 | readonly discover: Effect.Effect<SourceControlDiscoveryResult>; |
| 269 | } | 59 | } |
| ... | @@ -278,8 +68,10 @@ export const layer = Layer.effect( | ... | @@ -278,8 +68,10 @@ export const layer = Layer.effect( |
| 278 | Effect.gen(function* () { | 68 | Effect.gen(function* () { |
| 279 | const config = yield* ServerConfig; | 69 | const config = yield* ServerConfig; |
| 280 | const process = yield* VcsProcess.VcsProcess; | 70 | const process = yield* VcsProcess.VcsProcess; |
| 71 | const sourceControlProviders = | ||
| 72 | yield* SourceControlProviderRegistry.SourceControlProviderRegistry; | ||
| 281 | 73 | ||
| 282 | const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>( | 74 | const probe = <Kind extends VcsDriverKind>( |
| 283 | input: DiscoveryProbe & { readonly kind: Kind }, | 75 | input: DiscoveryProbe & { readonly kind: Kind }, |
| 284 | ): Effect.Effect<DiscoveryProbeResult<Kind>> => { | 76 | ): Effect.Effect<DiscoveryProbeResult<Kind>> => { |
| 285 | const executable = input.executable; | 77 | const executable = input.executable; |
| ... | @@ -316,8 +108,9 @@ export const layer = Layer.effect( | ... | @@ -316,8 +108,9 @@ export const layer = Layer.effect( |
| 316 | executable, | 108 | executable, |
| 317 | implemented: input.implemented, | 109 | implemented: input.implemented, |
| 318 | status: "available" as const, | 110 | status: "available" as const, |
| 319 | version: Option.orElse(firstNonEmptyLine(result.stdout), () => | 111 | version: Option.orElse( |
| 320 | firstNonEmptyLine(result.stderr), | 112 | SourceControlProviderDiscovery.firstNonEmptyLine(result.stdout), |
| 113 | () => SourceControlProviderDiscovery.firstNonEmptyLine(result.stderr), | ||
| 321 | ), | 114 | ), |
| 322 | installHint: input.installHint, | 115 | installHint: input.installHint, |
| 323 | detail: Option.none<string>(), | 116 | detail: Option.none<string>(), |
| ... | @@ -332,74 +125,19 @@ export const layer = Layer.effect( | ... | @@ -332,74 +125,19 @@ export const layer = Layer.effect( |
| 332 | status: "missing" as const, | 125 | status: "missing" as const, |
| 333 | version: Option.none<string>(), | 126 | version: Option.none<string>(), |
| 334 | installHint: input.installHint, | 127 | installHint: input.installHint, |
| 335 | detail: detailFromCause(cause), | 128 | detail: SourceControlProviderDiscovery.detailFromCause(cause), |
| 336 | } satisfies DiscoveryProbeResult<Kind>), | 129 | } satisfies DiscoveryProbeResult<Kind>), |
| 337 | ), | 130 | ), |
| 338 | ); | 131 | ); |
| 339 | }; | 132 | }; |
| 340 | 133 | ||
| 341 | const probeProvider = (input: ProviderProbe) => | ||
| 342 | probe(input).pipe( | ||
| 343 | Effect.flatMap((item) => { | ||
| 344 | const executable = input.executable; | ||
| 345 | const authArgs = input.authArgs; | ||
| 346 | const parseAuth = input.parseAuth; | ||
| 347 | |||
| 348 | if (!executable || !authArgs || !parseAuth) { | ||
| 349 | return Effect.succeed({ | ||
| 350 | ...item, | ||
| 351 | auth: unknownAuth(input.installHint), | ||
| 352 | } satisfies SourceControlProviderDiscoveryItem); | ||
| 353 | } | ||
| 354 | |||
| 355 | if (item.status !== "available") { | ||
| 356 | return Effect.succeed({ | ||
| 357 | ...item, | ||
| 358 | auth: unknownAuth("CLI is not installed."), | ||
| 359 | } satisfies SourceControlProviderDiscoveryItem); | ||
| 360 | } | ||
| 361 | |||
| 362 | return process | ||
| 363 | .run({ | ||
| 364 | operation: "source-control.discovery.auth", | ||
| 365 | command: executable, | ||
| 366 | args: authArgs, | ||
| 367 | cwd: config.cwd, | ||
| 368 | allowNonZeroExit: true, | ||
| 369 | timeoutMs: 5_000, | ||
| 370 | maxOutputBytes: 8_000, | ||
| 371 | truncateOutputAtMaxBytes: true, | ||
| 372 | }) | ||
| 373 | .pipe( | ||
| 374 | Effect.map( | ||
| 375 | (result) => | ||
| 376 | ({ | ||
| 377 | ...item, | ||
| 378 | auth: parseAuth(result), | ||
| 379 | }) satisfies SourceControlProviderDiscoveryItem, | ||
| 380 | ), | ||
| 381 | Effect.catch((cause) => | ||
| 382 | Effect.succeed({ | ||
| 383 | ...item, | ||
| 384 | auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), | ||
| 385 | } satisfies SourceControlProviderDiscoveryItem), | ||
| 386 | ), | ||
| 387 | ); | ||
| 388 | }), | ||
| 389 | ); | ||
| 390 | |||
| 391 | return SourceControlDiscovery.of({ | 134 | return SourceControlDiscovery.of({ |
| 392 | discover: Effect.all({ | 135 | discover: Effect.all({ |
| 393 | versionControlSystems: Effect.all( | 136 | versionControlSystems: Effect.all( |
| 394 | VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray<Effect.Effect<VcsDiscoveryItem>>, | 137 | VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray<Effect.Effect<VcsDiscoveryItem>>, |
| 395 | { concurrency: "unbounded" }, | 138 | { concurrency: "unbounded" }, |
| 396 | ), | 139 | ), |
| 397 | sourceControlProviders: Effect.all( | 140 | sourceControlProviders: sourceControlProviders.discover, |
| 398 | SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probeProvider(entry)) as ReadonlyArray< | ||
| 399 | Effect.Effect<SourceControlProviderDiscoveryItem> | ||
| 400 | >, | ||
| 401 | { concurrency: "unbounded" }, | ||
| 402 | ), | ||
| 403 | }), | 141 | }), |
| 404 | }); | 142 | }); |
| 405 | }), | 143 | }), |
apps/server/src/sourceControl/SourceControlProvider.ts+27| ... | @@ -21,6 +21,33 @@ export interface SourceControlRefSelector { | ... | @@ -21,6 +21,33 @@ export interface SourceControlRefSelector { |
| 21 | readonly repository?: string; | 21 | readonly repository?: string; |
| 22 | } | 22 | } |
| 23 | 23 | ||
| 24 | export function parseSourceControlOwnerRef( | ||
| 25 | headSelector: string, | ||
| 26 | ): SourceControlRefSelector | undefined { | ||
| 27 | const match = /^([^:/\s]+):(.+)$/u.exec(headSelector.trim()); | ||
| 28 | const owner = match?.[1]?.trim(); | ||
| 29 | const refName = match?.[2]?.trim(); | ||
| 30 | return owner && refName ? { owner, refName } : undefined; | ||
| 31 | } | ||
| 32 | |||
| 33 | export function normalizeSourceBranch(headSelector: string): string { | ||
| 34 | return parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim(); | ||
| 35 | } | ||
| 36 | |||
| 37 | export function sourceBranch(input: { | ||
| 38 | readonly headSelector: string; | ||
| 39 | readonly source?: SourceControlRefSelector; | ||
| 40 | }): string { | ||
| 41 | return input.source?.refName ?? normalizeSourceBranch(input.headSelector); | ||
| 42 | } | ||
| 43 | |||
| 44 | export function sourceControlRefFromInput(input: { | ||
| 45 | readonly headSelector: string; | ||
| 46 | readonly source?: SourceControlRefSelector; | ||
| 47 | }): SourceControlRefSelector | undefined { | ||
| 48 | return input.source ?? parseSourceControlOwnerRef(input.headSelector); | ||
| 49 | } | ||
| 50 | |||
| 24 | export interface SourceControlProviderShape { | 51 | export interface SourceControlProviderShape { |
| 25 | readonly kind: SourceControlProviderKind; | 52 | readonly kind: SourceControlProviderKind; |
| 26 | readonly listChangeRequests: (input: { | 53 | readonly listChangeRequests: (input: { |
apps/server/src/sourceControl/SourceControlProviderDiscovery.ts created+237| ... | @@ -0,0 +1,237 @@ | ||
| 1 | import type { | ||
| 2 | SourceControlProviderAuth, | ||
| 3 | SourceControlProviderDiscoveryItem, | ||
| 4 | SourceControlProviderKind, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { Effect, Option } from "effect"; | ||
| 7 | |||
| 8 | import type * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 9 | |||
| 10 | export interface SourceControlAuthProbeInput { | ||
| 11 | readonly stdout: string; | ||
| 12 | readonly stderr: string; | ||
| 13 | readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"]; | ||
| 14 | } | ||
| 15 | |||
| 16 | interface SourceControlDiscoverySpecBase { | ||
| 17 | readonly kind: SourceControlProviderKind; | ||
| 18 | readonly label: string; | ||
| 19 | readonly installHint: string; | ||
| 20 | } | ||
| 21 | |||
| 22 | export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { | ||
| 23 | readonly type: "cli"; | ||
| 24 | readonly executable: string; | ||
| 25 | readonly versionArgs: ReadonlyArray<string>; | ||
| 26 | readonly authArgs: ReadonlyArray<string>; | ||
| 27 | readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; | ||
| 28 | }; | ||
| 29 | |||
| 30 | export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & { | ||
| 31 | readonly type: "api"; | ||
| 32 | readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>; | ||
| 33 | }; | ||
| 34 | |||
| 35 | export type SourceControlProviderDiscoverySpec = | ||
| 36 | | SourceControlCliDiscoverySpec | ||
| 37 | | SourceControlApiDiscoverySpec; | ||
| 38 | |||
| 39 | interface DiscoveryProbeResult { | ||
| 40 | readonly kind: SourceControlProviderKind; | ||
| 41 | readonly label: string; | ||
| 42 | readonly executable: string; | ||
| 43 | readonly status: "available" | "missing"; | ||
| 44 | readonly version: Option.Option<string>; | ||
| 45 | readonly installHint: string; | ||
| 46 | readonly detail: Option.Option<string>; | ||
| 47 | } | ||
| 48 | |||
| 49 | export function firstNonEmptyLine(text: string): Option.Option<string> { | ||
| 50 | const line = text | ||
| 51 | .split(/\r?\n/) | ||
| 52 | .map((entry) => entry.trim()) | ||
| 53 | .find((entry) => entry.length > 0); | ||
| 54 | return line === undefined ? Option.none() : Option.some(line); | ||
| 55 | } | ||
| 56 | |||
| 57 | export function detailFromCause(cause: unknown): Option.Option<string> { | ||
| 58 | if (cause instanceof Error && cause.message.trim().length > 0) { | ||
| 59 | return Option.some(cause.message.trim()); | ||
| 60 | } | ||
| 61 | return Option.none(); | ||
| 62 | } | ||
| 63 | |||
| 64 | function authAccount(account: string | undefined): Option.Option<string> { | ||
| 65 | const trimmed = account?.trim(); | ||
| 66 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | ||
| 67 | } | ||
| 68 | |||
| 69 | function authHost(host: string | undefined): Option.Option<string> { | ||
| 70 | const trimmed = host?.trim(); | ||
| 71 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | ||
| 72 | } | ||
| 73 | |||
| 74 | function authDetail(detail: string | undefined): Option.Option<string> { | ||
| 75 | const trimmed = detail?.trim(); | ||
| 76 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | ||
| 77 | } | ||
| 78 | |||
| 79 | export function providerAuth(input: { | ||
| 80 | readonly status: SourceControlProviderAuth["status"]; | ||
| 81 | readonly account?: string | undefined; | ||
| 82 | readonly host?: string | undefined; | ||
| 83 | readonly detail?: string | undefined; | ||
| 84 | }): SourceControlProviderAuth { | ||
| 85 | return { | ||
| 86 | status: input.status, | ||
| 87 | account: authAccount(input.account), | ||
| 88 | host: authHost(input.host), | ||
| 89 | detail: authDetail(input.detail), | ||
| 90 | }; | ||
| 91 | } | ||
| 92 | |||
| 93 | export function unknownAuth(detail?: string): SourceControlProviderAuth { | ||
| 94 | return providerAuth({ status: "unknown", detail }); | ||
| 95 | } | ||
| 96 | |||
| 97 | export function combinedAuthOutput(input: SourceControlAuthProbeInput): string { | ||
| 98 | return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n"); | ||
| 99 | } | ||
| 100 | |||
| 101 | function sanitizedAuthLines(text: string): ReadonlyArray<string> { | ||
| 102 | return text | ||
| 103 | .split(/\r?\n/) | ||
| 104 | .map((entry) => entry.trim()) | ||
| 105 | .filter((entry) => entry.length > 0) | ||
| 106 | .filter((entry) => !/^[-\s]*token(?:\s+scopes?)?:/iu.test(entry)); | ||
| 107 | } | ||
| 108 | |||
| 109 | export function firstSafeAuthLine(text: string): string | undefined { | ||
| 110 | return sanitizedAuthLines(text)[0]; | ||
| 111 | } | ||
| 112 | |||
| 113 | export function parseCliHost(text: string): string | undefined { | ||
| 114 | return sanitizedAuthLines(text) | ||
| 115 | .map((line) => line.replace(/^[^a-z0-9]+/iu, "")) | ||
| 116 | .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line)); | ||
| 117 | } | ||
| 118 | |||
| 119 | export function matchFirst(text: string, patterns: ReadonlyArray<RegExp>): string | undefined { | ||
| 120 | for (const pattern of patterns) { | ||
| 121 | const match = pattern.exec(text); | ||
| 122 | const value = match?.[1]?.trim(); | ||
| 123 | if (value && value.length > 0) return value; | ||
| 124 | } | ||
| 125 | return undefined; | ||
| 126 | } | ||
| 127 | |||
| 128 | function probeCli(input: { | ||
| 129 | readonly spec: SourceControlCliDiscoverySpec; | ||
| 130 | readonly process: VcsProcess.VcsProcessShape; | ||
| 131 | readonly cwd: string; | ||
| 132 | }): Effect.Effect<DiscoveryProbeResult> { | ||
| 133 | return input.process | ||
| 134 | .run({ | ||
| 135 | operation: "source-control.discovery.probe", | ||
| 136 | command: input.spec.executable, | ||
| 137 | args: input.spec.versionArgs, | ||
| 138 | cwd: input.cwd, | ||
| 139 | timeoutMs: 5_000, | ||
| 140 | maxOutputBytes: 8_000, | ||
| 141 | truncateOutputAtMaxBytes: true, | ||
| 142 | }) | ||
| 143 | .pipe( | ||
| 144 | Effect.map( | ||
| 145 | (result) => | ||
| 146 | ({ | ||
| 147 | kind: input.spec.kind, | ||
| 148 | label: input.spec.label, | ||
| 149 | executable: input.spec.executable, | ||
| 150 | status: "available" as const, | ||
| 151 | version: Option.orElse(firstNonEmptyLine(result.stdout), () => | ||
| 152 | firstNonEmptyLine(result.stderr), | ||
| 153 | ), | ||
| 154 | installHint: input.spec.installHint, | ||
| 155 | detail: Option.none<string>(), | ||
| 156 | }) satisfies DiscoveryProbeResult, | ||
| 157 | ), | ||
| 158 | Effect.catch((cause) => | ||
| 159 | Effect.succeed({ | ||
| 160 | kind: input.spec.kind, | ||
| 161 | label: input.spec.label, | ||
| 162 | executable: input.spec.executable, | ||
| 163 | status: "missing" as const, | ||
| 164 | version: Option.none<string>(), | ||
| 165 | installHint: input.spec.installHint, | ||
| 166 | detail: detailFromCause(cause), | ||
| 167 | } satisfies DiscoveryProbeResult), | ||
| 168 | ), | ||
| 169 | ); | ||
| 170 | } | ||
| 171 | |||
| 172 | export function probeSourceControlProvider(input: { | ||
| 173 | readonly spec: SourceControlProviderDiscoverySpec; | ||
| 174 | readonly process: VcsProcess.VcsProcessShape; | ||
| 175 | readonly cwd: string; | ||
| 176 | }): Effect.Effect<SourceControlProviderDiscoveryItem> { | ||
| 177 | if (input.spec.type === "api") { | ||
| 178 | return input.spec.probeAuth.pipe( | ||
| 179 | Effect.map( | ||
| 180 | (auth) => | ||
| 181 | ({ | ||
| 182 | kind: input.spec.kind, | ||
| 183 | label: input.spec.label, | ||
| 184 | status: "available" as const, | ||
| 185 | version: Option.none<string>(), | ||
| 186 | installHint: input.spec.installHint, | ||
| 187 | detail: Option.none<string>(), | ||
| 188 | auth, | ||
| 189 | }) satisfies SourceControlProviderDiscoveryItem, | ||
| 190 | ), | ||
| 191 | ); | ||
| 192 | } | ||
| 193 | |||
| 194 | const spec = input.spec; | ||
| 195 | |||
| 196 | return probeCli({ | ||
| 197 | spec, | ||
| 198 | process: input.process, | ||
| 199 | cwd: input.cwd, | ||
| 200 | }).pipe( | ||
| 201 | Effect.flatMap((item) => { | ||
| 202 | if (item.status !== "available") { | ||
| 203 | return Effect.succeed({ | ||
| 204 | ...item, | ||
| 205 | auth: unknownAuth("Hosting integration command was not found on the server PATH."), | ||
| 206 | } satisfies SourceControlProviderDiscoveryItem); | ||
| 207 | } | ||
| 208 | |||
| 209 | return input.process | ||
| 210 | .run({ | ||
| 211 | operation: "source-control.discovery.auth", | ||
| 212 | command: spec.executable, | ||
| 213 | args: spec.authArgs, | ||
| 214 | cwd: input.cwd, | ||
| 215 | allowNonZeroExit: true, | ||
| 216 | timeoutMs: 5_000, | ||
| 217 | maxOutputBytes: 8_000, | ||
| 218 | truncateOutputAtMaxBytes: true, | ||
| 219 | }) | ||
| 220 | .pipe( | ||
| 221 | Effect.map( | ||
| 222 | (result) => | ||
| 223 | ({ | ||
| 224 | ...item, | ||
| 225 | auth: spec.parseAuth(result), | ||
| 226 | }) satisfies SourceControlProviderDiscoveryItem, | ||
| 227 | ), | ||
| 228 | Effect.catch((cause) => | ||
| 229 | Effect.succeed({ | ||
| 230 | ...item, | ||
| 231 | auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), | ||
| 232 | } satisfies SourceControlProviderDiscoveryItem), | ||
| 233 | ), | ||
| 234 | ); | ||
| 235 | }), | ||
| 236 | ); | ||
| 237 | } | ||
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+48-9| ... | @@ -1,11 +1,16 @@ | ... | @@ -1,11 +1,16 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { DateTime, Effect, Layer, Option } from "effect"; | 3 | import { DateTime, Effect, Layer, Option } from "effect"; |
| 3 | 4 | ||
| 4 | import { GitHubCli } from "./GitHubCli.ts"; | 5 | import { ServerConfig } from "../config.ts"; |
| 5 | import { GitLabCli } from "./GitLabCli.ts"; | 6 | import type * as VcsDriver from "../vcs/VcsDriver.ts"; |
| 7 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | ||
| 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 9 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | ||
| 10 | import * as BitbucketApi from "./BitbucketApi.ts"; | ||
| 11 | import * as GitHubCli from "./GitHubCli.ts"; | ||
| 12 | import * as GitLabCli from "./GitLabCli.ts"; | ||
| 6 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; | 13 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 7 | import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; | ||
| 8 | import type { VcsDriverShape } from "../vcs/VcsDriver.ts"; | ||
| 9 | 14 | ||
| 10 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | 15 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); |
| 11 | 16 | ||
| ... | @@ -29,10 +34,10 @@ function makeRegistry(input: { | ... | @@ -29,10 +34,10 @@ function makeRegistry(input: { |
| 29 | expiresAt: Option.none(), | 34 | expiresAt: Option.none(), |
| 30 | }, | 35 | }, |
| 31 | }), | 36 | }), |
| 32 | } satisfies Partial<VcsDriverShape>; | 37 | } satisfies Partial<VcsDriver.VcsDriverShape>; |
| 33 | 38 | ||
| 34 | const registryLayer = Layer.mock(VcsDriverRegistry)({ | 39 | const registryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ |
| 35 | get: () => Effect.succeed(driver as unknown as VcsDriverShape), | 40 | get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriverShape), |
| 36 | resolve: () => | 41 | resolve: () => |
| 37 | Effect.succeed({ | 42 | Effect.succeed({ |
| 38 | kind: "git", | 43 | kind: "git", |
| ... | @@ -46,13 +51,23 @@ function makeRegistry(input: { | ... | @@ -46,13 +51,23 @@ function makeRegistry(input: { |
| 46 | expiresAt: Option.none(), | 51 | expiresAt: Option.none(), |
| 47 | }, | 52 | }, |
| 48 | }, | 53 | }, |
| 49 | driver: driver as unknown as VcsDriverShape, | 54 | driver: driver as unknown as VcsDriver.VcsDriverShape, |
| 50 | }), | 55 | }), |
| 51 | }); | 56 | }); |
| 52 | 57 | ||
| 53 | return SourceControlProviderRegistry.make().pipe( | 58 | return SourceControlProviderRegistry.make().pipe( |
| 54 | Effect.provide( | 59 | Effect.provide( |
| 55 | Layer.mergeAll(registryLayer, Layer.mock(GitHubCli)({}), Layer.mock(GitLabCli)({})), | 60 | Layer.mergeAll( |
| 61 | registryLayer, | ||
| 62 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), | ||
| 63 | Layer.mock(BitbucketApi.BitbucketApi)({}), | ||
| 64 | Layer.mock(GitHubCli.GitHubCli)({}), | ||
| 65 | Layer.mock(GitLabCli.GitLabCli)({}), | ||
| 66 | Layer.mock(VcsProcess.VcsProcess)({}), | ||
| 67 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( | ||
| 68 | Layer.provide(NodeServices.layer), | ||
| 69 | ), | ||
| 70 | ), | ||
| 56 | ), | 71 | ), |
| 57 | ); | 72 | ); |
| 58 | } | 73 | } |
| ... | @@ -93,6 +108,30 @@ it.effect("routes GitLab remotes to the GitLab provider", () => | ... | @@ -93,6 +108,30 @@ it.effect("routes GitLab remotes to the GitLab provider", () => |
| 93 | }), | 108 | }), |
| 94 | ); | 109 | ); |
| 95 | 110 | ||
| 111 | it.effect("routes Bitbucket remotes to the Bitbucket provider", () => | ||
| 112 | Effect.gen(function* () { | ||
| 113 | const registry = yield* makeRegistry({ | ||
| 114 | remotes: [{ name: "origin", url: "git@bitbucket.org:pingdotgg/t3code.git" }], | ||
| 115 | }); | ||
| 116 | |||
| 117 | const provider = yield* registry.resolve({ cwd: "/repo" }); | ||
| 118 | |||
| 119 | assert.strictEqual(provider.kind, "bitbucket"); | ||
| 120 | }), | ||
| 121 | ); | ||
| 122 | |||
| 123 | it.effect("routes Azure DevOps remotes to the Azure DevOps provider", () => | ||
| 124 | Effect.gen(function* () { | ||
| 125 | const registry = yield* makeRegistry({ | ||
| 126 | remotes: [{ name: "origin", url: "https://dev.azure.com/acme/project/_git/repo" }], | ||
| 127 | }); | ||
| 128 | |||
| 129 | const provider = yield* registry.resolve({ cwd: "/repo" }); | ||
| 130 | |||
| 131 | assert.strictEqual(provider.kind, "azure-devops"); | ||
| 132 | }), | ||
| 133 | ); | ||
| 134 | |||
| 96 | it.effect("falls back to a non-origin remote when origin is not configured", () => | 135 | it.effect("falls back to a non-origin remote when origin is not configured", () => |
| 97 | Effect.gen(function* () { | 136 | Effect.gen(function* () { |
| 98 | const registry = yield* makeRegistry({ | 137 | const registry = yield* makeRegistry({ |
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+104-22| ... | @@ -1,40 +1,46 @@ | ... | @@ -1,40 +1,46 @@ |
| 1 | import { Cache, Context, Duration, Effect, Exit, Layer } from "effect"; | 1 | import { Cache, Context, Duration, Effect, Exit, Layer } from "effect"; |
| 2 | import { SourceControlProviderError } from "@t3tools/contracts"; | 2 | import { |
| 3 | SourceControlProviderError, | ||
| 4 | type SourceControlProviderDiscoveryItem, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 3 | import type { SourceControlProviderKind } from "@t3tools/contracts"; | 6 | import type { SourceControlProviderKind } from "@t3tools/contracts"; |
| 4 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; | 7 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; |
| 5 | 8 | ||
| 6 | import { | 9 | import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; |
| 7 | SourceControlProvider, | 10 | import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; |
| 8 | type SourceControlProviderContext, | ||
| 9 | type SourceControlProviderShape, | ||
| 10 | } from "./SourceControlProvider.ts"; | ||
| 11 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; | 11 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 12 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; | 12 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; |
| 13 | import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; | 13 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 14 | import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; | ||
| 15 | import { ServerConfig } from "../config.ts"; | ||
| 16 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | ||
| 17 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 14 | 18 | ||
| 15 | const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; | 19 | const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; |
| 16 | const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); | 20 | const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); |
| 17 | 21 | ||
| 18 | export interface SourceControlProviderRegistration { | 22 | export interface SourceControlProviderRegistration { |
| 19 | readonly kind: SourceControlProviderKind; | 23 | readonly kind: SourceControlProviderKind; |
| 20 | readonly provider: SourceControlProviderShape; | 24 | readonly provider: SourceControlProvider.SourceControlProviderShape; |
| 25 | readonly discovery: SourceControlProviderDiscovery.SourceControlProviderDiscoverySpec; | ||
| 21 | } | 26 | } |
| 22 | 27 | ||
| 23 | export interface SourceControlProviderHandle { | 28 | export interface SourceControlProviderHandle { |
| 24 | readonly provider: SourceControlProviderShape; | 29 | readonly provider: SourceControlProvider.SourceControlProviderShape; |
| 25 | readonly context: SourceControlProviderContext | null; | 30 | readonly context: SourceControlProvider.SourceControlProviderContext | null; |
| 26 | } | 31 | } |
| 27 | 32 | ||
| 28 | export interface SourceControlProviderRegistryShape { | 33 | export interface SourceControlProviderRegistryShape { |
| 29 | readonly get: ( | 34 | readonly get: ( |
| 30 | kind: SourceControlProviderKind, | 35 | kind: SourceControlProviderKind, |
| 31 | ) => Effect.Effect<SourceControlProviderShape, SourceControlProviderError>; | 36 | ) => Effect.Effect<SourceControlProvider.SourceControlProviderShape, SourceControlProviderError>; |
| 32 | readonly resolveHandle: (input: { | 37 | readonly resolveHandle: (input: { |
| 33 | readonly cwd: string; | 38 | readonly cwd: string; |
| 34 | }) => Effect.Effect<SourceControlProviderHandle, SourceControlProviderError>; | 39 | }) => Effect.Effect<SourceControlProviderHandle, SourceControlProviderError>; |
| 35 | readonly resolve: (input: { | 40 | readonly resolve: (input: { |
| 36 | readonly cwd: string; | 41 | readonly cwd: string; |
| 37 | }) => Effect.Effect<SourceControlProviderShape, SourceControlProviderError>; | 42 | }) => Effect.Effect<SourceControlProvider.SourceControlProviderShape, SourceControlProviderError>; |
| 43 | readonly discover: Effect.Effect<ReadonlyArray<SourceControlProviderDiscoveryItem>>; | ||
| 38 | } | 44 | } |
| 39 | 45 | ||
| 40 | export class SourceControlProviderRegistry extends Context.Service< | 46 | export class SourceControlProviderRegistry extends Context.Service< |
| ... | @@ -42,7 +48,9 @@ export class SourceControlProviderRegistry extends Context.Service< | ... | @@ -42,7 +48,9 @@ export class SourceControlProviderRegistry extends Context.Service< |
| 42 | SourceControlProviderRegistryShape | 48 | SourceControlProviderRegistryShape |
| 43 | >()("t3/source-control/SourceControlProviderRegistry") {} | 49 | >()("t3/source-control/SourceControlProviderRegistry") {} |
| 44 | 50 | ||
| 45 | function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProviderShape { | 51 | function unsupportedProvider( |
| 52 | kind: SourceControlProviderKind, | ||
| 53 | ): SourceControlProvider.SourceControlProviderShape { | ||
| 46 | const unsupported = (operation: string) => | 54 | const unsupported = (operation: string) => |
| 47 | Effect.fail( | 55 | Effect.fail( |
| 48 | new SourceControlProviderError({ | 56 | new SourceControlProviderError({ |
| ... | @@ -52,7 +60,7 @@ function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProv | ... | @@ -52,7 +60,7 @@ function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProv |
| 52 | }), | 60 | }), |
| 53 | ); | 61 | ); |
| 54 | 62 | ||
| 55 | return SourceControlProvider.of({ | 63 | return SourceControlProvider.SourceControlProvider.of({ |
| 56 | kind, | 64 | kind, |
| 57 | listChangeRequests: () => unsupported("listChangeRequests"), | 65 | listChangeRequests: () => unsupported("listChangeRequests"), |
| 58 | getChangeRequest: () => unsupported("getChangeRequest"), | 66 | getChangeRequest: () => unsupported("getChangeRequest"), |
| ... | @@ -78,7 +86,7 @@ function selectProviderContext( | ... | @@ -78,7 +86,7 @@ function selectProviderContext( |
| 78 | readonly name: string; | 86 | readonly name: string; |
| 79 | readonly url: string; | 87 | readonly url: string; |
| 80 | }>, | 88 | }>, |
| 81 | ): SourceControlProviderContext | null { | 89 | ): SourceControlProvider.SourceControlProviderContext | null { |
| 82 | const candidates = remotes | 90 | const candidates = remotes |
| 83 | .map((remote) => { | 91 | .map((remote) => { |
| 84 | const provider = detectSourceControlProviderFromRemoteUrl(remote.url); | 92 | const provider = detectSourceControlProviderFromRemoteUrl(remote.url); |
| ... | @@ -90,7 +98,7 @@ function selectProviderContext( | ... | @@ -90,7 +98,7 @@ function selectProviderContext( |
| 90 | } | 98 | } |
| 91 | : null; | 99 | : null; |
| 92 | }) | 100 | }) |
| 93 | .filter((value): value is SourceControlProviderContext => value !== null); | 101 | .filter((value): value is SourceControlProvider.SourceControlProviderContext => value !== null); |
| 94 | 102 | ||
| 95 | return ( | 103 | return ( |
| 96 | candidates.find((candidate) => candidate.remoteName === "origin") ?? | 104 | candidates.find((candidate) => candidate.remoteName === "origin") ?? |
| ... | @@ -100,12 +108,60 @@ function selectProviderContext( | ... | @@ -100,12 +108,60 @@ function selectProviderContext( |
| 100 | ); | 108 | ); |
| 101 | } | 109 | } |
| 102 | 110 | ||
| 111 | function bindProviderContext( | ||
| 112 | provider: SourceControlProvider.SourceControlProviderShape, | ||
| 113 | context: SourceControlProvider.SourceControlProviderContext | null, | ||
| 114 | ): SourceControlProvider.SourceControlProviderShape { | ||
| 115 | if (context === null) { | ||
| 116 | return provider; | ||
| 117 | } | ||
| 118 | |||
| 119 | return SourceControlProvider.SourceControlProvider.of({ | ||
| 120 | kind: provider.kind, | ||
| 121 | listChangeRequests: (input) => | ||
| 122 | provider.listChangeRequests({ | ||
| 123 | ...input, | ||
| 124 | context: input.context ?? context, | ||
| 125 | }), | ||
| 126 | getChangeRequest: (input) => | ||
| 127 | provider.getChangeRequest({ | ||
| 128 | ...input, | ||
| 129 | context: input.context ?? context, | ||
| 130 | }), | ||
| 131 | createChangeRequest: (input) => | ||
| 132 | provider.createChangeRequest({ | ||
| 133 | ...input, | ||
| 134 | context: input.context ?? context, | ||
| 135 | }), | ||
| 136 | getRepositoryCloneUrls: (input) => | ||
| 137 | provider.getRepositoryCloneUrls({ | ||
| 138 | ...input, | ||
| 139 | context: input.context ?? context, | ||
| 140 | }), | ||
| 141 | createRepository: (input) => provider.createRepository(input), | ||
| 142 | getDefaultBranch: (input) => | ||
| 143 | provider.getDefaultBranch({ | ||
| 144 | ...input, | ||
| 145 | context: input.context ?? context, | ||
| 146 | }), | ||
| 147 | checkoutChangeRequest: (input) => | ||
| 148 | provider.checkoutChangeRequest({ | ||
| 149 | ...input, | ||
| 150 | context: input.context ?? context, | ||
| 151 | }), | ||
| 152 | }); | ||
| 153 | } | ||
| 154 | |||
| 103 | export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( | 155 | export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( |
| 104 | function* (registrations: ReadonlyArray<SourceControlProviderRegistration>) { | 156 | function* (registrations: ReadonlyArray<SourceControlProviderRegistration>) { |
| 105 | const vcsRegistry = yield* VcsDriverRegistry; | 157 | const config = yield* ServerConfig; |
| 106 | const providers = new Map<SourceControlProviderKind, SourceControlProviderShape>( | 158 | const process = yield* VcsProcess.VcsProcess; |
| 107 | registrations.map((registration) => [registration.kind, registration.provider]), | 159 | const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; |
| 108 | ); | 160 | const providers = new Map< |
| 161 | SourceControlProviderKind, | ||
| 162 | SourceControlProvider.SourceControlProviderShape | ||
| 163 | >(registrations.map((registration) => [registration.kind, registration.provider])); | ||
| 164 | const discoverySpecs = registrations.map((registration) => registration.discovery); | ||
| 109 | 165 | ||
| 110 | const get: SourceControlProviderRegistryShape["get"] = (kind) => | 166 | const get: SourceControlProviderRegistryShape["get"] = (kind) => |
| 111 | Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); | 167 | Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); |
| ... | @@ -125,7 +181,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit | ... | @@ -125,7 +181,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit |
| 125 | 181 | ||
| 126 | const providerContextCache = yield* Cache.makeWith< | 182 | const providerContextCache = yield* Cache.makeWith< |
| 127 | string, | 183 | string, |
| 128 | SourceControlProviderContext | null, | 184 | SourceControlProvider.SourceControlProviderContext | null, |
| 129 | SourceControlProviderError | 185 | SourceControlProviderError |
| 130 | >(detectProviderContext, { | 186 | >(detectProviderContext, { |
| 131 | capacity: PROVIDER_DETECTION_CACHE_CAPACITY, | 187 | capacity: PROVIDER_DETECTION_CACHE_CAPACITY, |
| ... | @@ -136,8 +192,9 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit | ... | @@ -136,8 +192,9 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit |
| 136 | Cache.get(providerContextCache, input.cwd).pipe( | 192 | Cache.get(providerContextCache, input.cwd).pipe( |
| 137 | Effect.map((context) => { | 193 | Effect.map((context) => { |
| 138 | const kind = context?.provider.kind ?? "unknown"; | 194 | const kind = context?.provider.kind ?? "unknown"; |
| 195 | const provider = providers.get(kind) ?? unsupportedProvider(kind); | ||
| 139 | return { | 196 | return { |
| 140 | provider: providers.get(kind) ?? unsupportedProvider(kind), | 197 | provider: bindProviderContext(provider, context), |
| 141 | context, | 198 | context, |
| 142 | } satisfies SourceControlProviderHandle; | 199 | } satisfies SourceControlProviderHandle; |
| 143 | }), | 200 | }), |
| ... | @@ -147,6 +204,16 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit | ... | @@ -147,6 +204,16 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit |
| 147 | get, | 204 | get, |
| 148 | resolveHandle, | 205 | resolveHandle, |
| 149 | resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), | 206 | resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), |
| 207 | discover: Effect.all( | ||
| 208 | discoverySpecs.map((spec) => | ||
| 209 | SourceControlProviderDiscovery.probeSourceControlProvider({ | ||
| 210 | spec, | ||
| 211 | process, | ||
| 212 | cwd: config.cwd, | ||
| 213 | }), | ||
| 214 | ), | ||
| 215 | { concurrency: "unbounded" }, | ||
| 216 | ), | ||
| 150 | }); | 217 | }); |
| 151 | }, | 218 | }, |
| 152 | ); | 219 | ); |
| ... | @@ -154,14 +221,29 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit | ... | @@ -154,14 +221,29 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit |
| 154 | export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () { | 221 | export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () { |
| 155 | const github = yield* GitHubSourceControlProvider.make(); | 222 | const github = yield* GitHubSourceControlProvider.make(); |
| 156 | const gitlab = yield* GitLabSourceControlProvider.make(); | 223 | const gitlab = yield* GitLabSourceControlProvider.make(); |
| 224 | const bitbucket = yield* BitbucketSourceControlProvider.make(); | ||
| 225 | const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery(); | ||
| 226 | const azureDevOps = yield* AzureDevOpsSourceControlProvider.make(); | ||
| 157 | return yield* makeWithProviders([ | 227 | return yield* makeWithProviders([ |
| 158 | { | 228 | { |
| 159 | kind: "github", | 229 | kind: "github", |
| 160 | provider: github, | 230 | provider: github, |
| 231 | discovery: GitHubSourceControlProvider.discovery, | ||
| 161 | }, | 232 | }, |
| 162 | { | 233 | { |
| 163 | kind: "gitlab", | 234 | kind: "gitlab", |
| 164 | provider: gitlab, | 235 | provider: gitlab, |
| 236 | discovery: GitLabSourceControlProvider.discovery, | ||
| 237 | }, | ||
| 238 | { | ||
| 239 | kind: "azure-devops", | ||
| 240 | provider: azureDevOps, | ||
| 241 | discovery: AzureDevOpsSourceControlProvider.discovery, | ||
| 242 | }, | ||
| 243 | { | ||
| 244 | kind: "bitbucket", | ||
| 245 | provider: bitbucket, | ||
| 246 | discovery: bitbucketDiscovery, | ||
| 165 | }, | 247 | }, |
| 166 | ]); | 248 | ]); |
| 167 | }); | 249 | }); |
apps/server/src/sourceControl/SourceControlRepositoryService.test.ts+17-21| ... | @@ -6,14 +6,10 @@ import { ChildProcessSpawner } from "effect/unstable/process"; | ... | @@ -6,14 +6,10 @@ import { ChildProcessSpawner } from "effect/unstable/process"; |
| 6 | import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts"; | 6 | import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts"; |
| 7 | 7 | ||
| 8 | import { ServerConfig } from "../config.ts"; | 8 | import { ServerConfig } from "../config.ts"; |
| 9 | import { | 9 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 10 | GitVcsDriver, | 10 | import type * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 11 | type ExecuteGitResult, | 11 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 12 | type GitVcsDriverShape, | 12 | import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts"; |
| 13 | } from "../vcs/GitVcsDriver.ts"; | ||
| 14 | import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; | ||
| 15 | import type { SourceControlProviderShape } from "./SourceControlProvider.ts"; | ||
| 16 | import { SourceControlRepositoryService, layer } from "./SourceControlRepositoryService.ts"; | ||
| 17 | 13 | ||
| 18 | const CLONE_URLS = { | 14 | const CLONE_URLS = { |
| 19 | nameWithOwner: "octocat/t3code", | 15 | nameWithOwner: "octocat/t3code", |
| ... | @@ -22,8 +18,8 @@ const CLONE_URLS = { | ... | @@ -22,8 +18,8 @@ const CLONE_URLS = { |
| 22 | }; | 18 | }; |
| 23 | 19 | ||
| 24 | function makeProvider( | 20 | function makeProvider( |
| 25 | overrides: Partial<SourceControlProviderShape> = {}, | 21 | overrides: Partial<SourceControlProvider.SourceControlProviderShape> = {}, |
| 26 | ): SourceControlProviderShape { | 22 | ): SourceControlProvider.SourceControlProviderShape { |
| 27 | const unsupported = (operation: string) => | 23 | const unsupported = (operation: string) => |
| 28 | Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect< | 24 | Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect< |
| 29 | never, | 25 | never, |
| ... | @@ -43,7 +39,7 @@ function makeProvider( | ... | @@ -43,7 +39,7 @@ function makeProvider( |
| 43 | }; | 39 | }; |
| 44 | } | 40 | } |
| 45 | 41 | ||
| 46 | function processOutput(): ExecuteGitResult { | 42 | function processOutput(): GitVcsDriver.ExecuteGitResult { |
| 47 | return { | 43 | return { |
| 48 | exitCode: ChildProcessSpawner.ExitCode(0), | 44 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 49 | stdout: "", | 45 | stdout: "", |
| ... | @@ -54,17 +50,17 @@ function processOutput(): ExecuteGitResult { | ... | @@ -54,17 +50,17 @@ function processOutput(): ExecuteGitResult { |
| 54 | } | 50 | } |
| 55 | 51 | ||
| 56 | function makeLayer(input: { | 52 | function makeLayer(input: { |
| 57 | readonly provider?: SourceControlProviderShape; | 53 | readonly provider?: SourceControlProvider.SourceControlProviderShape; |
| 58 | readonly git?: Partial<GitVcsDriverShape>; | 54 | readonly git?: Partial<GitVcsDriver.GitVcsDriverShape>; |
| 59 | }) { | 55 | }) { |
| 60 | return layer.pipe( | 56 | return SourceControlRepositoryService.layer.pipe( |
| 61 | Layer.provide( | 57 | Layer.provide( |
| 62 | Layer.mock(SourceControlProviderRegistry)({ | 58 | Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ |
| 63 | get: () => Effect.succeed(input.provider ?? makeProvider()), | 59 | get: () => Effect.succeed(input.provider ?? makeProvider()), |
| 64 | }), | 60 | }), |
| 65 | ), | 61 | ), |
| 66 | Layer.provide( | 62 | Layer.provide( |
| 67 | Layer.mock(GitVcsDriver)({ | 63 | Layer.mock(GitVcsDriver.GitVcsDriver)({ |
| 68 | execute: () => Effect.succeed(processOutput()), | 64 | execute: () => Effect.succeed(processOutput()), |
| 69 | ensureRemote: () => Effect.succeed("origin"), | 65 | ensureRemote: () => Effect.succeed("origin"), |
| 70 | pushCurrentBranch: () => | 66 | pushCurrentBranch: () => |
| ... | @@ -93,7 +89,7 @@ it.effect("looks up repositories through the requested provider without search", | ... | @@ -93,7 +89,7 @@ it.effect("looks up repositories through the requested provider without search", |
| 93 | }); | 89 | }); |
| 94 | 90 | ||
| 95 | return Effect.gen(function* () { | 91 | return Effect.gen(function* () { |
| 96 | const service = yield* SourceControlRepositoryService; | 92 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 97 | const result = yield* service.lookupRepository({ | 93 | const result = yield* service.lookupRepository({ |
| 98 | provider: "github", | 94 | provider: "github", |
| 99 | repository: "octocat/t3code", | 95 | repository: "octocat/t3code", |
| ... | @@ -115,7 +111,7 @@ it.effect("clones a looked-up repository into the requested destination", () => | ... | @@ -115,7 +111,7 @@ it.effect("clones a looked-up repository into the requested destination", () => |
| 115 | const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = []; | 111 | const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = []; |
| 116 | 112 | ||
| 117 | yield* Effect.gen(function* () { | 113 | yield* Effect.gen(function* () { |
| 118 | const service = yield* SourceControlRepositoryService; | 114 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 119 | const result = yield* service.cloneRepository({ | 115 | const result = yield* service.cloneRepository({ |
| 120 | provider: "github", | 116 | provider: "github", |
| 121 | repository: "octocat/t3code", | 117 | repository: "octocat/t3code", |
| ... | @@ -167,7 +163,7 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up | ... | @@ -167,7 +163,7 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up |
| 167 | }); | 163 | }); |
| 168 | 164 | ||
| 169 | return Effect.gen(function* () { | 165 | return Effect.gen(function* () { |
| 170 | const service = yield* SourceControlRepositoryService; | 166 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 171 | const result = yield* service.publishRepository({ | 167 | const result = yield* service.publishRepository({ |
| 172 | cwd: "/workspace", | 168 | cwd: "/workspace", |
| 173 | provider: "github", | 169 | provider: "github", |
| ... | @@ -222,7 +218,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => { | ... | @@ -222,7 +218,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => { |
| 222 | const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; | 218 | const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; |
| 223 | 219 | ||
| 224 | return Effect.gen(function* () { | 220 | return Effect.gen(function* () { |
| 225 | const service = yield* SourceControlRepositoryService; | 221 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 226 | const result = yield* service.publishRepository({ | 222 | const result = yield* service.publishRepository({ |
| 227 | cwd: "/workspace", | 223 | cwd: "/workspace", |
| 228 | provider: "github", | 224 | provider: "github", |
| ... | @@ -258,7 +254,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => { | ... | @@ -258,7 +254,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => { |
| 258 | it.effect("publish succeeds with status remote_added when the local repo has no commits", () => { | 254 | it.effect("publish succeeds with status remote_added when the local repo has no commits", () => { |
| 259 | let pushCalls = 0; | 255 | let pushCalls = 0; |
| 260 | return Effect.gen(function* () { | 256 | return Effect.gen(function* () { |
| 261 | const service = yield* SourceControlRepositoryService; | 257 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 262 | const result = yield* service.publishRepository({ | 258 | const result = yield* service.publishRepository({ |
| 263 | cwd: "/workspace", | 259 | cwd: "/workspace", |
| 264 | provider: "github", | 260 | provider: "github", |
apps/server/src/sourceControl/SourceControlRepositoryService.ts+7-7| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import OS from "node:os"; | 1 | import * as NodeOS from "node:os"; |
| 2 | import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect"; | 2 | import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| ... | @@ -15,8 +15,8 @@ import { | ... | @@ -15,8 +15,8 @@ import { |
| 15 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 16 | 16 | ||
| 17 | import { ServerConfig } from "../config.ts"; | 17 | import { ServerConfig } from "../config.ts"; |
| 18 | import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; | 18 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 19 | import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; | 19 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 20 | 20 | ||
| 21 | export interface SourceControlRepositoryServiceShape { | 21 | export interface SourceControlRepositoryServiceShape { |
| 22 | readonly lookupRepository: ( | 22 | readonly lookupRepository: ( |
| ... | @@ -102,10 +102,10 @@ function selectRemoteUrl( | ... | @@ -102,10 +102,10 @@ function selectRemoteUrl( |
| 102 | 102 | ||
| 103 | function expandHomePath(input: string, path: Path.Path): string { | 103 | function expandHomePath(input: string, path: Path.Path): string { |
| 104 | if (input === "~") { | 104 | if (input === "~") { |
| 105 | return OS.homedir(); | 105 | return NodeOS.homedir(); |
| 106 | } | 106 | } |
| 107 | if (input.startsWith("~/") || input.startsWith("~\\")) { | 107 | if (input.startsWith("~/") || input.startsWith("~\\")) { |
| 108 | return path.join(OS.homedir(), input.slice(2)); | 108 | return path.join(NodeOS.homedir(), input.slice(2)); |
| 109 | } | 109 | } |
| 110 | return input; | 110 | return input; |
| 111 | } | 111 | } |
| ... | @@ -113,9 +113,9 @@ function expandHomePath(input: string, path: Path.Path): string { | ... | @@ -113,9 +113,9 @@ function expandHomePath(input: string, path: Path.Path): string { |
| 113 | export const make = Effect.fn("makeSourceControlRepositoryService")(function* () { | 113 | export const make = Effect.fn("makeSourceControlRepositoryService")(function* () { |
| 114 | const config = yield* ServerConfig; | 114 | const config = yield* ServerConfig; |
| 115 | const fileSystem = yield* FileSystem.FileSystem; | 115 | const fileSystem = yield* FileSystem.FileSystem; |
| 116 | const git = yield* GitVcsDriver; | 116 | const git = yield* GitVcsDriver.GitVcsDriver; |
| 117 | const path = yield* Path.Path; | 117 | const path = yield* Path.Path; |
| 118 | const providers = yield* SourceControlProviderRegistry; | 118 | const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; |
| 119 | 119 | ||
| 120 | const ensureConcreteProvider = (input: { | 120 | const ensureConcreteProvider = (input: { |
| 121 | readonly operation: string; | 121 | readonly operation: string; |
apps/server/src/sourceControl/azureDevOpsPullRequests.ts created+106| ... | @@ -0,0 +1,106 @@ | ||
| 1 | import { Cause, DateTime, Exit, Option, Result, Schema } from "effect"; | ||
| 2 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | ||
| 3 | import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; | ||
| 4 | |||
| 5 | export interface NormalizedAzureDevOpsPullRequestRecord { | ||
| 6 | readonly number: number; | ||
| 7 | readonly title: string; | ||
| 8 | readonly url: string; | ||
| 9 | readonly baseRefName: string; | ||
| 10 | readonly headRefName: string; | ||
| 11 | readonly state: "open" | "closed" | "merged"; | ||
| 12 | readonly updatedAt: Option.Option<DateTime.Utc>; | ||
| 13 | } | ||
| 14 | |||
| 15 | const AzureDevOpsPullRequestSchema = Schema.Struct({ | ||
| 16 | pullRequestId: PositiveInt, | ||
| 17 | title: TrimmedNonEmptyString, | ||
| 18 | url: Schema.optional(Schema.String), | ||
| 19 | sourceRefName: TrimmedNonEmptyString, | ||
| 20 | targetRefName: TrimmedNonEmptyString, | ||
| 21 | status: Schema.String, | ||
| 22 | creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | ||
| 23 | closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | ||
| 24 | _links: Schema.optional( | ||
| 25 | Schema.Struct({ | ||
| 26 | web: Schema.optional( | ||
| 27 | Schema.Struct({ | ||
| 28 | href: Schema.String, | ||
| 29 | }), | ||
| 30 | ), | ||
| 31 | }), | ||
| 32 | ), | ||
| 33 | }); | ||
| 34 | |||
| 35 | function trimOptionalString(value: string | null | undefined): string | null { | ||
| 36 | const trimmed = value?.trim() ?? ""; | ||
| 37 | return trimmed.length > 0 ? trimmed : null; | ||
| 38 | } | ||
| 39 | |||
| 40 | function normalizeRefName(refName: string): string { | ||
| 41 | return refName.trim().replace(/^refs\/heads\//, ""); | ||
| 42 | } | ||
| 43 | |||
| 44 | function normalizeAzureDevOpsPullRequestState(status: string): "open" | "closed" | "merged" { | ||
| 45 | switch (status.trim().toLowerCase()) { | ||
| 46 | case "completed": | ||
| 47 | return "merged"; | ||
| 48 | case "abandoned": | ||
| 49 | return "closed"; | ||
| 50 | default: | ||
| 51 | return "open"; | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | function normalizeAzureDevOpsPullRequestRecord( | ||
| 56 | raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>, | ||
| 57 | ): NormalizedAzureDevOpsPullRequestRecord { | ||
| 58 | return { | ||
| 59 | number: raw.pullRequestId, | ||
| 60 | title: raw.title, | ||
| 61 | url: trimOptionalString(raw._links?.web?.href) ?? trimOptionalString(raw.url) ?? "", | ||
| 62 | baseRefName: normalizeRefName(raw.targetRefName), | ||
| 63 | headRefName: normalizeRefName(raw.sourceRefName), | ||
| 64 | state: normalizeAzureDevOpsPullRequestState(raw.status), | ||
| 65 | updatedAt: (raw.closedDate ?? Option.none()).pipe( | ||
| 66 | Option.orElse(() => raw.creationDate ?? Option.none()), | ||
| 67 | ), | ||
| 68 | }; | ||
| 69 | } | ||
| 70 | |||
| 71 | const decodeAzureDevOpsPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); | ||
| 72 | const decodeAzureDevOpsPullRequest = decodeJsonResult(AzureDevOpsPullRequestSchema); | ||
| 73 | const decodeAzureDevOpsPullRequestEntry = Schema.decodeUnknownExit(AzureDevOpsPullRequestSchema); | ||
| 74 | |||
| 75 | export const formatAzureDevOpsJsonDecodeError = formatSchemaError; | ||
| 76 | |||
| 77 | export function decodeAzureDevOpsPullRequestListJson( | ||
| 78 | raw: string, | ||
| 79 | ): Result.Result< | ||
| 80 | ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>, | ||
| 81 | Cause.Cause<Schema.SchemaError> | ||
| 82 | > { | ||
| 83 | const result = decodeAzureDevOpsPullRequestList(raw); | ||
| 84 | if (Result.isSuccess(result)) { | ||
| 85 | const pullRequests: NormalizedAzureDevOpsPullRequestRecord[] = []; | ||
| 86 | for (const entry of result.success) { | ||
| 87 | const decodedEntry = decodeAzureDevOpsPullRequestEntry(entry); | ||
| 88 | if (Exit.isFailure(decodedEntry)) { | ||
| 89 | continue; | ||
| 90 | } | ||
| 91 | pullRequests.push(normalizeAzureDevOpsPullRequestRecord(decodedEntry.value)); | ||
| 92 | } | ||
| 93 | return Result.succeed(pullRequests); | ||
| 94 | } | ||
| 95 | return Result.fail(result.failure); | ||
| 96 | } | ||
| 97 | |||
| 98 | export function decodeAzureDevOpsPullRequestJson( | ||
| 99 | raw: string, | ||
| 100 | ): Result.Result<NormalizedAzureDevOpsPullRequestRecord, Cause.Cause<Schema.SchemaError>> { | ||
| 101 | const result = decodeAzureDevOpsPullRequest(raw); | ||
| 102 | if (Result.isSuccess(result)) { | ||
| 103 | return Result.succeed(normalizeAzureDevOpsPullRequestRecord(result.success)); | ||
| 104 | } | ||
| 105 | return Result.fail(result.failure); | ||
| 106 | } | ||
apps/server/src/sourceControl/bitbucketPullRequests.ts created+104| ... | @@ -0,0 +1,104 @@ | ||
| 1 | import { DateTime, Option, Schema } from "effect"; | ||
| 2 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | export interface NormalizedBitbucketPullRequestRecord { | ||
| 5 | readonly number: number; | ||
| 6 | readonly title: string; | ||
| 7 | readonly url: string; | ||
| 8 | readonly baseRefName: string; | ||
| 9 | readonly headRefName: string; | ||
| 10 | readonly state: "open" | "closed" | "merged"; | ||
| 11 | readonly updatedAt: Option.Option<DateTime.Utc>; | ||
| 12 | readonly isCrossRepository?: boolean; | ||
| 13 | readonly headRepositoryNameWithOwner?: string | null; | ||
| 14 | readonly headRepositoryOwnerLogin?: string | null; | ||
| 15 | } | ||
| 16 | |||
| 17 | export const BitbucketRepositoryRefSchema = Schema.Struct({ | ||
| 18 | full_name: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | ||
| 19 | workspace: Schema.optional( | ||
| 20 | Schema.NullOr( | ||
| 21 | Schema.Struct({ | ||
| 22 | slug: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | ||
| 23 | }), | ||
| 24 | ), | ||
| 25 | ), | ||
| 26 | }); | ||
| 27 | |||
| 28 | export const BitbucketPullRequestBranchSchema = Schema.Struct({ | ||
| 29 | repository: Schema.optional(Schema.NullOr(BitbucketRepositoryRefSchema)), | ||
| 30 | branch: Schema.Struct({ | ||
| 31 | name: TrimmedNonEmptyString, | ||
| 32 | }), | ||
| 33 | }); | ||
| 34 | |||
| 35 | export const BitbucketPullRequestSchema = Schema.Struct({ | ||
| 36 | id: PositiveInt, | ||
| 37 | title: TrimmedNonEmptyString, | ||
| 38 | state: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 39 | updated_on: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | ||
| 40 | links: Schema.Struct({ | ||
| 41 | html: Schema.Struct({ | ||
| 42 | href: TrimmedNonEmptyString, | ||
| 43 | }), | ||
| 44 | }), | ||
| 45 | source: BitbucketPullRequestBranchSchema, | ||
| 46 | destination: BitbucketPullRequestBranchSchema, | ||
| 47 | }); | ||
| 48 | |||
| 49 | export const BitbucketPullRequestListSchema = Schema.Struct({ | ||
| 50 | values: Schema.Array(BitbucketPullRequestSchema), | ||
| 51 | next: Schema.optional(TrimmedNonEmptyString), | ||
| 52 | }); | ||
| 53 | |||
| 54 | function trimOptionalString(value: string | null | undefined): string | null { | ||
| 55 | const trimmed = value?.trim() ?? ""; | ||
| 56 | return trimmed.length > 0 ? trimmed : null; | ||
| 57 | } | ||
| 58 | |||
| 59 | function repositoryOwner(repository: Schema.Schema.Type<typeof BitbucketRepositoryRefSchema>) { | ||
| 60 | return ( | ||
| 61 | trimOptionalString(repository.workspace?.slug) ?? | ||
| 62 | (repository.full_name?.includes("/") ? (repository.full_name.split("/")[0] ?? null) : null) | ||
| 63 | ); | ||
| 64 | } | ||
| 65 | |||
| 66 | function normalizeBitbucketPullRequestState(state: string | null | undefined) { | ||
| 67 | switch (state?.trim().toUpperCase()) { | ||
| 68 | case "MERGED": | ||
| 69 | return "merged" as const; | ||
| 70 | case "DECLINED": | ||
| 71 | case "SUPERSEDED": | ||
| 72 | return "closed" as const; | ||
| 73 | case "OPEN": | ||
| 74 | default: | ||
| 75 | return "open" as const; | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | export function normalizeBitbucketPullRequestRecord( | ||
| 80 | raw: Schema.Schema.Type<typeof BitbucketPullRequestSchema>, | ||
| 81 | ): NormalizedBitbucketPullRequestRecord { | ||
| 82 | const headRepositoryNameWithOwner = trimOptionalString(raw.source.repository?.full_name); | ||
| 83 | const baseRepositoryNameWithOwner = trimOptionalString(raw.destination.repository?.full_name); | ||
| 84 | const headRepositoryOwnerLogin = raw.source.repository | ||
| 85 | ? repositoryOwner(raw.source.repository) | ||
| 86 | : null; | ||
| 87 | const isCrossRepository = | ||
| 88 | headRepositoryNameWithOwner !== null && | ||
| 89 | baseRepositoryNameWithOwner !== null && | ||
| 90 | headRepositoryNameWithOwner !== baseRepositoryNameWithOwner; | ||
| 91 | |||
| 92 | return { | ||
| 93 | number: raw.id, | ||
| 94 | title: raw.title, | ||
| 95 | url: raw.links.html.href, | ||
| 96 | baseRefName: raw.destination.branch.name, | ||
| 97 | headRefName: raw.source.branch.name, | ||
| 98 | state: normalizeBitbucketPullRequestState(raw.state), | ||
| 99 | updatedAt: raw.updated_on ?? Option.none(), | ||
| 100 | ...(isCrossRepository ? { isCrossRepository: true } : {}), | ||
| 101 | ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), | ||
| 102 | ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), | ||
| 103 | }; | ||
| 104 | } | ||
apps/server/src/vcs/GitVcsDriver.ts+111-109| ... | @@ -18,9 +18,9 @@ import { | ... | @@ -18,9 +18,9 @@ import { |
| 18 | type VcsStatusInput, | 18 | type VcsStatusInput, |
| 19 | type VcsStatusResult, | 19 | type VcsStatusResult, |
| 20 | } from "@t3tools/contracts"; | 20 | } from "@t3tools/contracts"; |
| 21 | import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; | 21 | import * as GitVcsDriverCore from "./GitVcsDriverCore.ts"; |
| 22 | import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts"; | 22 | import * as VcsDriver from "./VcsDriver.ts"; |
| 23 | import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts"; | 23 | import * as VcsProcess from "./VcsProcess.ts"; |
| 24 | 24 | ||
| 25 | export interface ExecuteGitInput { | 25 | export interface ExecuteGitInput { |
| 26 | readonly operation: string; | 26 | readonly operation: string; |
| ... | @@ -304,7 +304,7 @@ function parseGitRemoteVerboseOutput( | ... | @@ -304,7 +304,7 @@ function parseGitRemoteVerboseOutput( |
| 304 | } | 304 | } |
| 305 | 305 | ||
| 306 | const gitCommand = ( | 306 | const gitCommand = ( |
| 307 | process: VcsProcessShape, | 307 | process: VcsProcess.VcsProcessShape, |
| 308 | operation: string, | 308 | operation: string, |
| 309 | cwd: string, | 309 | cwd: string, |
| 310 | args: ReadonlyArray<string>, | 310 | args: ReadonlyArray<string>, |
| ... | @@ -335,7 +335,7 @@ const gitCommand = ( | ... | @@ -335,7 +335,7 @@ const gitCommand = ( |
| 335 | }); | 335 | }); |
| 336 | 336 | ||
| 337 | export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () { | 337 | export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () { |
| 338 | const process = yield* VcsProcess; | 338 | const process = yield* VcsProcess.VcsProcess; |
| 339 | const capabilities = { | 339 | const capabilities = { |
| 340 | kind: "git" as const, | 340 | kind: "git" as const, |
| 341 | supportsWorktrees: true, | 341 | supportsWorktrees: true, |
| ... | @@ -345,7 +345,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( | ... | @@ -345,7 +345,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 345 | ignoreClassifier: "native" as const, | 345 | ignoreClassifier: "native" as const, |
| 346 | }; | 346 | }; |
| 347 | 347 | ||
| 348 | const isInsideWorkTree: VcsDriverShape["isInsideWorkTree"] = (cwd) => | 348 | const isInsideWorkTree: VcsDriver.VcsDriverShape["isInsideWorkTree"] = (cwd) => |
| 349 | gitCommand( | 349 | gitCommand( |
| 350 | process, | 350 | process, |
| 351 | "GitVcsDriver.isInsideWorkTree", | 351 | "GitVcsDriver.isInsideWorkTree", |
| ... | @@ -358,7 +358,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( | ... | @@ -358,7 +358,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 358 | }, | 358 | }, |
| 359 | ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true")); | 359 | ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true")); |
| 360 | 360 | ||
| 361 | const execute: VcsDriverShape["execute"] = (input) => | 361 | const execute: VcsDriver.VcsDriverShape["execute"] = (input) => |
| 362 | gitCommand(process, input.operation, input.cwd, input.args, { | 362 | gitCommand(process, input.operation, input.cwd, input.args, { |
| 363 | ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), | 363 | ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), |
| 364 | ...(input.env !== undefined ? { env: input.env } : {}), | 364 | ...(input.env !== undefined ? { env: input.env } : {}), |
| ... | @@ -370,33 +370,33 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( | ... | @@ -370,33 +370,33 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 370 | : {}), | 370 | : {}), |
| 371 | }); | 371 | }); |
| 372 | 372 | ||
| 373 | const detectRepository: VcsDriverShape["detectRepository"] = Effect.fn("detectRepository")( | 373 | const detectRepository: VcsDriver.VcsDriverShape["detectRepository"] = Effect.fn( |
| 374 | function* (cwd) { | 374 | "detectRepository", |
| 375 | if (!(yield* isInsideWorkTree(cwd))) { | 375 | )(function* (cwd) { |
| 376 | return null; | 376 | if (!(yield* isInsideWorkTree(cwd))) { |
| 377 | } | 377 | return null; |
| 378 | } | ||
| 378 | 379 | ||
| 379 | const root = yield* gitCommand(process, "GitVcsDriver.detectRepository.root", cwd, [ | 380 | const root = yield* gitCommand(process, "GitVcsDriver.detectRepository.root", cwd, [ |
| 380 | "rev-parse", | 381 | "rev-parse", |
| 381 | "--show-toplevel", | 382 | "--show-toplevel", |
| 382 | ]); | 383 | ]); |
| 383 | const gitCommonDir = yield* gitCommand( | 384 | const gitCommonDir = yield* gitCommand( |
| 384 | process, | 385 | process, |
| 385 | "GitVcsDriver.detectRepository.commonDir", | 386 | "GitVcsDriver.detectRepository.commonDir", |
| 386 | cwd, | 387 | cwd, |
| 387 | ["rev-parse", "--git-common-dir"], | 388 | ["rev-parse", "--git-common-dir"], |
| 388 | ).pipe(Effect.catch(() => Effect.succeed(null))); | 389 | ).pipe(Effect.catch(() => Effect.succeed(null))); |
| 389 | 390 | ||
| 390 | return { | 391 | return { |
| 391 | kind: "git" as const, | 392 | kind: "git" as const, |
| 392 | rootPath: root.stdout.trim(), | 393 | rootPath: root.stdout.trim(), |
| 393 | metadataPath: gitCommonDir?.stdout.trim() || null, | 394 | metadataPath: gitCommonDir?.stdout.trim() || null, |
| 394 | freshness: yield* nowFreshness(), | 395 | freshness: yield* nowFreshness(), |
| 395 | }; | 396 | }; |
| 396 | }, | 397 | }); |
| 397 | ); | ||
| 398 | 398 | ||
| 399 | const listWorkspaceFiles: VcsDriverShape["listWorkspaceFiles"] = (cwd) => | 399 | const listWorkspaceFiles: VcsDriver.VcsDriverShape["listWorkspaceFiles"] = (cwd) => |
| 400 | gitCommand( | 400 | gitCommand( |
| 401 | process, | 401 | process, |
| 402 | "GitVcsDriver.listWorkspaceFiles", | 402 | "GitVcsDriver.listWorkspaceFiles", |
| ... | @@ -438,98 +438,100 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( | ... | @@ -438,98 +438,100 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 438 | ), | 438 | ), |
| 439 | ); | 439 | ); |
| 440 | 440 | ||
| 441 | const listRemotes: VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")(function* (cwd) { | 441 | const listRemotes: VcsDriver.VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")( |
| 442 | const result = yield* gitCommand(process, "GitVcsDriver.listRemotes", cwd, ["remote", "-v"], { | 442 | function* (cwd) { |
| 443 | allowNonZeroExit: true, | 443 | const result = yield* gitCommand(process, "GitVcsDriver.listRemotes", cwd, ["remote", "-v"], { |
| 444 | timeoutMs: 5_000, | 444 | allowNonZeroExit: true, |
| 445 | maxOutputBytes: 64 * 1024, | 445 | timeoutMs: 5_000, |
| 446 | }); | 446 | maxOutputBytes: 64 * 1024, |
| 447 | |||
| 448 | if (result.exitCode !== 0) { | ||
| 449 | return yield* new VcsProcessExitError({ | ||
| 450 | operation: "GitVcsDriver.listRemotes", | ||
| 451 | command: "git remote -v", | ||
| 452 | cwd, | ||
| 453 | exitCode: result.exitCode, | ||
| 454 | detail: result.stderr.trim() || "git remote -v failed", | ||
| 455 | }); | 447 | }); |
| 456 | } | ||
| 457 | 448 | ||
| 458 | const parsed = parseGitRemoteVerboseOutput(result.stdout); | 449 | if (result.exitCode !== 0) { |
| 459 | const remotes = Array.from(parsed.entries()).flatMap(([name, remote]) => { | 450 | return yield* new VcsProcessExitError({ |
| 460 | if (!remote.url) { | 451 | operation: "GitVcsDriver.listRemotes", |
| 461 | return []; | 452 | command: "git remote -v", |
| 453 | cwd, | ||
| 454 | exitCode: result.exitCode, | ||
| 455 | detail: result.stderr.trim() || "git remote -v failed", | ||
| 456 | }); | ||
| 462 | } | 457 | } |
| 463 | return [ | ||
| 464 | { | ||
| 465 | name, | ||
| 466 | url: remote.url, | ||
| 467 | pushUrl: remote.pushUrl ? Option.some(remote.pushUrl) : Option.none(), | ||
| 468 | isPrimary: name === "origin", | ||
| 469 | }, | ||
| 470 | ]; | ||
| 471 | }); | ||
| 472 | 458 | ||
| 473 | return { | 459 | const parsed = parseGitRemoteVerboseOutput(result.stdout); |
| 474 | remotes, | 460 | const remotes = Array.from(parsed.entries()).flatMap(([name, remote]) => { |
| 475 | freshness: yield* nowFreshness(), | 461 | if (!remote.url) { |
| 476 | }; | 462 | return []; |
| 477 | }); | 463 | } |
| 464 | return [ | ||
| 465 | { | ||
| 466 | name, | ||
| 467 | url: remote.url, | ||
| 468 | pushUrl: remote.pushUrl ? Option.some(remote.pushUrl) : Option.none(), | ||
| 469 | isPrimary: name === "origin", | ||
| 470 | }, | ||
| 471 | ]; | ||
| 472 | }); | ||
| 478 | 473 | ||
| 479 | const filterIgnoredPaths: VcsDriverShape["filterIgnoredPaths"] = Effect.fn("filterIgnoredPaths")( | 474 | return { |
| 480 | function* (cwd, relativePaths) { | 475 | remotes, |
| 481 | if (relativePaths.length === 0) { | 476 | freshness: yield* nowFreshness(), |
| 482 | return relativePaths; | 477 | }; |
| 483 | } | 478 | }, |
| 479 | ); | ||
| 484 | 480 | ||
| 485 | const ignoredPaths = new Set<string>(); | 481 | const filterIgnoredPaths: VcsDriver.VcsDriverShape["filterIgnoredPaths"] = Effect.fn( |
| 486 | const chunks = chunkPathsForGitCheckIgnore(relativePaths); | 482 | "filterIgnoredPaths", |
| 483 | )(function* (cwd, relativePaths) { | ||
| 484 | if (relativePaths.length === 0) { | ||
| 485 | return relativePaths; | ||
| 486 | } | ||
| 487 | 487 | ||
| 488 | for (const chunk of chunks) { | 488 | const ignoredPaths = new Set<string>(); |
| 489 | const result = yield* gitCommand( | 489 | const chunks = chunkPathsForGitCheckIgnore(relativePaths); |
| 490 | process, | ||
| 491 | "GitVcsDriver.filterIgnoredPaths", | ||
| 492 | cwd, | ||
| 493 | [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], | ||
| 494 | { | ||
| 495 | stdin: `${chunk.join("\0")}\0`, | ||
| 496 | allowNonZeroExit: true, | ||
| 497 | timeoutMs: 20_000, | ||
| 498 | maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, | ||
| 499 | truncateOutputAtMaxBytes: true, | ||
| 500 | }, | ||
| 501 | ); | ||
| 502 | |||
| 503 | if (result.exitCode !== 0 && result.exitCode !== 1) { | ||
| 504 | return yield* new VcsProcessExitError({ | ||
| 505 | operation: "GitVcsDriver.filterIgnoredPaths", | ||
| 506 | command: "git check-ignore", | ||
| 507 | cwd, | ||
| 508 | exitCode: result.exitCode, | ||
| 509 | detail: result.stderr.trim() || "git check-ignore failed", | ||
| 510 | }); | ||
| 511 | } | ||
| 512 | 490 | ||
| 513 | for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { | 491 | for (const chunk of chunks) { |
| 514 | ignoredPaths.add(ignoredPath); | 492 | const result = yield* gitCommand( |
| 515 | } | 493 | process, |
| 494 | "GitVcsDriver.filterIgnoredPaths", | ||
| 495 | cwd, | ||
| 496 | [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], | ||
| 497 | { | ||
| 498 | stdin: `${chunk.join("\0")}\0`, | ||
| 499 | allowNonZeroExit: true, | ||
| 500 | timeoutMs: 20_000, | ||
| 501 | maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, | ||
| 502 | truncateOutputAtMaxBytes: true, | ||
| 503 | }, | ||
| 504 | ); | ||
| 505 | |||
| 506 | if (result.exitCode !== 0 && result.exitCode !== 1) { | ||
| 507 | return yield* new VcsProcessExitError({ | ||
| 508 | operation: "GitVcsDriver.filterIgnoredPaths", | ||
| 509 | command: "git check-ignore", | ||
| 510 | cwd, | ||
| 511 | exitCode: result.exitCode, | ||
| 512 | detail: result.stderr.trim() || "git check-ignore failed", | ||
| 513 | }); | ||
| 516 | } | 514 | } |
| 517 | 515 | ||
| 518 | if (ignoredPaths.size === 0) { | 516 | for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { |
| 519 | return relativePaths; | 517 | ignoredPaths.add(ignoredPath); |
| 520 | } | 518 | } |
| 519 | } | ||
| 521 | 520 | ||
| 522 | return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); | 521 | if (ignoredPaths.size === 0) { |
| 523 | }, | 522 | return relativePaths; |
| 524 | ); | 523 | } |
| 525 | 524 | ||
| 526 | const initRepository: VcsDriverShape["initRepository"] = (input) => | 525 | return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); |
| 526 | }); | ||
| 527 | |||
| 528 | const initRepository: VcsDriver.VcsDriverShape["initRepository"] = (input) => | ||
| 527 | gitCommand(process, "GitVcsDriver.initRepository", input.cwd, ["init"], { | 529 | gitCommand(process, "GitVcsDriver.initRepository", input.cwd, ["init"], { |
| 528 | timeoutMs: 10_000, | 530 | timeoutMs: 10_000, |
| 529 | maxOutputBytes: 64 * 1024, | 531 | maxOutputBytes: 64 * 1024, |
| 530 | }).pipe(Effect.asVoid); | 532 | }).pipe(Effect.asVoid); |
| 531 | 533 | ||
| 532 | return { | 534 | return VcsDriver.VcsDriver.of({ |
| 533 | capabilities, | 535 | capabilities, |
| 534 | execute, | 536 | execute, |
| 535 | detectRepository, | 537 | detectRepository, |
| ... | @@ -538,18 +540,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( | ... | @@ -538,18 +540,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 538 | listRemotes, | 540 | listRemotes, |
| 539 | filterIgnoredPaths, | 541 | filterIgnoredPaths, |
| 540 | initRepository, | 542 | initRepository, |
| 541 | } satisfies VcsDriverShape; | 543 | }); |
| 542 | }); | 544 | }); |
| 543 | 545 | ||
| 544 | export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { | 546 | export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { |
| 545 | const driver = yield* makeVcsDriverShape(); | 547 | const driver = yield* makeVcsDriverShape(); |
| 546 | return VcsDriver.of(driver); | 548 | return VcsDriver.VcsDriver.of(driver); |
| 547 | }); | 549 | }); |
| 548 | 550 | ||
| 549 | export const make = Effect.fn("makeGitVcsDriverService")(function* () { | 551 | export const make = Effect.fn("makeGitVcsDriverService")(function* () { |
| 550 | const git = yield* makeGitVcsDriverCore(); | 552 | const git = yield* GitVcsDriverCore.makeGitVcsDriverCore(); |
| 551 | return GitVcsDriver.of(git); | 553 | return GitVcsDriver.of(git); |
| 552 | }); | 554 | }); |
| 553 | 555 | ||
| 554 | export const vcsLayer = Layer.effect(VcsDriver, makeVcsDriver()); | 556 | export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver()); |
| 555 | export const layer = Layer.effect(GitVcsDriver, make()); | 557 | export const layer = Layer.effect(GitVcsDriver, make()); |
apps/server/src/vcs/GitVcsDriverCore.test.ts+1-2| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, it } from "@effect/vitest"; | 2 | import { assert, it, describe } from "@effect/vitest"; |
| 3 | import { Effect, FileSystem, Layer, Path, PlatformError, Scope } from "effect"; | 3 | import { Effect, FileSystem, Layer, Path, PlatformError, Scope } from "effect"; |
| 4 | import { describe } from "vitest"; | ||
| 5 | 4 | ||
| 6 | import { GitCommandError } from "@t3tools/contracts"; | 5 | import { GitCommandError } from "@t3tools/contracts"; |
| 7 | import { ServerConfig } from "../config.ts"; | 6 | import { ServerConfig } from "../config.ts"; |
apps/server/src/vcs/GitVcsDriverCore.ts+575-578| ... | @@ -22,14 +22,7 @@ import { GitCommandError, type VcsRef } from "@t3tools/contracts"; | ... | @@ -22,14 +22,7 @@ import { GitCommandError, type VcsRef } from "@t3tools/contracts"; |
| 22 | import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; | 22 | import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; |
| 23 | import { compactTraceAttributes } from "../observability/Attributes.ts"; | 23 | import { compactTraceAttributes } from "../observability/Attributes.ts"; |
| 24 | import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; | 24 | import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; |
| 25 | import type { | 25 | import * as GitVcsDriver from "./GitVcsDriver.ts"; |
| 26 | ExecuteGitProgress, | ||
| 27 | GitCommitOptions, | ||
| 28 | GitVcsDriverShape, | ||
| 29 | GitStatusDetails, | ||
| 30 | ExecuteGitInput, | ||
| 31 | ExecuteGitResult, | ||
| 32 | } from "./GitVcsDriver.ts"; | ||
| 33 | import { | 26 | import { |
| 34 | parseRemoteNames, | 27 | parseRemoteNames, |
| 35 | parseRemoteNamesInGitOrder, | 28 | parseRemoteNamesInGitOrder, |
| ... | @@ -51,7 +44,7 @@ const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); | ... | @@ -51,7 +44,7 @@ const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); |
| 51 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; | 44 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; |
| 52 | const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; | 45 | const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; |
| 53 | const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; | 46 | const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; |
| 54 | const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitStatusDetails>({ | 47 | const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetails>({ |
| 55 | isRepo: false, | 48 | isRepo: false, |
| 56 | hasOriginRemote: false, | 49 | hasOriginRemote: false, |
| 57 | isDefaultBranch: false, | 50 | isDefaultBranch: false, |
| ... | @@ -82,7 +75,7 @@ interface ExecuteGitOptions { | ... | @@ -82,7 +75,7 @@ interface ExecuteGitOptions { |
| 82 | fallbackErrorMessage?: string | undefined; | 75 | fallbackErrorMessage?: string | undefined; |
| 83 | maxOutputBytes?: number | undefined; | 76 | maxOutputBytes?: number | undefined; |
| 84 | truncateOutputAtMaxBytes?: boolean | undefined; | 77 | truncateOutputAtMaxBytes?: boolean | undefined; |
| 85 | progress?: ExecuteGitProgress | undefined; | 78 | progress?: GitVcsDriver.ExecuteGitProgress | undefined; |
| 86 | } | 79 | } |
| 87 | 80 | ||
| 88 | function parseBranchAb(value: string): { ahead: number; behind: number } { | 81 | function parseBranchAb(value: string): { ahead: number; behind: number } { |
| ... | @@ -332,7 +325,7 @@ function isMissingGitCwdError(error: GitCommandError): boolean { | ... | @@ -332,7 +325,7 @@ function isMissingGitCwdError(error: GitCommandError): boolean { |
| 332 | } | 325 | } |
| 333 | 326 | ||
| 334 | function toGitCommandError( | 327 | function toGitCommandError( |
| 335 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | 328 | input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">, |
| 336 | detail: string, | 329 | detail: string, |
| 337 | ) { | 330 | ) { |
| 338 | return (cause: unknown) => | 331 | return (cause: unknown) => |
| ... | @@ -377,8 +370,8 @@ function trace2ChildKey(record: Record<string, unknown>): string | null { | ... | @@ -377,8 +370,8 @@ function trace2ChildKey(record: Record<string, unknown>): string | null { |
| 377 | const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); | 370 | const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); |
| 378 | 371 | ||
| 379 | const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( | 372 | const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( |
| 380 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | 373 | input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">, |
| 381 | progress: ExecuteGitProgress | undefined, | 374 | progress: GitVcsDriver.ExecuteGitProgress | undefined, |
| 382 | ): Effect.fn.Return< | 375 | ): Effect.fn.Return< |
| 383 | Trace2Monitor, | 376 | Trace2Monitor, |
| 384 | PlatformError.PlatformError, | 377 | PlatformError.PlatformError, |
| ... | @@ -536,7 +529,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( | ... | @@ -536,7 +529,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( |
| 536 | }); | 529 | }); |
| 537 | 530 | ||
| 538 | const collectOutput = Effect.fn("collectOutput")(function* <E>( | 531 | const collectOutput = Effect.fn("collectOutput")(function* <E>( |
| 539 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | 532 | input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">, |
| 540 | stream: Stream.Stream<Uint8Array, E>, | 533 | stream: Stream.Stream<Uint8Array, E>, |
| 541 | maxOutputBytes: number, | 534 | maxOutputBytes: number, |
| 542 | truncateOutputAtMaxBytes: boolean, | 535 | truncateOutputAtMaxBytes: boolean, |
| ... | @@ -610,13 +603,13 @@ const collectOutput = Effect.fn("collectOutput")(function* <E>( | ... | @@ -610,13 +603,13 @@ const collectOutput = Effect.fn("collectOutput")(function* <E>( |
| 610 | }); | 603 | }); |
| 611 | 604 | ||
| 612 | export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* (options?: { | 605 | export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* (options?: { |
| 613 | executeOverride?: GitVcsDriverShape["execute"]; | 606 | executeOverride?: GitVcsDriver.GitVcsDriverShape["execute"]; |
| 614 | }) { | 607 | }) { |
| 615 | const fileSystem = yield* FileSystem.FileSystem; | 608 | const fileSystem = yield* FileSystem.FileSystem; |
| 616 | const path = yield* Path.Path; | 609 | const path = yield* Path.Path; |
| 617 | const { worktreesDir } = yield* ServerConfig; | 610 | const { worktreesDir } = yield* ServerConfig; |
| 618 | 611 | ||
| 619 | let executeRaw: GitVcsDriverShape["execute"]; | 612 | let executeRaw: GitVcsDriver.GitVcsDriverShape["execute"]; |
| 620 | 613 | ||
| 621 | if (options?.executeOverride) { | 614 | if (options?.executeOverride) { |
| 622 | executeRaw = options.executeOverride; | 615 | executeRaw = options.executeOverride; |
| ... | @@ -698,7 +691,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -698,7 +691,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 698 | stderr: stderr.text, | 691 | stderr: stderr.text, |
| 699 | stdoutTruncated: stdout.truncated, | 692 | stdoutTruncated: stdout.truncated, |
| 700 | stderrTruncated: stderr.truncated, | 693 | stderrTruncated: stderr.truncated, |
| 701 | } satisfies ExecuteGitResult; | 694 | } satisfies GitVcsDriver.ExecuteGitResult; |
| 702 | }); | 695 | }); |
| 703 | 696 | ||
| 704 | return yield* runGitCommand().pipe( | 697 | return yield* runGitCommand().pipe( |
| ... | @@ -722,7 +715,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -722,7 +715,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 722 | }); | 715 | }); |
| 723 | } | 716 | } |
| 724 | 717 | ||
| 725 | const execute: GitVcsDriverShape["execute"] = (input) => | 718 | const execute: GitVcsDriver.GitVcsDriverShape["execute"] = (input) => |
| 726 | executeRaw(input).pipe( | 719 | executeRaw(input).pipe( |
| 727 | withMetrics({ | 720 | withMetrics({ |
| 728 | counter: gitCommandsTotal, | 721 | counter: gitCommandsTotal, |
| ... | @@ -746,7 +739,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -746,7 +739,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 746 | cwd: string, | 739 | cwd: string, |
| 747 | args: readonly string[], | 740 | args: readonly string[], |
| 748 | options: ExecuteGitOptions = {}, | 741 | options: ExecuteGitOptions = {}, |
| 749 | ): Effect.Effect<ExecuteGitResult, GitCommandError> => | 742 | ): Effect.Effect<GitVcsDriver.ExecuteGitResult, GitCommandError> => |
| 750 | execute({ | 743 | execute({ |
| 751 | operation, | 744 | operation, |
| 752 | cwd, | 745 | cwd, |
| ... | @@ -1023,7 +1016,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -1023,7 +1016,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1023 | return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null))); | 1016 | return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null))); |
| 1024 | }); | 1017 | }); |
| 1025 | 1018 | ||
| 1026 | const ensureRemote: GitVcsDriverShape["ensureRemote"] = Effect.fn("ensureRemote")( | 1019 | const ensureRemote: GitVcsDriver.GitVcsDriverShape["ensureRemote"] = Effect.fn("ensureRemote")( |
| 1027 | function* (input) { | 1020 | function* (input) { |
| 1028 | const preferredName = sanitizeRemoteName(input.preferredName); | 1021 | const preferredName = sanitizeRemoteName(input.preferredName); |
| 1029 | const normalizedTargetUrl = normalizeRemoteUrl(input.url); | 1022 | const normalizedTargetUrl = normalizeRemoteUrl(input.url); |
| ... | @@ -1322,13 +1315,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -1322,13 +1315,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1322 | }; | 1315 | }; |
| 1323 | }); | 1316 | }); |
| 1324 | 1317 | ||
| 1325 | const statusDetailsLocal: GitVcsDriverShape["statusDetailsLocal"] = Effect.fn( | 1318 | const statusDetailsLocal: GitVcsDriver.GitVcsDriverShape["statusDetailsLocal"] = Effect.fn( |
| 1326 | "statusDetailsLocal", | 1319 | "statusDetailsLocal", |
| 1327 | )(function* (cwd) { | 1320 | )(function* (cwd) { |
| 1328 | return yield* readStatusDetailsLocal(cwd); | 1321 | return yield* readStatusDetailsLocal(cwd); |
| 1329 | }); | 1322 | }); |
| 1330 | 1323 | ||
| 1331 | const statusDetails: GitVcsDriverShape["statusDetails"] = Effect.fn("statusDetails")( | 1324 | const statusDetails: GitVcsDriver.GitVcsDriverShape["statusDetails"] = Effect.fn("statusDetails")( |
| 1332 | function* (cwd) { | 1325 | function* (cwd) { |
| 1333 | yield* refreshStatusUpstreamIfStale(cwd).pipe( | 1326 | yield* refreshStatusUpstreamIfStale(cwd).pipe( |
| 1334 | Effect.catchIf(isMissingGitCwdError, () => Effect.void), | 1327 | Effect.catchIf(isMissingGitCwdError, () => Effect.void), |
| ... | @@ -1338,7 +1331,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -1338,7 +1331,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1338 | }, | 1331 | }, |
| 1339 | ); | 1332 | ); |
| 1340 | 1333 | ||
| 1341 | const status: GitVcsDriverShape["status"] = (input) => | 1334 | const status: GitVcsDriver.GitVcsDriverShape["status"] = (input) => |
| 1342 | statusDetails(input.cwd).pipe( | 1335 | statusDetails(input.cwd).pipe( |
| 1343 | Effect.map((details) => ({ | 1336 | Effect.map((details) => ({ |
| 1344 | isRepo: details.isRepo, | 1337 | isRepo: details.isRepo, |
| ... | @@ -1355,7 +1348,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -1355,7 +1348,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1355 | })), | 1348 | })), |
| 1356 | ); | 1349 | ); |
| 1357 | 1350 | ||
| 1358 | const prepareCommitContext: GitVcsDriverShape["prepareCommitContext"] = Effect.fn( | 1351 | const prepareCommitContext: GitVcsDriver.GitVcsDriverShape["prepareCommitContext"] = Effect.fn( |
| 1359 | "prepareCommitContext", | 1352 | "prepareCommitContext", |
| 1360 | )(function* (cwd, filePaths) { | 1353 | )(function* (cwd, filePaths) { |
| 1361 | if (filePaths && filePaths.length > 0) { | 1354 | if (filePaths && filePaths.length > 0) { |
| ... | @@ -1397,11 +1390,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -1397,11 +1390,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1397 | }; | 1390 | }; |
| 1398 | }); | 1391 | }); |
| 1399 | 1392 | ||
| 1400 | const commit: GitVcsDriverShape["commit"] = Effect.fn("commit")(function* ( | 1393 | const commit: GitVcsDriver.GitVcsDriverShape["commit"] = Effect.fn("commit")(function* ( |
| 1401 | cwd, | 1394 | cwd, |
| 1402 | subject, | 1395 | subject, |
| 1403 | body, | 1396 | body, |
| 1404 | options?: GitCommitOptions, | 1397 | options?: GitVcsDriver.GitCommitOptions, |
| 1405 | ) { | 1398 | ) { |
| 1406 | const args = ["commit", "-m", subject]; | 1399 | const args = ["commit", "-m", subject]; |
| 1407 | const trimmedBody = body.trim(); | 1400 | const trimmedBody = body.trim(); |
| ... | @@ -1430,529 +1423,529 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -1430,529 +1423,529 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1430 | return { commitSha }; | 1423 | return { commitSha }; |
| 1431 | }); | 1424 | }); |
| 1432 | 1425 | ||
| 1433 | const pushCurrentBranch: GitVcsDriverShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")( | 1426 | const pushCurrentBranch: GitVcsDriver.GitVcsDriverShape["pushCurrentBranch"] = Effect.fn( |
| 1434 | function* (cwd, fallbackBranch, options) { | 1427 | "pushCurrentBranch", |
| 1435 | const details = yield* statusDetails(cwd); | 1428 | )(function* (cwd, fallbackBranch, options) { |
| 1436 | const branch = details.branch ?? fallbackBranch; | 1429 | const details = yield* statusDetails(cwd); |
| 1437 | if (!branch) { | 1430 | const branch = details.branch ?? fallbackBranch; |
| 1438 | return yield* createGitCommandError( | 1431 | if (!branch) { |
| 1439 | "GitVcsDriver.pushCurrentBranch", | 1432 | return yield* createGitCommandError( |
| 1440 | cwd, | 1433 | "GitVcsDriver.pushCurrentBranch", |
| 1441 | ["push"], | 1434 | cwd, |
| 1442 | "Cannot push from detached HEAD.", | 1435 | ["push"], |
| 1443 | ); | 1436 | "Cannot push from detached HEAD.", |
| 1444 | } | 1437 | ); |
| 1438 | } | ||
| 1439 | |||
| 1440 | const requestedRemoteName = options?.remoteName?.trim() || null; | ||
| 1441 | if (requestedRemoteName) { | ||
| 1442 | const publishBranch = yield* resolvePublishBranchName(cwd, branch); | ||
| 1443 | yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", cwd, [ | ||
| 1444 | "push", | ||
| 1445 | "-u", | ||
| 1446 | requestedRemoteName, | ||
| 1447 | `HEAD:refs/heads/${publishBranch}`, | ||
| 1448 | ]); | ||
| 1449 | return { | ||
| 1450 | status: "pushed" as const, | ||
| 1451 | branch, | ||
| 1452 | upstreamBranch: `${requestedRemoteName}/${publishBranch}`, | ||
| 1453 | setUpstream: true, | ||
| 1454 | }; | ||
| 1455 | } | ||
| 1445 | 1456 | ||
| 1446 | const requestedRemoteName = options?.remoteName?.trim() || null; | 1457 | const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; |
| 1447 | if (requestedRemoteName) { | 1458 | if (hasNoLocalDelta) { |
| 1448 | const publishBranch = yield* resolvePublishBranchName(cwd, branch); | 1459 | if (details.hasUpstream) { |
| 1449 | yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", cwd, [ | ||
| 1450 | "push", | ||
| 1451 | "-u", | ||
| 1452 | requestedRemoteName, | ||
| 1453 | `HEAD:refs/heads/${publishBranch}`, | ||
| 1454 | ]); | ||
| 1455 | return { | 1460 | return { |
| 1456 | status: "pushed" as const, | 1461 | status: "skipped_up_to_date" as const, |
| 1457 | branch, | 1462 | branch, |
| 1458 | upstreamBranch: `${requestedRemoteName}/${publishBranch}`, | 1463 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), |
| 1459 | setUpstream: true, | ||
| 1460 | }; | 1464 | }; |
| 1461 | } | 1465 | } |
| 1462 | 1466 | ||
| 1463 | const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; | 1467 | const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( |
| 1464 | if (hasNoLocalDelta) { | 1468 | Effect.catch(() => Effect.succeed(null)), |
| 1465 | if (details.hasUpstream) { | 1469 | ); |
| 1470 | if (comparableBaseBranch) { | ||
| 1471 | const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( | ||
| 1472 | Effect.catch(() => Effect.succeed(null)), | ||
| 1473 | ); | ||
| 1474 | if (!publishRemoteName) { | ||
| 1466 | return { | 1475 | return { |
| 1467 | status: "skipped_up_to_date" as const, | 1476 | status: "skipped_up_to_date" as const, |
| 1468 | branch, | 1477 | branch, |
| 1469 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | ||
| 1470 | }; | 1478 | }; |
| 1471 | } | 1479 | } |
| 1472 | 1480 | ||
| 1473 | const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( | 1481 | const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( |
| 1474 | Effect.catch(() => Effect.succeed(null)), | 1482 | Effect.catch(() => Effect.succeed(false)), |
| 1475 | ); | 1483 | ); |
| 1476 | if (comparableBaseBranch) { | 1484 | if (hasRemoteBranch) { |
| 1477 | const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( | 1485 | return { |
| 1478 | Effect.catch(() => Effect.succeed(null)), | 1486 | status: "skipped_up_to_date" as const, |
| 1479 | ); | 1487 | branch, |
| 1480 | if (!publishRemoteName) { | 1488 | }; |
| 1481 | return { | ||
| 1482 | status: "skipped_up_to_date" as const, | ||
| 1483 | branch, | ||
| 1484 | }; | ||
| 1485 | } | ||
| 1486 | |||
| 1487 | const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( | ||
| 1488 | Effect.catch(() => Effect.succeed(false)), | ||
| 1489 | ); | ||
| 1490 | if (hasRemoteBranch) { | ||
| 1491 | return { | ||
| 1492 | status: "skipped_up_to_date" as const, | ||
| 1493 | branch, | ||
| 1494 | }; | ||
| 1495 | } | ||
| 1496 | } | ||
| 1497 | } | ||
| 1498 | |||
| 1499 | if (!details.hasUpstream) { | ||
| 1500 | const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); | ||
| 1501 | if (!publishRemoteName) { | ||
| 1502 | return yield* createGitCommandError( | ||
| 1503 | "GitVcsDriver.pushCurrentBranch", | ||
| 1504 | cwd, | ||
| 1505 | ["push"], | ||
| 1506 | "Cannot push because no git remote is configured for this repository.", | ||
| 1507 | ); | ||
| 1508 | } | 1489 | } |
| 1509 | const publishBranch = yield* resolvePublishBranchName(cwd, branch); | ||
| 1510 | yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [ | ||
| 1511 | "push", | ||
| 1512 | "-u", | ||
| 1513 | publishRemoteName, | ||
| 1514 | `HEAD:refs/heads/${publishBranch}`, | ||
| 1515 | ]); | ||
| 1516 | return { | ||
| 1517 | status: "pushed" as const, | ||
| 1518 | branch, | ||
| 1519 | upstreamBranch: `${publishRemoteName}/${publishBranch}`, | ||
| 1520 | setUpstream: true, | ||
| 1521 | }; | ||
| 1522 | } | 1490 | } |
| 1491 | } | ||
| 1523 | 1492 | ||
| 1524 | const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( | 1493 | if (!details.hasUpstream) { |
| 1525 | Effect.catch(() => Effect.succeed(null)), | 1494 | const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); |
| 1526 | ); | 1495 | if (!publishRemoteName) { |
| 1527 | if (currentUpstream) { | 1496 | return yield* createGitCommandError( |
| 1528 | yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [ | 1497 | "GitVcsDriver.pushCurrentBranch", |
| 1529 | "push", | 1498 | cwd, |
| 1530 | currentUpstream.remoteName, | 1499 | ["push"], |
| 1531 | `HEAD:refs/heads/${currentUpstream.branchName}`, | 1500 | "Cannot push because no git remote is configured for this repository.", |
| 1532 | ]); | 1501 | ); |
| 1533 | return { | ||
| 1534 | status: "pushed" as const, | ||
| 1535 | branch, | ||
| 1536 | upstreamBranch: currentUpstream.upstreamRef, | ||
| 1537 | setUpstream: false, | ||
| 1538 | }; | ||
| 1539 | } | 1502 | } |
| 1503 | const publishBranch = yield* resolvePublishBranchName(cwd, branch); | ||
| 1504 | yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [ | ||
| 1505 | "push", | ||
| 1506 | "-u", | ||
| 1507 | publishRemoteName, | ||
| 1508 | `HEAD:refs/heads/${publishBranch}`, | ||
| 1509 | ]); | ||
| 1510 | return { | ||
| 1511 | status: "pushed" as const, | ||
| 1512 | branch, | ||
| 1513 | upstreamBranch: `${publishRemoteName}/${publishBranch}`, | ||
| 1514 | setUpstream: true, | ||
| 1515 | }; | ||
| 1516 | } | ||
| 1540 | 1517 | ||
| 1541 | yield* runGit("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"]); | 1518 | const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( |
| 1519 | Effect.catch(() => Effect.succeed(null)), | ||
| 1520 | ); | ||
| 1521 | if (currentUpstream) { | ||
| 1522 | yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [ | ||
| 1523 | "push", | ||
| 1524 | currentUpstream.remoteName, | ||
| 1525 | `HEAD:refs/heads/${currentUpstream.branchName}`, | ||
| 1526 | ]); | ||
| 1542 | return { | 1527 | return { |
| 1543 | status: "pushed" as const, | 1528 | status: "pushed" as const, |
| 1544 | branch, | 1529 | branch, |
| 1545 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | 1530 | upstreamBranch: currentUpstream.upstreamRef, |
| 1546 | setUpstream: false, | 1531 | setUpstream: false, |
| 1547 | }; | 1532 | }; |
| 1548 | }, | 1533 | } |
| 1549 | ); | ||
| 1550 | 1534 | ||
| 1551 | const pullCurrentBranch: GitVcsDriverShape["pullCurrentBranch"] = Effect.fn("pullCurrentBranch")( | 1535 | yield* runGit("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"]); |
| 1552 | function* (cwd) { | 1536 | return { |
| 1553 | const details = yield* statusDetails(cwd); | 1537 | status: "pushed" as const, |
| 1554 | const refName = details.branch; | 1538 | branch, |
| 1555 | if (!refName) { | 1539 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), |
| 1556 | return yield* createGitCommandError( | 1540 | setUpstream: false, |
| 1557 | "GitVcsDriver.pullCurrentBranch", | 1541 | }; |
| 1558 | cwd, | 1542 | }); |
| 1559 | ["pull", "--ff-only"], | 1543 | |
| 1560 | "Cannot pull from detached HEAD.", | 1544 | const pullCurrentBranch: GitVcsDriver.GitVcsDriverShape["pullCurrentBranch"] = Effect.fn( |
| 1561 | ); | 1545 | "pullCurrentBranch", |
| 1562 | } | 1546 | )(function* (cwd) { |
| 1563 | if (!details.hasUpstream) { | 1547 | const details = yield* statusDetails(cwd); |
| 1564 | return yield* createGitCommandError( | 1548 | const refName = details.branch; |
| 1565 | "GitVcsDriver.pullCurrentBranch", | 1549 | if (!refName) { |
| 1566 | cwd, | 1550 | return yield* createGitCommandError( |
| 1567 | ["pull", "--ff-only"], | 1551 | "GitVcsDriver.pullCurrentBranch", |
| 1568 | "Current branch has no upstream configured. Push with upstream first.", | ||
| 1569 | ); | ||
| 1570 | } | ||
| 1571 | const beforeSha = yield* runGitStdout( | ||
| 1572 | "GitVcsDriver.pullCurrentBranch.beforeSha", | ||
| 1573 | cwd, | 1552 | cwd, |
| 1574 | ["rev-parse", "HEAD"], | 1553 | ["pull", "--ff-only"], |
| 1575 | true, | 1554 | "Cannot pull from detached HEAD.", |
| 1576 | ).pipe(Effect.map((stdout) => stdout.trim())); | 1555 | ); |
| 1577 | yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { | 1556 | } |
| 1578 | timeoutMs: 30_000, | 1557 | if (!details.hasUpstream) { |
| 1579 | fallbackErrorMessage: "git pull failed", | 1558 | return yield* createGitCommandError( |
| 1580 | }); | 1559 | "GitVcsDriver.pullCurrentBranch", |
| 1581 | const afterSha = yield* runGitStdout( | ||
| 1582 | "GitVcsDriver.pullCurrentBranch.afterSha", | ||
| 1583 | cwd, | 1560 | cwd, |
| 1584 | ["rev-parse", "HEAD"], | 1561 | ["pull", "--ff-only"], |
| 1585 | true, | 1562 | "Current branch has no upstream configured. Push with upstream first.", |
| 1586 | ).pipe(Effect.map((stdout) => stdout.trim())); | 1563 | ); |
| 1564 | } | ||
| 1565 | const beforeSha = yield* runGitStdout( | ||
| 1566 | "GitVcsDriver.pullCurrentBranch.beforeSha", | ||
| 1567 | cwd, | ||
| 1568 | ["rev-parse", "HEAD"], | ||
| 1569 | true, | ||
| 1570 | ).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 1571 | yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { | ||
| 1572 | timeoutMs: 30_000, | ||
| 1573 | fallbackErrorMessage: "git pull failed", | ||
| 1574 | }); | ||
| 1575 | const afterSha = yield* runGitStdout( | ||
| 1576 | "GitVcsDriver.pullCurrentBranch.afterSha", | ||
| 1577 | cwd, | ||
| 1578 | ["rev-parse", "HEAD"], | ||
| 1579 | true, | ||
| 1580 | ).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 1587 | 1581 | ||
| 1588 | const refreshed = yield* statusDetails(cwd); | 1582 | const refreshed = yield* statusDetails(cwd); |
| 1589 | return { | 1583 | return { |
| 1590 | status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", | 1584 | status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", |
| 1591 | refName, | 1585 | refName, |
| 1592 | upstreamRef: refreshed.upstreamRef, | 1586 | upstreamRef: refreshed.upstreamRef, |
| 1593 | }; | 1587 | }; |
| 1594 | }, | 1588 | }); |
| 1595 | ); | ||
| 1596 | 1589 | ||
| 1597 | const readRangeContext: GitVcsDriverShape["readRangeContext"] = Effect.fn("readRangeContext")( | 1590 | const readRangeContext: GitVcsDriver.GitVcsDriverShape["readRangeContext"] = Effect.fn( |
| 1598 | function* (cwd, baseRef) { | 1591 | "readRangeContext", |
| 1599 | const range = `${baseRef}..HEAD`; | 1592 | )(function* (cwd, baseRef) { |
| 1600 | const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( | 1593 | const range = `${baseRef}..HEAD`; |
| 1601 | [ | 1594 | const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( |
| 1602 | runGitStdoutWithOptions( | 1595 | [ |
| 1603 | "GitVcsDriver.readRangeContext.log", | 1596 | runGitStdoutWithOptions( |
| 1604 | cwd, | 1597 | "GitVcsDriver.readRangeContext.log", |
| 1605 | ["log", "--oneline", range], | 1598 | cwd, |
| 1606 | { | 1599 | ["log", "--oneline", range], |
| 1607 | maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, | 1600 | { |
| 1608 | truncateOutputAtMaxBytes: true, | 1601 | maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, |
| 1609 | }, | 1602 | truncateOutputAtMaxBytes: true, |
| 1610 | ), | 1603 | }, |
| 1611 | runGitStdoutWithOptions( | 1604 | ), |
| 1612 | "GitVcsDriver.readRangeContext.diffStat", | 1605 | runGitStdoutWithOptions( |
| 1613 | cwd, | 1606 | "GitVcsDriver.readRangeContext.diffStat", |
| 1614 | ["diff", "--stat", range], | 1607 | cwd, |
| 1615 | { | 1608 | ["diff", "--stat", range], |
| 1616 | maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, | 1609 | { |
| 1617 | truncateOutputAtMaxBytes: true, | 1610 | maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, |
| 1618 | }, | 1611 | truncateOutputAtMaxBytes: true, |
| 1619 | ), | 1612 | }, |
| 1620 | runGitStdoutWithOptions( | 1613 | ), |
| 1621 | "GitVcsDriver.readRangeContext.diffPatch", | 1614 | runGitStdoutWithOptions( |
| 1622 | cwd, | 1615 | "GitVcsDriver.readRangeContext.diffPatch", |
| 1623 | ["diff", "--patch", "--minimal", range], | 1616 | cwd, |
| 1624 | { | 1617 | ["diff", "--patch", "--minimal", range], |
| 1625 | maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, | 1618 | { |
| 1626 | truncateOutputAtMaxBytes: true, | 1619 | maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, |
| 1627 | }, | 1620 | truncateOutputAtMaxBytes: true, |
| 1628 | ), | 1621 | }, |
| 1629 | ], | 1622 | ), |
| 1630 | { concurrency: "unbounded" }, | 1623 | ], |
| 1631 | ); | 1624 | { concurrency: "unbounded" }, |
| 1625 | ); | ||
| 1632 | 1626 | ||
| 1633 | return { | 1627 | return { |
| 1634 | commitSummary, | 1628 | commitSummary, |
| 1635 | diffSummary, | 1629 | diffSummary, |
| 1636 | diffPatch, | 1630 | diffPatch, |
| 1637 | }; | 1631 | }; |
| 1638 | }, | 1632 | }); |
| 1639 | ); | ||
| 1640 | 1633 | ||
| 1641 | const readConfigValue: GitVcsDriverShape["readConfigValue"] = (cwd, key) => | 1634 | const readConfigValue: GitVcsDriver.GitVcsDriverShape["readConfigValue"] = (cwd, key) => |
| 1642 | runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe( | 1635 | runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe( |
| 1643 | Effect.map((stdout) => stdout.trim()), | 1636 | Effect.map((stdout) => stdout.trim()), |
| 1644 | Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), | 1637 | Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), |
| 1645 | ); | 1638 | ); |
| 1646 | 1639 | ||
| 1647 | const listRefs: GitVcsDriverShape["listRefs"] = Effect.fn("listRefs")(function* (input) { | 1640 | const listRefs: GitVcsDriver.GitVcsDriverShape["listRefs"] = Effect.fn("listRefs")( |
| 1648 | const branchRecencyPromise = readBranchRecency(input.cwd).pipe( | 1641 | function* (input) { |
| 1649 | Effect.catch(() => Effect.succeed(new Map<string, number>())), | 1642 | const branchRecencyPromise = readBranchRecency(input.cwd).pipe( |
| 1650 | ); | 1643 | Effect.catch(() => Effect.succeed(new Map<string, number>())), |
| 1651 | const localBranchResult = yield* executeGit( | 1644 | ); |
| 1652 | "GitVcsDriver.listRefs.branchNoColor", | 1645 | const localBranchResult = yield* executeGit( |
| 1653 | input.cwd, | 1646 | "GitVcsDriver.listRefs.branchNoColor", |
| 1654 | ["branch", "--no-color", "--no-column"], | ||
| 1655 | { | ||
| 1656 | timeoutMs: 10_000, | ||
| 1657 | allowNonZeroExit: true, | ||
| 1658 | }, | ||
| 1659 | ).pipe( | ||
| 1660 | Effect.catchIf(isMissingGitCwdError, () => | ||
| 1661 | Effect.succeed({ | ||
| 1662 | exitCode: ChildProcessSpawner.ExitCode(128), | ||
| 1663 | stdout: "", | ||
| 1664 | stderr: "fatal: not a git repository", | ||
| 1665 | stdoutTruncated: false, | ||
| 1666 | stderrTruncated: false, | ||
| 1667 | }), | ||
| 1668 | ), | ||
| 1669 | ); | ||
| 1670 | |||
| 1671 | if (localBranchResult.exitCode !== 0) { | ||
| 1672 | const stderr = localBranchResult.stderr.trim(); | ||
| 1673 | if (stderr.toLowerCase().includes("not a git repository")) { | ||
| 1674 | return { | ||
| 1675 | refs: [], | ||
| 1676 | isRepo: false, | ||
| 1677 | hasPrimaryRemote: false, | ||
| 1678 | nextCursor: null, | ||
| 1679 | totalCount: 0, | ||
| 1680 | }; | ||
| 1681 | } | ||
| 1682 | return yield* createGitCommandError( | ||
| 1683 | "GitVcsDriver.listRefs", | ||
| 1684 | input.cwd, | 1647 | input.cwd, |
| 1685 | ["branch", "--no-color", "--no-column"], | 1648 | ["branch", "--no-color", "--no-column"], |
| 1686 | stderr || "git branch failed", | 1649 | { |
| 1687 | ); | 1650 | timeoutMs: 10_000, |
| 1688 | } | 1651 | allowNonZeroExit: true, |
| 1689 | 1652 | }, | |
| 1690 | const remoteBranchResultEffect = executeGit( | 1653 | ).pipe( |
| 1691 | "GitVcsDriver.listRefs.remoteBranches", | 1654 | Effect.catchIf(isMissingGitCwdError, () => |
| 1692 | input.cwd, | 1655 | Effect.succeed({ |
| 1693 | ["branch", "--no-color", "--no-column", "--remotes"], | 1656 | exitCode: ChildProcessSpawner.ExitCode(128), |
| 1694 | { | ||
| 1695 | timeoutMs: 10_000, | ||
| 1696 | allowNonZeroExit: true, | ||
| 1697 | }, | ||
| 1698 | ).pipe( | ||
| 1699 | Effect.catch((error) => | ||
| 1700 | Effect.logWarning( | ||
| 1701 | `GitVcsDriver.listRefs: remote refName lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote refName list.`, | ||
| 1702 | ).pipe( | ||
| 1703 | Effect.as({ | ||
| 1704 | exitCode: ChildProcessSpawner.ExitCode(1), | ||
| 1705 | stdout: "", | 1657 | stdout: "", |
| 1706 | stderr: "", | 1658 | stderr: "fatal: not a git repository", |
| 1707 | stdoutTruncated: false, | 1659 | stdoutTruncated: false, |
| 1708 | stderrTruncated: false, | 1660 | stderrTruncated: false, |
| 1709 | } satisfies ExecuteGitResult), | 1661 | }), |
| 1710 | ), | 1662 | ), |
| 1711 | ), | 1663 | ); |
| 1712 | ); | ||
| 1713 | 1664 | ||
| 1714 | const remoteNamesResultEffect = executeGit( | 1665 | if (localBranchResult.exitCode !== 0) { |
| 1715 | "GitVcsDriver.listRefs.remoteNames", | 1666 | const stderr = localBranchResult.stderr.trim(); |
| 1716 | input.cwd, | 1667 | if (stderr.toLowerCase().includes("not a git repository")) { |
| 1717 | ["remote"], | 1668 | return { |
| 1718 | { | 1669 | refs: [], |
| 1719 | timeoutMs: 5_000, | 1670 | isRepo: false, |
| 1720 | allowNonZeroExit: true, | 1671 | hasPrimaryRemote: false, |
| 1721 | }, | 1672 | nextCursor: null, |
| 1722 | ).pipe( | 1673 | totalCount: 0, |
| 1723 | Effect.catch((error) => | 1674 | }; |
| 1724 | Effect.logWarning( | 1675 | } |
| 1725 | `GitVcsDriver.listRefs: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, | 1676 | return yield* createGitCommandError( |
| 1726 | ).pipe( | 1677 | "GitVcsDriver.listRefs", |
| 1727 | Effect.as({ | 1678 | input.cwd, |
| 1728 | exitCode: ChildProcessSpawner.ExitCode(1), | 1679 | ["branch", "--no-color", "--no-column"], |
| 1729 | stdout: "", | 1680 | stderr || "git branch failed", |
| 1730 | stderr: "", | 1681 | ); |
| 1731 | stdoutTruncated: false, | 1682 | } |
| 1732 | stderrTruncated: false, | ||
| 1733 | } satisfies ExecuteGitResult), | ||
| 1734 | ), | ||
| 1735 | ), | ||
| 1736 | ); | ||
| 1737 | 1683 | ||
| 1738 | const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = | 1684 | const remoteBranchResultEffect = executeGit( |
| 1739 | yield* Effect.all( | 1685 | "GitVcsDriver.listRefs.remoteBranches", |
| 1740 | [ | 1686 | input.cwd, |
| 1741 | executeGit( | 1687 | ["branch", "--no-color", "--no-column", "--remotes"], |
| 1742 | "GitVcsDriver.listRefs.defaultRef", | 1688 | { |
| 1743 | input.cwd, | 1689 | timeoutMs: 10_000, |
| 1744 | ["symbolic-ref", "refs/remotes/origin/HEAD"], | 1690 | allowNonZeroExit: true, |
| 1745 | { | 1691 | }, |
| 1746 | timeoutMs: 5_000, | 1692 | ).pipe( |
| 1747 | allowNonZeroExit: true, | 1693 | Effect.catch((error) => |
| 1748 | }, | 1694 | Effect.logWarning( |
| 1749 | ), | 1695 | `GitVcsDriver.listRefs: remote refName lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote refName list.`, |
| 1750 | executeGit( | 1696 | ).pipe( |
| 1751 | "GitVcsDriver.listRefs.worktreeList", | 1697 | Effect.as({ |
| 1752 | input.cwd, | 1698 | exitCode: ChildProcessSpawner.ExitCode(1), |
| 1753 | ["worktree", "list", "--porcelain"], | 1699 | stdout: "", |
| 1754 | { | 1700 | stderr: "", |
| 1755 | timeoutMs: 5_000, | 1701 | stdoutTruncated: false, |
| 1756 | allowNonZeroExit: true, | 1702 | stderrTruncated: false, |
| 1757 | }, | 1703 | } satisfies GitVcsDriver.ExecuteGitResult), |
| 1758 | ), | 1704 | ), |
| 1759 | remoteBranchResultEffect, | 1705 | ), |
| 1760 | remoteNamesResultEffect, | ||
| 1761 | branchRecencyPromise, | ||
| 1762 | ], | ||
| 1763 | { concurrency: "unbounded" }, | ||
| 1764 | ); | 1706 | ); |
| 1765 | 1707 | ||
| 1766 | const remoteNames = | 1708 | const remoteNamesResultEffect = executeGit( |
| 1767 | remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; | 1709 | "GitVcsDriver.listRefs.remoteNames", |
| 1768 | if (remoteBranchResult.exitCode !== 0 && remoteBranchResult.stderr.trim().length > 0) { | 1710 | input.cwd, |
| 1769 | yield* Effect.logWarning( | 1711 | ["remote"], |
| 1770 | `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.exitCode} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`, | 1712 | { |
| 1771 | ); | 1713 | timeoutMs: 5_000, |
| 1772 | } | 1714 | allowNonZeroExit: true, |
| 1773 | if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) { | 1715 | }, |
| 1774 | yield* Effect.logWarning( | 1716 | ).pipe( |
| 1775 | `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, | 1717 | Effect.catch((error) => |
| 1718 | Effect.logWarning( | ||
| 1719 | `GitVcsDriver.listRefs: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, | ||
| 1720 | ).pipe( | ||
| 1721 | Effect.as({ | ||
| 1722 | exitCode: ChildProcessSpawner.ExitCode(1), | ||
| 1723 | stdout: "", | ||
| 1724 | stderr: "", | ||
| 1725 | stdoutTruncated: false, | ||
| 1726 | stderrTruncated: false, | ||
| 1727 | } satisfies GitVcsDriver.ExecuteGitResult), | ||
| 1728 | ), | ||
| 1729 | ), | ||
| 1776 | ); | 1730 | ); |
| 1777 | } | ||
| 1778 | 1731 | ||
| 1779 | const defaultBranch = | 1732 | const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = |
| 1780 | defaultRef.exitCode === 0 | 1733 | yield* Effect.all( |
| 1781 | ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") | 1734 | [ |
| 1782 | : null; | 1735 | executeGit( |
| 1736 | "GitVcsDriver.listRefs.defaultRef", | ||
| 1737 | input.cwd, | ||
| 1738 | ["symbolic-ref", "refs/remotes/origin/HEAD"], | ||
| 1739 | { | ||
| 1740 | timeoutMs: 5_000, | ||
| 1741 | allowNonZeroExit: true, | ||
| 1742 | }, | ||
| 1743 | ), | ||
| 1744 | executeGit( | ||
| 1745 | "GitVcsDriver.listRefs.worktreeList", | ||
| 1746 | input.cwd, | ||
| 1747 | ["worktree", "list", "--porcelain"], | ||
| 1748 | { | ||
| 1749 | timeoutMs: 5_000, | ||
| 1750 | allowNonZeroExit: true, | ||
| 1751 | }, | ||
| 1752 | ), | ||
| 1753 | remoteBranchResultEffect, | ||
| 1754 | remoteNamesResultEffect, | ||
| 1755 | branchRecencyPromise, | ||
| 1756 | ], | ||
| 1757 | { concurrency: "unbounded" }, | ||
| 1758 | ); | ||
| 1783 | 1759 | ||
| 1784 | const worktreeMap = new Map<string, string>(); | 1760 | const remoteNames = |
| 1785 | if (worktreeList.exitCode === 0) { | 1761 | remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; |
| 1786 | let currentPath: string | null = null; | 1762 | if (remoteBranchResult.exitCode !== 0 && remoteBranchResult.stderr.trim().length > 0) { |
| 1787 | for (const line of worktreeList.stdout.split("\n")) { | 1763 | yield* Effect.logWarning( |
| 1788 | if (line.startsWith("worktree ")) { | 1764 | `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.exitCode} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`, |
| 1789 | const candidatePath = line.slice("worktree ".length); | 1765 | ); |
| 1790 | const exists = yield* fileSystem.stat(candidatePath).pipe( | 1766 | } |
| 1791 | Effect.map(() => true), | 1767 | if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) { |
| 1792 | Effect.catch(() => Effect.succeed(false)), | 1768 | yield* Effect.logWarning( |
| 1793 | ); | 1769 | `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, |
| 1794 | currentPath = exists ? candidatePath : null; | 1770 | ); |
| 1795 | } else if (line.startsWith("branch refs/heads/") && currentPath) { | ||
| 1796 | worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); | ||
| 1797 | } else if (line === "") { | ||
| 1798 | currentPath = null; | ||
| 1799 | } | ||
| 1800 | } | 1771 | } |
| 1801 | } | ||
| 1802 | |||
| 1803 | const localBranches = localBranchResult.stdout | ||
| 1804 | .split("\n") | ||
| 1805 | .map(parseBranchLine) | ||
| 1806 | .filter((refName): refName is { name: string; current: boolean } => refName !== null) | ||
| 1807 | .map((refName) => ({ | ||
| 1808 | name: refName.name, | ||
| 1809 | current: refName.current, | ||
| 1810 | isRemote: false, | ||
| 1811 | isDefault: refName.name === defaultBranch, | ||
| 1812 | worktreePath: worktreeMap.get(refName.name) ?? null, | ||
| 1813 | })) | ||
| 1814 | .toSorted((a, b) => { | ||
| 1815 | const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; | ||
| 1816 | const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; | ||
| 1817 | if (aPriority !== bPriority) return aPriority - bPriority; | ||
| 1818 | |||
| 1819 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; | ||
| 1820 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; | ||
| 1821 | if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; | ||
| 1822 | return a.name.localeCompare(b.name); | ||
| 1823 | }); | ||
| 1824 | 1772 | ||
| 1825 | const remoteBranches = | 1773 | const defaultBranch = |
| 1826 | remoteBranchResult.exitCode === 0 | 1774 | defaultRef.exitCode === 0 |
| 1827 | ? remoteBranchResult.stdout | 1775 | ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") |
| 1828 | .split("\n") | 1776 | : null; |
| 1829 | .map(parseBranchLine) | 1777 | |
| 1830 | .filter((refName): refName is { name: string; current: boolean } => refName !== null) | 1778 | const worktreeMap = new Map<string, string>(); |
| 1831 | .map((refName) => { | 1779 | if (worktreeList.exitCode === 0) { |
| 1832 | const parsedRemoteRef = parseRemoteRefWithRemoteNames(refName.name, remoteNames); | 1780 | let currentPath: string | null = null; |
| 1833 | const remoteBranch: { | 1781 | for (const line of worktreeList.stdout.split("\n")) { |
| 1834 | name: string; | 1782 | if (line.startsWith("worktree ")) { |
| 1835 | current: boolean; | 1783 | const candidatePath = line.slice("worktree ".length); |
| 1836 | isRemote: boolean; | 1784 | const exists = yield* fileSystem.stat(candidatePath).pipe( |
| 1837 | remoteName?: string; | 1785 | Effect.map(() => true), |
| 1838 | isDefault: boolean; | 1786 | Effect.catch(() => Effect.succeed(false)), |
| 1839 | worktreePath: string | null; | 1787 | ); |
| 1840 | } = { | 1788 | currentPath = exists ? candidatePath : null; |
| 1841 | name: refName.name, | 1789 | } else if (line.startsWith("branch refs/heads/") && currentPath) { |
| 1842 | current: false, | 1790 | worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); |
| 1843 | isRemote: true, | 1791 | } else if (line === "") { |
| 1844 | isDefault: false, | 1792 | currentPath = null; |
| 1845 | worktreePath: null, | 1793 | } |
| 1846 | }; | 1794 | } |
| 1847 | if (parsedRemoteRef) { | 1795 | } |
| 1848 | remoteBranch.remoteName = parsedRemoteRef.remoteName; | ||
| 1849 | } | ||
| 1850 | return remoteBranch; | ||
| 1851 | }) | ||
| 1852 | .toSorted((a, b) => { | ||
| 1853 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; | ||
| 1854 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; | ||
| 1855 | if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; | ||
| 1856 | return a.name.localeCompare(b.name); | ||
| 1857 | }) | ||
| 1858 | : []; | ||
| 1859 | |||
| 1860 | const refs = paginateBranches({ | ||
| 1861 | refs: filterBranchesForListQuery( | ||
| 1862 | dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), | ||
| 1863 | input.query, | ||
| 1864 | ), | ||
| 1865 | cursor: input.cursor, | ||
| 1866 | limit: input.limit, | ||
| 1867 | }); | ||
| 1868 | 1796 | ||
| 1869 | return { | 1797 | const localBranches = localBranchResult.stdout |
| 1870 | refs: [...refs.refs], | 1798 | .split("\n") |
| 1871 | isRepo: true, | 1799 | .map(parseBranchLine) |
| 1872 | hasPrimaryRemote: remoteNames.includes("origin"), | 1800 | .filter((refName): refName is { name: string; current: boolean } => refName !== null) |
| 1873 | nextCursor: refs.nextCursor, | 1801 | .map((refName) => ({ |
| 1874 | totalCount: refs.totalCount, | 1802 | name: refName.name, |
| 1875 | }; | 1803 | current: refName.current, |
| 1876 | }); | 1804 | isRemote: false, |
| 1805 | isDefault: refName.name === defaultBranch, | ||
| 1806 | worktreePath: worktreeMap.get(refName.name) ?? null, | ||
| 1807 | })) | ||
| 1808 | .toSorted((a, b) => { | ||
| 1809 | const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; | ||
| 1810 | const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; | ||
| 1811 | if (aPriority !== bPriority) return aPriority - bPriority; | ||
| 1812 | |||
| 1813 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; | ||
| 1814 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; | ||
| 1815 | if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; | ||
| 1816 | return a.name.localeCompare(b.name); | ||
| 1817 | }); | ||
| 1877 | 1818 | ||
| 1878 | const createWorktree: GitVcsDriverShape["createWorktree"] = Effect.fn("createWorktree")( | 1819 | const remoteBranches = |
| 1879 | function* (input) { | 1820 | remoteBranchResult.exitCode === 0 |
| 1880 | const targetBranch = input.newRefName ?? input.refName; | 1821 | ? remoteBranchResult.stdout |
| 1881 | const sanitizedBranch = targetBranch.replace(/\//g, "-"); | 1822 | .split("\n") |
| 1882 | const repoName = path.basename(input.cwd); | 1823 | .map(parseBranchLine) |
| 1883 | const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); | 1824 | .filter((refName): refName is { name: string; current: boolean } => refName !== null) |
| 1884 | const args = input.newRefName | 1825 | .map((refName) => { |
| 1885 | ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] | 1826 | const parsedRemoteRef = parseRemoteRefWithRemoteNames(refName.name, remoteNames); |
| 1886 | : ["worktree", "add", worktreePath, input.refName]; | 1827 | const remoteBranch: { |
| 1887 | 1828 | name: string; | |
| 1888 | yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { | 1829 | current: boolean; |
| 1889 | fallbackErrorMessage: "git worktree add failed", | 1830 | isRemote: boolean; |
| 1831 | remoteName?: string; | ||
| 1832 | isDefault: boolean; | ||
| 1833 | worktreePath: string | null; | ||
| 1834 | } = { | ||
| 1835 | name: refName.name, | ||
| 1836 | current: false, | ||
| 1837 | isRemote: true, | ||
| 1838 | isDefault: false, | ||
| 1839 | worktreePath: null, | ||
| 1840 | }; | ||
| 1841 | if (parsedRemoteRef) { | ||
| 1842 | remoteBranch.remoteName = parsedRemoteRef.remoteName; | ||
| 1843 | } | ||
| 1844 | return remoteBranch; | ||
| 1845 | }) | ||
| 1846 | .toSorted((a, b) => { | ||
| 1847 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; | ||
| 1848 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; | ||
| 1849 | if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; | ||
| 1850 | return a.name.localeCompare(b.name); | ||
| 1851 | }) | ||
| 1852 | : []; | ||
| 1853 | |||
| 1854 | const refs = paginateBranches({ | ||
| 1855 | refs: filterBranchesForListQuery( | ||
| 1856 | dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), | ||
| 1857 | input.query, | ||
| 1858 | ), | ||
| 1859 | cursor: input.cursor, | ||
| 1860 | limit: input.limit, | ||
| 1890 | }); | 1861 | }); |
| 1891 | 1862 | ||
| 1892 | return { | 1863 | return { |
| 1893 | worktree: { | 1864 | refs: [...refs.refs], |
| 1894 | path: worktreePath, | 1865 | isRepo: true, |
| 1895 | refName: targetBranch, | 1866 | hasPrimaryRemote: remoteNames.includes("origin"), |
| 1896 | }, | 1867 | nextCursor: refs.nextCursor, |
| 1868 | totalCount: refs.totalCount, | ||
| 1897 | }; | 1869 | }; |
| 1898 | }, | 1870 | }, |
| 1899 | ); | 1871 | ); |
| 1900 | 1872 | ||
| 1901 | const fetchPullRequestBranch: GitVcsDriverShape["fetchPullRequestBranch"] = Effect.fn( | 1873 | const createWorktree: GitVcsDriver.GitVcsDriverShape["createWorktree"] = Effect.fn( |
| 1902 | "fetchPullRequestBranch", | 1874 | "createWorktree", |
| 1903 | )(function* (input) { | 1875 | )(function* (input) { |
| 1904 | const remoteName = yield* resolvePrimaryRemoteName(input.cwd); | 1876 | const targetBranch = input.newRefName ?? input.refName; |
| 1905 | yield* executeGit( | 1877 | const sanitizedBranch = targetBranch.replace(/\//g, "-"); |
| 1906 | "GitVcsDriver.fetchPullRequestBranch", | 1878 | const repoName = path.basename(input.cwd); |
| 1907 | input.cwd, | 1879 | const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); |
| 1908 | [ | 1880 | const args = input.newRefName |
| 1909 | "fetch", | 1881 | ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] |
| 1910 | "--quiet", | 1882 | : ["worktree", "add", worktreePath, input.refName]; |
| 1911 | "--no-tags", | 1883 | |
| 1912 | remoteName, | 1884 | yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { |
| 1913 | `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, | 1885 | fallbackErrorMessage: "git worktree add failed", |
| 1914 | ], | 1886 | }); |
| 1915 | { | 1887 | |
| 1916 | fallbackErrorMessage: "git fetch pull request branch failed", | 1888 | return { |
| 1889 | worktree: { | ||
| 1890 | path: worktreePath, | ||
| 1891 | refName: targetBranch, | ||
| 1917 | }, | 1892 | }, |
| 1918 | ); | 1893 | }; |
| 1919 | }); | 1894 | }); |
| 1920 | 1895 | ||
| 1921 | const fetchRemoteBranch: GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn("fetchRemoteBranch")( | 1896 | const fetchPullRequestBranch: GitVcsDriver.GitVcsDriverShape["fetchPullRequestBranch"] = |
| 1922 | function* (input) { | 1897 | Effect.fn("fetchPullRequestBranch")(function* (input) { |
| 1923 | yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [ | 1898 | const remoteName = yield* resolvePrimaryRemoteName(input.cwd); |
| 1924 | "fetch", | 1899 | yield* executeGit( |
| 1925 | "--quiet", | 1900 | "GitVcsDriver.fetchPullRequestBranch", |
| 1926 | "--no-tags", | ||
| 1927 | input.remoteName, | ||
| 1928 | `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, | ||
| 1929 | ]); | ||
| 1930 | |||
| 1931 | const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); | ||
| 1932 | const targetRef = `${input.remoteName}/${input.remoteBranch}`; | ||
| 1933 | yield* runGit( | ||
| 1934 | "GitVcsDriver.fetchRemoteBranch.materialize", | ||
| 1935 | input.cwd, | 1901 | input.cwd, |
| 1936 | localBranchAlreadyExists | 1902 | [ |
| 1937 | ? ["branch", "--force", input.localBranch, targetRef] | 1903 | "fetch", |
| 1938 | : ["branch", input.localBranch, targetRef], | 1904 | "--quiet", |
| 1905 | "--no-tags", | ||
| 1906 | remoteName, | ||
| 1907 | `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, | ||
| 1908 | ], | ||
| 1909 | { | ||
| 1910 | fallbackErrorMessage: "git fetch pull request branch failed", | ||
| 1911 | }, | ||
| 1939 | ); | 1912 | ); |
| 1940 | }, | 1913 | }); |
| 1941 | ); | ||
| 1942 | 1914 | ||
| 1943 | const fetchRemoteTrackingBranch: GitVcsDriverShape["fetchRemoteTrackingBranch"] = Effect.fn( | 1915 | const fetchRemoteBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn( |
| 1944 | "fetchRemoteTrackingBranch", | 1916 | "fetchRemoteBranch", |
| 1945 | )(function* (input) { | 1917 | )(function* (input) { |
| 1946 | yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [ | 1918 | yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [ |
| 1947 | "fetch", | 1919 | "fetch", |
| 1948 | "--quiet", | 1920 | "--quiet", |
| 1949 | "--no-tags", | 1921 | "--no-tags", |
| 1950 | input.remoteName, | 1922 | input.remoteName, |
| 1951 | `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, | 1923 | `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, |
| 1952 | ]); | 1924 | ]); |
| 1925 | |||
| 1926 | const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); | ||
| 1927 | const targetRef = `${input.remoteName}/${input.remoteBranch}`; | ||
| 1928 | yield* runGit( | ||
| 1929 | "GitVcsDriver.fetchRemoteBranch.materialize", | ||
| 1930 | input.cwd, | ||
| 1931 | localBranchAlreadyExists | ||
| 1932 | ? ["branch", "--force", input.localBranch, targetRef] | ||
| 1933 | : ["branch", input.localBranch, targetRef], | ||
| 1934 | ); | ||
| 1953 | }); | 1935 | }); |
| 1954 | 1936 | ||
| 1955 | const setBranchUpstream: GitVcsDriverShape["setBranchUpstream"] = (input) => | 1937 | const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteTrackingBranch"] = |
| 1938 | Effect.fn("fetchRemoteTrackingBranch")(function* (input) { | ||
| 1939 | yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [ | ||
| 1940 | "fetch", | ||
| 1941 | "--quiet", | ||
| 1942 | "--no-tags", | ||
| 1943 | input.remoteName, | ||
| 1944 | `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, | ||
| 1945 | ]); | ||
| 1946 | }); | ||
| 1947 | |||
| 1948 | const setBranchUpstream: GitVcsDriver.GitVcsDriverShape["setBranchUpstream"] = (input) => | ||
| 1956 | runGit("GitVcsDriver.setBranchUpstream", input.cwd, [ | 1949 | runGit("GitVcsDriver.setBranchUpstream", input.cwd, [ |
| 1957 | "branch", | 1950 | "branch", |
| 1958 | "--set-upstream-to", | 1951 | "--set-upstream-to", |
| ... | @@ -1960,31 +1953,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -1960,31 +1953,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1960 | input.branch, | 1953 | input.branch, |
| 1961 | ]); | 1954 | ]); |
| 1962 | 1955 | ||
| 1963 | const removeWorktree: GitVcsDriverShape["removeWorktree"] = Effect.fn("removeWorktree")( | 1956 | const removeWorktree: GitVcsDriver.GitVcsDriverShape["removeWorktree"] = Effect.fn( |
| 1964 | function* (input) { | 1957 | "removeWorktree", |
| 1965 | const args = ["worktree", "remove"]; | 1958 | )(function* (input) { |
| 1966 | if (input.force) { | 1959 | const args = ["worktree", "remove"]; |
| 1967 | args.push("--force"); | 1960 | if (input.force) { |
| 1968 | } | 1961 | args.push("--force"); |
| 1969 | args.push(input.path); | 1962 | } |
| 1970 | yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { | 1963 | args.push(input.path); |
| 1971 | timeoutMs: 15_000, | 1964 | yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { |
| 1972 | fallbackErrorMessage: "git worktree remove failed", | 1965 | timeoutMs: 15_000, |
| 1973 | }).pipe( | 1966 | fallbackErrorMessage: "git worktree remove failed", |
| 1974 | Effect.mapError((error) => | 1967 | }).pipe( |
| 1975 | createGitCommandError( | 1968 | Effect.mapError((error) => |
| 1976 | "GitVcsDriver.removeWorktree", | 1969 | createGitCommandError( |
| 1977 | input.cwd, | 1970 | "GitVcsDriver.removeWorktree", |
| 1978 | args, | 1971 | input.cwd, |
| 1979 | `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, | 1972 | args, |
| 1980 | error, | 1973 | `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, |
| 1981 | ), | 1974 | error, |
| 1982 | ), | 1975 | ), |
| 1983 | ); | 1976 | ), |
| 1984 | }, | 1977 | ); |
| 1985 | ); | 1978 | }); |
| 1986 | 1979 | ||
| 1987 | const renameBranch: GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")( | 1980 | const renameBranch: GitVcsDriver.GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")( |
| 1988 | function* (input) { | 1981 | function* (input) { |
| 1989 | if (input.oldBranch === input.newBranch) { | 1982 | if (input.oldBranch === input.newBranch) { |
| 1990 | return { branch: input.newBranch }; | 1983 | return { branch: input.newBranch }; |
| ... | @@ -2005,105 +1998,109 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -2005,105 +1998,109 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 2005 | }, | 1998 | }, |
| 2006 | ); | 1999 | ); |
| 2007 | 2000 | ||
| 2008 | const switchRef: GitVcsDriverShape["switchRef"] = Effect.fn("switchRef")(function* (input) { | 2001 | const switchRef: GitVcsDriver.GitVcsDriverShape["switchRef"] = Effect.fn("switchRef")( |
| 2009 | const [localInputExists, remoteExists] = yield* Effect.all( | 2002 | function* (input) { |
| 2010 | [ | 2003 | const [localInputExists, remoteExists] = yield* Effect.all( |
| 2011 | executeGit( | 2004 | [ |
| 2012 | "GitVcsDriver.switchRef.localInputExists", | 2005 | executeGit( |
| 2013 | input.cwd, | 2006 | "GitVcsDriver.switchRef.localInputExists", |
| 2014 | ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], | 2007 | input.cwd, |
| 2015 | { | 2008 | ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], |
| 2016 | timeoutMs: 5_000, | 2009 | { |
| 2017 | allowNonZeroExit: true, | 2010 | timeoutMs: 5_000, |
| 2018 | }, | 2011 | allowNonZeroExit: true, |
| 2019 | ).pipe(Effect.map((result) => result.exitCode === 0)), | 2012 | }, |
| 2020 | executeGit( | 2013 | ).pipe(Effect.map((result) => result.exitCode === 0)), |
| 2021 | "GitVcsDriver.switchRef.remoteExists", | 2014 | executeGit( |
| 2022 | input.cwd, | 2015 | "GitVcsDriver.switchRef.remoteExists", |
| 2023 | ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], | 2016 | input.cwd, |
| 2024 | { | 2017 | ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], |
| 2025 | timeoutMs: 5_000, | 2018 | { |
| 2026 | allowNonZeroExit: true, | 2019 | timeoutMs: 5_000, |
| 2027 | }, | 2020 | allowNonZeroExit: true, |
| 2028 | ).pipe(Effect.map((result) => result.exitCode === 0)), | 2021 | }, |
| 2029 | ], | 2022 | ).pipe(Effect.map((result) => result.exitCode === 0)), |
| 2030 | { concurrency: "unbounded" }, | 2023 | ], |
| 2031 | ); | 2024 | { concurrency: "unbounded" }, |
| 2032 | 2025 | ); | |
| 2033 | const localTrackingBranch = remoteExists | ||
| 2034 | ? yield* executeGit( | ||
| 2035 | "GitVcsDriver.switchRef.localTrackingBranch", | ||
| 2036 | input.cwd, | ||
| 2037 | ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], | ||
| 2038 | { | ||
| 2039 | timeoutMs: 5_000, | ||
| 2040 | allowNonZeroExit: true, | ||
| 2041 | }, | ||
| 2042 | ).pipe( | ||
| 2043 | Effect.map((result) => | ||
| 2044 | result.exitCode === 0 | ||
| 2045 | ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName) | ||
| 2046 | : null, | ||
| 2047 | ), | ||
| 2048 | ) | ||
| 2049 | : null; | ||
| 2050 | 2026 | ||
| 2051 | const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName); | 2027 | const localTrackingBranch = remoteExists |
| 2052 | const localTrackedBranchTargetExists = | ||
| 2053 | remoteExists && localTrackedBranchCandidate | ||
| 2054 | ? yield* executeGit( | 2028 | ? yield* executeGit( |
| 2055 | "GitVcsDriver.switchRef.localTrackedBranchTargetExists", | 2029 | "GitVcsDriver.switchRef.localTrackingBranch", |
| 2056 | input.cwd, | 2030 | input.cwd, |
| 2057 | ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], | 2031 | ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], |
| 2058 | { | 2032 | { |
| 2059 | timeoutMs: 5_000, | 2033 | timeoutMs: 5_000, |
| 2060 | allowNonZeroExit: true, | 2034 | allowNonZeroExit: true, |
| 2061 | }, | 2035 | }, |
| 2062 | ).pipe(Effect.map((result) => result.exitCode === 0)) | 2036 | ).pipe( |
| 2063 | : false; | 2037 | Effect.map((result) => |
| 2038 | result.exitCode === 0 | ||
| 2039 | ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName) | ||
| 2040 | : null, | ||
| 2041 | ), | ||
| 2042 | ) | ||
| 2043 | : null; | ||
| 2064 | 2044 | ||
| 2065 | const checkoutArgs = localInputExists | 2045 | const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName); |
| 2066 | ? ["checkout", input.refName] | 2046 | const localTrackedBranchTargetExists = |
| 2067 | : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists | 2047 | remoteExists && localTrackedBranchCandidate |
| 2068 | ? ["checkout", input.refName] | 2048 | ? yield* executeGit( |
| 2069 | : remoteExists && !localTrackingBranch | 2049 | "GitVcsDriver.switchRef.localTrackedBranchTargetExists", |
| 2070 | ? ["checkout", "--track", input.refName] | 2050 | input.cwd, |
| 2071 | : remoteExists && localTrackingBranch | 2051 | ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], |
| 2072 | ? ["checkout", localTrackingBranch] | 2052 | { |
| 2073 | : ["checkout", input.refName]; | 2053 | timeoutMs: 5_000, |
| 2054 | allowNonZeroExit: true, | ||
| 2055 | }, | ||
| 2056 | ).pipe(Effect.map((result) => result.exitCode === 0)) | ||
| 2057 | : false; | ||
| 2074 | 2058 | ||
| 2075 | yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { | 2059 | const checkoutArgs = localInputExists |
| 2076 | timeoutMs: 10_000, | 2060 | ? ["checkout", input.refName] |
| 2077 | fallbackErrorMessage: "git checkout failed", | 2061 | : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists |
| 2078 | }); | 2062 | ? ["checkout", input.refName] |
| 2063 | : remoteExists && !localTrackingBranch | ||
| 2064 | ? ["checkout", "--track", input.refName] | ||
| 2065 | : remoteExists && localTrackingBranch | ||
| 2066 | ? ["checkout", localTrackingBranch] | ||
| 2067 | : ["checkout", input.refName]; | ||
| 2068 | |||
| 2069 | yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { | ||
| 2070 | timeoutMs: 10_000, | ||
| 2071 | fallbackErrorMessage: "git checkout failed", | ||
| 2072 | }); | ||
| 2079 | 2073 | ||
| 2080 | const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ | 2074 | const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ |
| 2081 | "branch", | 2075 | "branch", |
| 2082 | "--show-current", | 2076 | "--show-current", |
| 2083 | ]).pipe(Effect.map((stdout) => stdout.trim() || null)); | 2077 | ]).pipe(Effect.map((stdout) => stdout.trim() || null)); |
| 2084 | 2078 | ||
| 2085 | return { refName }; | 2079 | return { refName }; |
| 2086 | }); | 2080 | }, |
| 2081 | ); | ||
| 2087 | 2082 | ||
| 2088 | const createRef: GitVcsDriverShape["createRef"] = Effect.fn("createRef")(function* (input) { | 2083 | const createRef: GitVcsDriver.GitVcsDriverShape["createRef"] = Effect.fn("createRef")( |
| 2089 | yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], { | 2084 | function* (input) { |
| 2090 | timeoutMs: 10_000, | 2085 | yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], { |
| 2091 | fallbackErrorMessage: "git branch create failed", | 2086 | timeoutMs: 10_000, |
| 2092 | }); | 2087 | fallbackErrorMessage: "git branch create failed", |
| 2093 | if (input.switchRef) { | 2088 | }); |
| 2094 | yield* switchRef({ cwd: input.cwd, refName: input.refName }); | 2089 | if (input.switchRef) { |
| 2095 | } | 2090 | yield* switchRef({ cwd: input.cwd, refName: input.refName }); |
| 2091 | } | ||
| 2096 | 2092 | ||
| 2097 | return { refName: input.refName }; | 2093 | return { refName: input.refName }; |
| 2098 | }); | 2094 | }, |
| 2095 | ); | ||
| 2099 | 2096 | ||
| 2100 | const initRepo: GitVcsDriverShape["initRepo"] = (input) => | 2097 | const initRepo: GitVcsDriver.GitVcsDriverShape["initRepo"] = (input) => |
| 2101 | executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], { | 2098 | executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], { |
| 2102 | timeoutMs: 10_000, | 2099 | timeoutMs: 10_000, |
| 2103 | fallbackErrorMessage: "git init failed", | 2100 | fallbackErrorMessage: "git init failed", |
| 2104 | }).pipe(Effect.asVoid); | 2101 | }).pipe(Effect.asVoid); |
| 2105 | 2102 | ||
| 2106 | const listLocalBranchNames: GitVcsDriverShape["listLocalBranchNames"] = (cwd) => | 2103 | const listLocalBranchNames: GitVcsDriver.GitVcsDriverShape["listLocalBranchNames"] = (cwd) => |
| 2107 | runGitStdout("GitVcsDriver.listLocalBranchNames", cwd, [ | 2104 | runGitStdout("GitVcsDriver.listLocalBranchNames", cwd, [ |
| 2108 | "branch", | 2105 | "branch", |
| 2109 | "--list", | 2106 | "--list", |
| ... | @@ -2118,7 +2115,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -2118,7 +2115,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 2118 | ), | 2115 | ), |
| 2119 | ); | 2116 | ); |
| 2120 | 2117 | ||
| 2121 | return { | 2118 | return GitVcsDriver.GitVcsDriver.of({ |
| 2122 | execute, | 2119 | execute, |
| 2123 | status, | 2120 | status, |
| 2124 | statusDetails, | 2121 | statusDetails, |
| ... | @@ -2143,5 +2140,5 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -2143,5 +2140,5 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 2143 | switchRef, | 2140 | switchRef, |
| 2144 | initRepo, | 2141 | initRepo, |
| 2145 | listLocalBranchNames, | 2142 | listLocalBranchNames, |
| 2146 | } satisfies GitVcsDriverShape; | 2143 | }); |
| 2147 | }); | 2144 | }); |
apps/server/src/vcs/VcsDriver.ts+3-3| ... | @@ -8,13 +8,13 @@ import type { | ... | @@ -8,13 +8,13 @@ import type { |
| 8 | VcsListWorkspaceFilesResult, | 8 | VcsListWorkspaceFilesResult, |
| 9 | VcsRepositoryIdentity, | 9 | VcsRepositoryIdentity, |
| 10 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 11 | import type { VcsProcessInput, VcsProcessOutput } from "./VcsProcess.ts"; | 11 | import * as VcsProcess from "./VcsProcess.ts"; |
| 12 | 12 | ||
| 13 | export interface VcsDriverShape { | 13 | export interface VcsDriverShape { |
| 14 | readonly capabilities: VcsDriverCapabilities; | 14 | readonly capabilities: VcsDriverCapabilities; |
| 15 | readonly execute: ( | 15 | readonly execute: ( |
| 16 | input: Omit<VcsProcessInput, "command">, | 16 | input: Omit<VcsProcess.VcsProcessInput, "command">, |
| 17 | ) => Effect.Effect<VcsProcessOutput, VcsError>; | 17 | ) => Effect.Effect<VcsProcess.VcsProcessOutput, VcsError>; |
| 18 | readonly detectRepository: (cwd: string) => Effect.Effect<VcsRepositoryIdentity | null, VcsError>; | 18 | readonly detectRepository: (cwd: string) => Effect.Effect<VcsRepositoryIdentity | null, VcsError>; |
| 19 | readonly isInsideWorkTree: (cwd: string) => Effect.Effect<boolean, VcsError>; | 19 | readonly isInsideWorkTree: (cwd: string) => Effect.Effect<boolean, VcsError>; |
| 20 | readonly listWorkspaceFiles: ( | 20 | readonly listWorkspaceFiles: ( |
apps/server/src/vcs/VcsDriverRegistry.test.ts+14-15| ... | @@ -1,13 +1,12 @@ | ... | @@ -1,13 +1,12 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it, describe } from "@effect/vitest"; |
| 2 | import { Effect, Layer } from "effect"; | 2 | import { Effect, Layer } from "effect"; |
| 3 | import { ChildProcessSpawner } from "effect/unstable/process"; | 3 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 4 | import { describe } from "vitest"; | ||
| 5 | 4 | ||
| 6 | import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "./VcsProcess.ts"; | 5 | import * as VcsProcess from "./VcsProcess.ts"; |
| 7 | import { VcsProjectConfig } from "./VcsProjectConfig.ts"; | 6 | import * as VcsProjectConfig from "./VcsProjectConfig.ts"; |
| 8 | import { VcsDriverRegistry, make as makeVcsDriverRegistry } from "./VcsDriverRegistry.ts"; | 7 | import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; |
| 9 | 8 | ||
| 10 | const processOutput = (stdout: string): VcsProcessOutput => ({ | 9 | const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ |
| 11 | exitCode: ChildProcessSpawner.ExitCode(0), | 10 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 12 | stdout, | 11 | stdout, |
| 13 | stderr: "", | 12 | stderr: "", |
| ... | @@ -17,21 +16,21 @@ const processOutput = (stdout: string): VcsProcessOutput => ({ | ... | @@ -17,21 +16,21 @@ const processOutput = (stdout: string): VcsProcessOutput => ({ |
| 17 | 16 | ||
| 18 | describe("VcsDriverRegistry", () => { | 17 | describe("VcsDriverRegistry", () => { |
| 19 | it.effect("routes directly by VCS driver kind for non-repository workflows", () => { | 18 | it.effect("routes directly by VCS driver kind for non-repository workflows", () => { |
| 20 | const layer = Layer.effect(VcsDriverRegistry, makeVcsDriverRegistry()).pipe( | 19 | const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe( |
| 21 | Layer.provide( | 20 | Layer.provide( |
| 22 | Layer.mock(VcsProjectConfig)({ | 21 | Layer.mock(VcsProjectConfig.VcsProjectConfig)({ |
| 23 | resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), | 22 | resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), |
| 24 | }), | 23 | }), |
| 25 | ), | 24 | ), |
| 26 | Layer.provide( | 25 | Layer.provide( |
| 27 | Layer.mock(VcsProcess)({ | 26 | Layer.mock(VcsProcess.VcsProcess)({ |
| 28 | run: () => Effect.succeed(processOutput("")), | 27 | run: () => Effect.succeed(processOutput("")), |
| 29 | }), | 28 | }), |
| 30 | ), | 29 | ), |
| 31 | ); | 30 | ); |
| 32 | 31 | ||
| 33 | return Effect.gen(function* () { | 32 | return Effect.gen(function* () { |
| 34 | const registry = yield* VcsDriverRegistry; | 33 | const registry = yield* VcsDriverRegistry.VcsDriverRegistry; |
| 35 | const driver = yield* registry.get("git"); | 34 | const driver = yield* registry.get("git"); |
| 36 | 35 | ||
| 37 | assert.strictEqual(driver.capabilities.kind, "git"); | 36 | assert.strictEqual(driver.capabilities.kind, "git"); |
| ... | @@ -39,15 +38,15 @@ describe("VcsDriverRegistry", () => { | ... | @@ -39,15 +38,15 @@ describe("VcsDriverRegistry", () => { |
| 39 | }); | 38 | }); |
| 40 | 39 | ||
| 41 | it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => { | 40 | it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => { |
| 42 | const calls: VcsProcessInput[] = []; | 41 | const calls: VcsProcess.VcsProcessInput[] = []; |
| 43 | const layer = Layer.effect(VcsDriverRegistry, makeVcsDriverRegistry()).pipe( | 42 | const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe( |
| 44 | Layer.provide( | 43 | Layer.provide( |
| 45 | Layer.mock(VcsProjectConfig)({ | 44 | Layer.mock(VcsProjectConfig.VcsProjectConfig)({ |
| 46 | resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), | 45 | resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), |
| 47 | }), | 46 | }), |
| 48 | ), | 47 | ), |
| 49 | Layer.provide( | 48 | Layer.provide( |
| 50 | Layer.mock(VcsProcess)({ | 49 | Layer.mock(VcsProcess.VcsProcess)({ |
| 51 | run: (input) => | 50 | run: (input) => |
| 52 | Effect.sync(() => { | 51 | Effect.sync(() => { |
| 53 | calls.push(input); | 52 | calls.push(input); |
| ... | @@ -68,7 +67,7 @@ describe("VcsDriverRegistry", () => { | ... | @@ -68,7 +67,7 @@ describe("VcsDriverRegistry", () => { |
| 68 | ); | 67 | ); |
| 69 | 68 | ||
| 70 | return Effect.gen(function* () { | 69 | return Effect.gen(function* () { |
| 71 | const registry = yield* VcsDriverRegistry; | 70 | const registry = yield* VcsDriverRegistry.VcsDriverRegistry; |
| 72 | const first = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); | 71 | const first = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); |
| 73 | const second = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); | 72 | const second = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); |
| 74 | 73 |
apps/server/src/vcs/VcsDriverRegistry.ts+5-5| ... | @@ -4,7 +4,7 @@ import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/co | ... | @@ -4,7 +4,7 @@ import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/co |
| 4 | import { VcsUnsupportedOperationError } from "@t3tools/contracts"; | 4 | import { VcsUnsupportedOperationError } from "@t3tools/contracts"; |
| 5 | import * as GitVcsDriver from "./GitVcsDriver.ts"; | 5 | import * as GitVcsDriver from "./GitVcsDriver.ts"; |
| 6 | import * as VcsProjectConfig from "./VcsProjectConfig.ts"; | 6 | import * as VcsProjectConfig from "./VcsProjectConfig.ts"; |
| 7 | import type { VcsDriverShape } from "./VcsDriver.ts"; | 7 | import * as VcsDriver from "./VcsDriver.ts"; |
| 8 | 8 | ||
| 9 | const DETECTION_CACHE_CAPACITY = 2_048; | 9 | const DETECTION_CACHE_CAPACITY = 2_048; |
| 10 | const DETECTION_CACHE_TTL = Duration.seconds(2); | 10 | const DETECTION_CACHE_TTL = Duration.seconds(2); |
| ... | @@ -17,11 +17,11 @@ export interface VcsDriverResolveInput { | ... | @@ -17,11 +17,11 @@ export interface VcsDriverResolveInput { |
| 17 | export interface VcsDriverHandle { | 17 | export interface VcsDriverHandle { |
| 18 | readonly kind: VcsDriverKind; | 18 | readonly kind: VcsDriverKind; |
| 19 | readonly repository: VcsRepositoryIdentity; | 19 | readonly repository: VcsRepositoryIdentity; |
| 20 | readonly driver: VcsDriverShape; | 20 | readonly driver: VcsDriver.VcsDriverShape; |
| 21 | } | 21 | } |
| 22 | 22 | ||
| 23 | export interface VcsDriverRegistryShape { | 23 | export interface VcsDriverRegistryShape { |
| 24 | readonly get: (kind: VcsDriverKind) => Effect.Effect<VcsDriverShape, VcsError>; | 24 | readonly get: (kind: VcsDriverKind) => Effect.Effect<VcsDriver.VcsDriverShape, VcsError>; |
| 25 | readonly detect: ( | 25 | readonly detect: ( |
| 26 | input: VcsDriverResolveInput, | 26 | input: VcsDriverResolveInput, |
| 27 | ) => Effect.Effect<VcsDriverHandle | null, VcsError>; | 27 | ) => Effect.Effect<VcsDriverHandle | null, VcsError>; |
| ... | @@ -66,7 +66,7 @@ function parseDetectionCacheKey(key: string): { | ... | @@ -66,7 +66,7 @@ function parseDetectionCacheKey(key: string): { |
| 66 | export const make = Effect.fn("makeVcsDriverRegistry")(function* () { | 66 | export const make = Effect.fn("makeVcsDriverRegistry")(function* () { |
| 67 | const projectConfig = yield* VcsProjectConfig.VcsProjectConfig; | 67 | const projectConfig = yield* VcsProjectConfig.VcsProjectConfig; |
| 68 | const git = yield* GitVcsDriver.makeVcsDriverShape(); | 68 | const git = yield* GitVcsDriver.makeVcsDriverShape(); |
| 69 | const drivers: Partial<Record<VcsDriverKind, VcsDriverShape>> = { | 69 | const drivers: Partial<Record<VcsDriverKind, VcsDriver.VcsDriverShape>> = { |
| 70 | git, | 70 | git, |
| 71 | }; | 71 | }; |
| 72 | 72 | ||
| ... | @@ -82,7 +82,7 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { | ... | @@ -82,7 +82,7 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { |
| 82 | 82 | ||
| 83 | const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* ( | 83 | const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* ( |
| 84 | kind: VcsDriverKind, | 84 | kind: VcsDriverKind, |
| 85 | driver: VcsDriverShape, | 85 | driver: VcsDriver.VcsDriverShape, |
| 86 | cwd: string, | 86 | cwd: string, |
| 87 | ) { | 87 | ) { |
| 88 | const repository = yield* driver.detectRepository(cwd); | 88 | const repository = yield* driver.detectRepository(cwd); |
apps/server/src/vcs/VcsProjectConfig.test.ts+6-7| ... | @@ -1,11 +1,10 @@ | ... | @@ -1,11 +1,10 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it, describe } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { Effect, FileSystem, Layer, Path } from "effect"; | 3 | import { Effect, FileSystem, Layer, Path } from "effect"; |
| 4 | import { describe } from "vitest"; | ||
| 5 | 4 | ||
| 6 | import { VcsProjectConfig, layer as VcsProjectConfigLayer } from "./VcsProjectConfig.ts"; | 5 | import * as VcsProjectConfig from "./VcsProjectConfig.ts"; |
| 7 | 6 | ||
| 8 | const TestLayer = VcsProjectConfigLayer.pipe( | 7 | const TestLayer = VcsProjectConfig.layer.pipe( |
| 9 | Layer.provide(NodeServices.layer), | 8 | Layer.provide(NodeServices.layer), |
| 10 | Layer.provideMerge(NodeServices.layer), | 9 | Layer.provideMerge(NodeServices.layer), |
| 11 | ); | 10 | ); |
| ... | @@ -14,7 +13,7 @@ describe("VcsProjectConfig", () => { | ... | @@ -14,7 +13,7 @@ describe("VcsProjectConfig", () => { |
| 14 | it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => { | 13 | it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => { |
| 15 | it.effect("returns the requested kind", () => | 14 | it.effect("returns the requested kind", () => |
| 16 | Effect.gen(function* () { | 15 | Effect.gen(function* () { |
| 17 | const config = yield* VcsProjectConfig; | 16 | const config = yield* VcsProjectConfig.VcsProjectConfig; |
| 18 | const kind = yield* config.resolveKind({ | 17 | const kind = yield* config.resolveKind({ |
| 19 | cwd: "/repo", | 18 | cwd: "/repo", |
| 20 | requestedKind: "jj", | 19 | requestedKind: "jj", |
| ... | @@ -42,7 +41,7 @@ describe("VcsProjectConfig", () => { | ... | @@ -42,7 +41,7 @@ describe("VcsProjectConfig", () => { |
| 42 | JSON.stringify({ vcs: { kind: "jj" } }), | 41 | JSON.stringify({ vcs: { kind: "jj" } }), |
| 43 | ); | 42 | ); |
| 44 | 43 | ||
| 45 | const config = yield* VcsProjectConfig; | 44 | const config = yield* VcsProjectConfig.VcsProjectConfig; |
| 46 | const kind = yield* config.resolveKind({ cwd: nested }); | 45 | const kind = yield* config.resolveKind({ cwd: nested }); |
| 47 | 46 | ||
| 48 | assert.equal(kind, "jj"); | 47 | assert.equal(kind, "jj"); |
| ... | @@ -57,7 +56,7 @@ describe("VcsProjectConfig", () => { | ... | @@ -57,7 +56,7 @@ describe("VcsProjectConfig", () => { |
| 57 | const root = yield* fileSystem.makeTempDirectoryScoped({ | 56 | const root = yield* fileSystem.makeTempDirectoryScoped({ |
| 58 | prefix: "t3-vcs-config-test-", | 57 | prefix: "t3-vcs-config-test-", |
| 59 | }); | 58 | }); |
| 60 | const config = yield* VcsProjectConfig; | 59 | const config = yield* VcsProjectConfig.VcsProjectConfig; |
| 61 | const kind = yield* config.resolveKind({ cwd: root }); | 60 | const kind = yield* config.resolveKind({ cwd: root }); |
| 62 | 61 | ||
| 63 | assert.equal(kind, "auto"); | 62 | assert.equal(kind, "auto"); |
apps/server/src/vcs/VcsProvisioningService.test.ts+10-10| ... | @@ -2,13 +2,13 @@ import { assert, it } from "@effect/vitest"; | ... | @@ -2,13 +2,13 @@ import { assert, it } from "@effect/vitest"; |
| 2 | import { DateTime, Effect, Layer, Option } from "effect"; | 2 | import { DateTime, Effect, Layer, Option } from "effect"; |
| 3 | import { ChildProcessSpawner } from "effect/unstable/process"; | 3 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 4 | 4 | ||
| 5 | import type { VcsDriverShape } from "./VcsDriver.ts"; | 5 | import * as VcsDriver from "./VcsDriver.ts"; |
| 6 | import { VcsDriverRegistry } from "./VcsDriverRegistry.ts"; | 6 | import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; |
| 7 | import { VcsProvisioningService, layer } from "./VcsProvisioningService.ts"; | 7 | import * as VcsProvisioningService from "./VcsProvisioningService.ts"; |
| 8 | 8 | ||
| 9 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | 9 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); |
| 10 | 10 | ||
| 11 | function makeDriver(calls: string[]): VcsDriverShape { | 11 | function makeDriver(calls: string[]): VcsDriver.VcsDriverShape { |
| 12 | return { | 12 | return { |
| 13 | capabilities: { | 13 | capabilities: { |
| 14 | kind: "git", | 14 | kind: "git", |
| ... | @@ -58,16 +58,16 @@ function makeDriver(calls: string[]): VcsDriverShape { | ... | @@ -58,16 +58,16 @@ function makeDriver(calls: string[]): VcsDriverShape { |
| 58 | it.effect("routes repository initialization through an explicit VCS driver kind", () => { | 58 | it.effect("routes repository initialization through an explicit VCS driver kind", () => { |
| 59 | const calls: string[] = []; | 59 | const calls: string[] = []; |
| 60 | const driver = makeDriver(calls); | 60 | const driver = makeDriver(calls); |
| 61 | const testLayer = layer.pipe( | 61 | const testLayer = VcsProvisioningService.layer.pipe( |
| 62 | Layer.provide( | 62 | Layer.provide( |
| 63 | Layer.mock(VcsDriverRegistry)({ | 63 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ |
| 64 | get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), | 64 | get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), |
| 65 | }), | 65 | }), |
| 66 | ), | 66 | ), |
| 67 | ); | 67 | ); |
| 68 | 68 | ||
| 69 | return Effect.gen(function* () { | 69 | return Effect.gen(function* () { |
| 70 | const provisioning = yield* VcsProvisioningService; | 70 | const provisioning = yield* VcsProvisioningService.VcsProvisioningService; |
| 71 | yield* provisioning.initRepository({ cwd: "/repo", kind: "git" }); | 71 | yield* provisioning.initRepository({ cwd: "/repo", kind: "git" }); |
| 72 | 72 | ||
| 73 | assert.deepStrictEqual(calls, ["git:/repo"]); | 73 | assert.deepStrictEqual(calls, ["git:/repo"]); |
| ... | @@ -77,16 +77,16 @@ it.effect("routes repository initialization through an explicit VCS driver kind" | ... | @@ -77,16 +77,16 @@ it.effect("routes repository initialization through an explicit VCS driver kind" |
| 77 | it.effect("defaults repository initialization to Git until callers choose a VCS kind", () => { | 77 | it.effect("defaults repository initialization to Git until callers choose a VCS kind", () => { |
| 78 | const calls: string[] = []; | 78 | const calls: string[] = []; |
| 79 | const driver = makeDriver(calls); | 79 | const driver = makeDriver(calls); |
| 80 | const testLayer = layer.pipe( | 80 | const testLayer = VcsProvisioningService.layer.pipe( |
| 81 | Layer.provide( | 81 | Layer.provide( |
| 82 | Layer.mock(VcsDriverRegistry)({ | 82 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ |
| 83 | get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), | 83 | get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), |
| 84 | }), | 84 | }), |
| 85 | ), | 85 | ), |
| 86 | ); | 86 | ); |
| 87 | 87 | ||
| 88 | return Effect.gen(function* () { | 88 | return Effect.gen(function* () { |
| 89 | const provisioning = yield* VcsProvisioningService; | 89 | const provisioning = yield* VcsProvisioningService.VcsProvisioningService; |
| 90 | yield* provisioning.initRepository({ cwd: "/repo" }); | 90 | yield* provisioning.initRepository({ cwd: "/repo" }); |
| 91 | 91 | ||
| 92 | assert.deepStrictEqual(calls, ["default:/repo"]); | 92 | assert.deepStrictEqual(calls, ["default:/repo"]); |
apps/server/src/vcs/VcsProvisioningService.ts+2-2| ... | @@ -6,7 +6,7 @@ import { | ... | @@ -6,7 +6,7 @@ import { |
| 6 | type VcsInitInput, | 6 | type VcsInitInput, |
| 7 | VcsUnsupportedOperationError, | 7 | VcsUnsupportedOperationError, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import { VcsDriverRegistry } from "./VcsDriverRegistry.ts"; | 9 | import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; |
| 10 | 10 | ||
| 11 | export interface VcsProvisioningServiceShape { | 11 | export interface VcsProvisioningServiceShape { |
| 12 | readonly initRepository: (input: VcsInitInput) => Effect.Effect<void, VcsError>; | 12 | readonly initRepository: (input: VcsInitInput) => Effect.Effect<void, VcsError>; |
| ... | @@ -36,7 +36,7 @@ function resolveRequestedKind( | ... | @@ -36,7 +36,7 @@ function resolveRequestedKind( |
| 36 | } | 36 | } |
| 37 | 37 | ||
| 38 | export const make = Effect.fn("makeVcsProvisioningService")(function* () { | 38 | export const make = Effect.fn("makeVcsProvisioningService")(function* () { |
| 39 | const registry = yield* VcsDriverRegistry; | 39 | const registry = yield* VcsDriverRegistry.VcsDriverRegistry; |
| 40 | 40 | ||
| 41 | const initRepository: VcsProvisioningServiceShape["initRepository"] = Effect.fn( | 41 | const initRepository: VcsProvisioningServiceShape["initRepository"] = Effect.fn( |
| 42 | "VcsProvisioningService.initRepository", | 42 | "VcsProvisioningService.initRepository", |
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+17-21| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it, describe } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { Deferred, Effect, Exit, FileSystem, Layer, Option, Path, Scope, Stream } from "effect"; | 3 | import { Deferred, Effect, Exit, FileSystem, Layer, Option, Path, Scope, Stream } from "effect"; |
| 4 | import type { | 4 | import type { |
| ... | @@ -7,13 +7,9 @@ import type { | ... | @@ -7,13 +7,9 @@ import type { |
| 7 | VcsStatusResult, | 7 | VcsStatusResult, |
| 8 | VcsStatusStreamEvent, | 8 | VcsStatusStreamEvent, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import { describe } from "vitest"; | ||
| 11 | 10 | ||
| 12 | import { | 11 | import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; |
| 13 | VcsStatusBroadcaster, | 12 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; |
| 14 | layer as VcsStatusBroadcasterLayer, | ||
| 15 | } from "./VcsStatusBroadcaster.ts"; | ||
| 16 | import { GitWorkflowService, type GitWorkflowServiceShape } from "../git/GitWorkflowService.ts"; | ||
| 17 | 13 | ||
| 18 | const baseLocalStatus: VcsStatusLocalResult = { | 14 | const baseLocalStatus: VcsStatusLocalResult = { |
| 19 | isRepo: true, | 15 | isRepo: true, |
| ... | @@ -49,9 +45,9 @@ function makeTestLayer(state: { | ... | @@ -49,9 +45,9 @@ function makeTestLayer(state: { |
| 49 | localInvalidationCalls: number; | 45 | localInvalidationCalls: number; |
| 50 | remoteInvalidationCalls: number; | 46 | remoteInvalidationCalls: number; |
| 51 | }) { | 47 | }) { |
| 52 | return VcsStatusBroadcasterLayer.pipe( | 48 | return VcsStatusBroadcaster.layer.pipe( |
| 53 | Layer.provide( | 49 | Layer.provide( |
| 54 | Layer.mock(GitWorkflowService)({ | 50 | Layer.mock(GitWorkflowService.GitWorkflowService)({ |
| 55 | localStatus: () => | 51 | localStatus: () => |
| 56 | Effect.sync(() => { | 52 | Effect.sync(() => { |
| 57 | state.localStatusCalls += 1; | 53 | state.localStatusCalls += 1; |
| ... | @@ -87,7 +83,7 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -87,7 +83,7 @@ describe("VcsStatusBroadcaster", () => { |
| 87 | }; | 83 | }; |
| 88 | 84 | ||
| 89 | return Effect.gen(function* () { | 85 | return Effect.gen(function* () { |
| 90 | const broadcaster = yield* VcsStatusBroadcaster; | 86 | const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; |
| 91 | 87 | ||
| 92 | const first = yield* broadcaster.getStatus({ cwd: "/repo" }); | 88 | const first = yield* broadcaster.getStatus({ cwd: "/repo" }); |
| 93 | const second = yield* broadcaster.getStatus({ cwd: "/repo" }); | 89 | const second = yield* broadcaster.getStatus({ cwd: "/repo" }); |
| ... | @@ -112,7 +108,7 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -112,7 +108,7 @@ describe("VcsStatusBroadcaster", () => { |
| 112 | }; | 108 | }; |
| 113 | 109 | ||
| 114 | return Effect.gen(function* () { | 110 | return Effect.gen(function* () { |
| 115 | const broadcaster = yield* VcsStatusBroadcaster; | 111 | const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; |
| 116 | const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); | 112 | const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); |
| 117 | 113 | ||
| 118 | state.currentLocalStatus = { | 114 | state.currentLocalStatus = { |
| ... | @@ -153,7 +149,7 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -153,7 +149,7 @@ describe("VcsStatusBroadcaster", () => { |
| 153 | }; | 149 | }; |
| 154 | 150 | ||
| 155 | return Effect.gen(function* () { | 151 | return Effect.gen(function* () { |
| 156 | const broadcaster = yield* VcsStatusBroadcaster; | 152 | const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; |
| 157 | const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); | 153 | const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); |
| 158 | 154 | ||
| 159 | state.currentLocalStatus = { | 155 | state.currentLocalStatus = { |
| ... | @@ -188,9 +184,9 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -188,9 +184,9 @@ describe("VcsStatusBroadcaster", () => { |
| 188 | localInvalidationCalls: 0, | 184 | localInvalidationCalls: 0, |
| 189 | remoteInvalidationCalls: 0, | 185 | remoteInvalidationCalls: 0, |
| 190 | }; | 186 | }; |
| 191 | const testLayer = VcsStatusBroadcasterLayer.pipe( | 187 | const testLayer = VcsStatusBroadcaster.layer.pipe( |
| 192 | Layer.provide( | 188 | Layer.provide( |
| 193 | Layer.mock(GitWorkflowService)({ | 189 | Layer.mock(GitWorkflowService.GitWorkflowService)({ |
| 194 | localStatus: (input) => | 190 | localStatus: (input) => |
| 195 | Effect.sync(() => { | 191 | Effect.sync(() => { |
| 196 | seenCwds.push(input.cwd); | 192 | seenCwds.push(input.cwd); |
| ... | @@ -211,7 +207,7 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -211,7 +207,7 @@ describe("VcsStatusBroadcaster", () => { |
| 211 | Effect.sync(() => { | 207 | Effect.sync(() => { |
| 212 | state.remoteInvalidationCalls += 1; | 208 | state.remoteInvalidationCalls += 1; |
| 213 | }), | 209 | }), |
| 214 | } satisfies Partial<GitWorkflowServiceShape>), | 210 | } satisfies Partial<GitWorkflowService.GitWorkflowServiceShape>), |
| 215 | ), | 211 | ), |
| 216 | ); | 212 | ); |
| 217 | 213 | ||
| ... | @@ -228,7 +224,7 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -228,7 +224,7 @@ describe("VcsStatusBroadcaster", () => { |
| 228 | yield* fileSystem.symlink(realDir, linkDir); | 224 | yield* fileSystem.symlink(realDir, linkDir); |
| 229 | const realPath = yield* fileSystem.realPath(realDir); | 225 | const realPath = yield* fileSystem.realPath(realDir); |
| 230 | 226 | ||
| 231 | const broadcaster = yield* VcsStatusBroadcaster; | 227 | const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; |
| 232 | yield* broadcaster.getStatus({ cwd: linkDir }); | 228 | yield* broadcaster.getStatus({ cwd: linkDir }); |
| 233 | yield* broadcaster.getStatus({ cwd: realDir }); | 229 | yield* broadcaster.getStatus({ cwd: realDir }); |
| 234 | 230 | ||
| ... | @@ -249,7 +245,7 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -249,7 +245,7 @@ describe("VcsStatusBroadcaster", () => { |
| 249 | }; | 245 | }; |
| 250 | 246 | ||
| 251 | return Effect.gen(function* () { | 247 | return Effect.gen(function* () { |
| 252 | const broadcaster = yield* VcsStatusBroadcaster; | 248 | const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; |
| 253 | const snapshotDeferred = yield* Deferred.make<VcsStatusStreamEvent>(); | 249 | const snapshotDeferred = yield* Deferred.make<VcsStatusStreamEvent>(); |
| 254 | const remoteUpdatedDeferred = yield* Deferred.make<VcsStatusStreamEvent>(); | 250 | const remoteUpdatedDeferred = yield* Deferred.make<VcsStatusStreamEvent>(); |
| 255 | yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { | 251 | yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { |
| ... | @@ -289,9 +285,9 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -289,9 +285,9 @@ describe("VcsStatusBroadcaster", () => { |
| 289 | }; | 285 | }; |
| 290 | let remoteInterruptedDeferred: Deferred.Deferred<void, never> | null = null; | 286 | let remoteInterruptedDeferred: Deferred.Deferred<void, never> | null = null; |
| 291 | let remoteStartedDeferred: Deferred.Deferred<void, never> | null = null; | 287 | let remoteStartedDeferred: Deferred.Deferred<void, never> | null = null; |
| 292 | const testLayer = VcsStatusBroadcasterLayer.pipe( | 288 | const testLayer = VcsStatusBroadcaster.layer.pipe( |
| 293 | Layer.provide( | 289 | Layer.provide( |
| 294 | Layer.mock(GitWorkflowService)({ | 290 | Layer.mock(GitWorkflowService.GitWorkflowService)({ |
| 295 | localStatus: () => | 291 | localStatus: () => |
| 296 | Effect.sync(() => { | 292 | Effect.sync(() => { |
| 297 | state.localStatusCalls += 1; | 293 | state.localStatusCalls += 1; |
| ... | @@ -321,7 +317,7 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -321,7 +317,7 @@ describe("VcsStatusBroadcaster", () => { |
| 321 | Effect.sync(() => { | 317 | Effect.sync(() => { |
| 322 | state.remoteInvalidationCalls += 1; | 318 | state.remoteInvalidationCalls += 1; |
| 323 | }), | 319 | }), |
| 324 | } satisfies Partial<GitWorkflowServiceShape>), | 320 | } satisfies Partial<GitWorkflowService.GitWorkflowServiceShape>), |
| 325 | ), | 321 | ), |
| 326 | ); | 322 | ); |
| 327 | 323 | ||
| ... | @@ -331,7 +327,7 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -331,7 +327,7 @@ describe("VcsStatusBroadcaster", () => { |
| 331 | remoteInterruptedDeferred = remoteInterrupted; | 327 | remoteInterruptedDeferred = remoteInterrupted; |
| 332 | remoteStartedDeferred = remoteStarted; | 328 | remoteStartedDeferred = remoteStarted; |
| 333 | 329 | ||
| 334 | const broadcaster = yield* VcsStatusBroadcaster; | 330 | const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; |
| 335 | const firstSnapshot = yield* Deferred.make<VcsStatusStreamEvent>(); | 331 | const firstSnapshot = yield* Deferred.make<VcsStatusStreamEvent>(); |
| 336 | const secondSnapshot = yield* Deferred.make<VcsStatusStreamEvent>(); | 332 | const secondSnapshot = yield* Deferred.make<VcsStatusStreamEvent>(); |
| 337 | const firstScope = yield* Scope.make(); | 333 | const firstScope = yield* Scope.make(); |
apps/server/src/vcs/VcsStatusBroadcaster.ts+3-3| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { realpathSync } from "node:fs"; | 1 | import { realpathSync } from "node:fs"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | Context, | ||
| 4 | Duration, | 5 | Duration, |
| 5 | Effect, | 6 | Effect, |
| 6 | Exit, | 7 | Exit, |
| ... | @@ -20,10 +21,9 @@ import type { | ... | @@ -20,10 +21,9 @@ import type { |
| 20 | VcsStatusResult, | 21 | VcsStatusResult, |
| 21 | VcsStatusStreamEvent, | 22 | VcsStatusStreamEvent, |
| 22 | } from "@t3tools/contracts"; | 23 | } from "@t3tools/contracts"; |
| 23 | import { Context } from "effect"; | ||
| 24 | import { mergeGitStatusParts } from "@t3tools/shared/git"; | 24 | import { mergeGitStatusParts } from "@t3tools/shared/git"; |
| 25 | 25 | ||
| 26 | import { GitWorkflowService } from "../git/GitWorkflowService.ts"; | 26 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; |
| 27 | 27 | ||
| 28 | const VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); | 28 | const VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); |
| 29 | 29 | ||
| ... | @@ -80,7 +80,7 @@ function normalizeCwd(cwd: string): string { | ... | @@ -80,7 +80,7 @@ function normalizeCwd(cwd: string): string { |
| 80 | export const layer = Layer.effect( | 80 | export const layer = Layer.effect( |
| 81 | VcsStatusBroadcaster, | 81 | VcsStatusBroadcaster, |
| 82 | Effect.gen(function* () { | 82 | Effect.gen(function* () { |
| 83 | const workflow = yield* GitWorkflowService; | 83 | const workflow = yield* GitWorkflowService.GitWorkflowService; |
| 84 | const changesPubSub = yield* Effect.acquireRelease( | 84 | const changesPubSub = yield* Effect.acquireRelease( |
| 85 | PubSub.unbounded<VcsStatusChange>(), | 85 | PubSub.unbounded<VcsStatusChange>(), |
| 86 | (pubsub) => PubSub.shutdown(pubsub), | 86 | (pubsub) => PubSub.shutdown(pubsub), |
apps/server/src/vcs/testing/VcsDriverContractHarness.ts+23-12| ... | @@ -1,10 +1,17 @@ | ... | @@ -1,10 +1,17 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it, describe } from "@effect/vitest"; |
| 2 | import { DateTime, Option } from "effect"; | 2 | import { |
| 3 | import { Effect, FileSystem, Layer, Path, type PlatformError, type Scope } from "effect"; | 3 | Effect, |
| 4 | import { describe } from "vitest"; | 4 | FileSystem, |
| 5 | Layer, | ||
| 6 | Path, | ||
| 7 | type PlatformError, | ||
| 8 | type Scope, | ||
| 9 | DateTime, | ||
| 10 | Option, | ||
| 11 | } from "effect"; | ||
| 5 | 12 | ||
| 6 | import type { VcsDriverKind } from "@t3tools/contracts"; | 13 | import type { VcsDriverKind } from "@t3tools/contracts"; |
| 7 | import { VcsDriver } from "../VcsDriver.ts"; | 14 | import * as VcsDriver from "../VcsDriver.ts"; |
| 8 | 15 | ||
| 9 | export interface VcsDriverFixture<R, E> { | 16 | export interface VcsDriverFixture<R, E> { |
| 10 | readonly createRepo: (cwd: string) => Effect.Effect<void, E, R>; | 17 | readonly createRepo: (cwd: string) => Effect.Effect<void, E, R>; |
| ... | @@ -24,7 +31,11 @@ export interface VcsDriverFixture<R, E> { | ... | @@ -24,7 +31,11 @@ export interface VcsDriverFixture<R, E> { |
| 24 | export interface VcsDriverContractSuiteInput<R, E> { | 31 | export interface VcsDriverContractSuiteInput<R, E> { |
| 25 | readonly name: string; | 32 | readonly name: string; |
| 26 | readonly kind: VcsDriverKind; | 33 | readonly kind: VcsDriverKind; |
| 27 | readonly layer: Layer.Layer<VcsDriver | R | FileSystem.FileSystem | Path.Path, E, never>; | 34 | readonly layer: Layer.Layer< |
| 35 | VcsDriver.VcsDriver | R | FileSystem.FileSystem | Path.Path, | ||
| 36 | E, | ||
| 37 | never | ||
| 38 | >; | ||
| 28 | readonly fixture: VcsDriverFixture<R, E>; | 39 | readonly fixture: VcsDriverFixture<R, E>; |
| 29 | } | 40 | } |
| 30 | 41 | ||
| ... | @@ -42,7 +53,7 @@ export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInp | ... | @@ -42,7 +53,7 @@ export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInp |
| 42 | it.effect("returns null outside a repository", () => | 53 | it.effect("returns null outside a repository", () => |
| 43 | Effect.gen(function* () { | 54 | Effect.gen(function* () { |
| 44 | const cwd = yield* makeTmpDir(); | 55 | const cwd = yield* makeTmpDir(); |
| 45 | const driver = yield* VcsDriver; | 56 | const driver = yield* VcsDriver.VcsDriver; |
| 46 | 57 | ||
| 47 | assert.equal(yield* driver.detectRepository(cwd), null); | 58 | assert.equal(yield* driver.detectRepository(cwd), null); |
| 48 | assert.equal(yield* driver.isInsideWorkTree(cwd), false); | 59 | assert.equal(yield* driver.isInsideWorkTree(cwd), false); |
| ... | @@ -52,7 +63,7 @@ export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInp | ... | @@ -52,7 +63,7 @@ export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInp |
| 52 | it.effect("detects repository identity inside a repository and nested directories", () => | 63 | it.effect("detects repository identity inside a repository and nested directories", () => |
| 53 | Effect.gen(function* () { | 64 | Effect.gen(function* () { |
| 54 | const cwd = yield* makeTmpDir(); | 65 | const cwd = yield* makeTmpDir(); |
| 55 | const driver = yield* VcsDriver; | 66 | const driver = yield* VcsDriver.VcsDriver; |
| 56 | 67 | ||
| 57 | yield* input.fixture.createRepo(cwd); | 68 | yield* input.fixture.createRepo(cwd); |
| 58 | yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n"); | 69 | yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n"); |
| ... | @@ -77,7 +88,7 @@ export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInp | ... | @@ -77,7 +88,7 @@ export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInp |
| 77 | it.effect("lists tracked and untracked non-ignored files", () => | 88 | it.effect("lists tracked and untracked non-ignored files", () => |
| 78 | Effect.gen(function* () { | 89 | Effect.gen(function* () { |
| 79 | const cwd = yield* makeTmpDir(); | 90 | const cwd = yield* makeTmpDir(); |
| 80 | const driver = yield* VcsDriver; | 91 | const driver = yield* VcsDriver.VcsDriver; |
| 81 | 92 | ||
| 82 | yield* input.fixture.createRepo(cwd); | 93 | yield* input.fixture.createRepo(cwd); |
| 83 | yield* input.fixture.writeFile(cwd, "tracked.ts", "export const tracked = true;\n"); | 94 | yield* input.fixture.writeFile(cwd, "tracked.ts", "export const tracked = true;\n"); |
| ... | @@ -101,7 +112,7 @@ export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInp | ... | @@ -101,7 +112,7 @@ export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInp |
| 101 | it.effect("excludes ignored files from workspace listing", () => | 112 | it.effect("excludes ignored files from workspace listing", () => |
| 102 | Effect.gen(function* () { | 113 | Effect.gen(function* () { |
| 103 | const cwd = yield* makeTmpDir(); | 114 | const cwd = yield* makeTmpDir(); |
| 104 | const driver = yield* VcsDriver; | 115 | const driver = yield* VcsDriver.VcsDriver; |
| 105 | 116 | ||
| 106 | yield* input.fixture.createRepo(cwd); | 117 | yield* input.fixture.createRepo(cwd); |
| 107 | yield* input.fixture.ignorePath(cwd, "*.log"); | 118 | yield* input.fixture.ignorePath(cwd, "*.log"); |
| ... | @@ -122,7 +133,7 @@ export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInp | ... | @@ -122,7 +133,7 @@ export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInp |
| 122 | it.effect("filters ignored paths", () => | 133 | it.effect("filters ignored paths", () => |
| 123 | Effect.gen(function* () { | 134 | Effect.gen(function* () { |
| 124 | const cwd = yield* makeTmpDir(); | 135 | const cwd = yield* makeTmpDir(); |
| 125 | const driver = yield* VcsDriver; | 136 | const driver = yield* VcsDriver.VcsDriver; |
| 126 | 137 | ||
| 127 | yield* input.fixture.createRepo(cwd); | 138 | yield* input.fixture.createRepo(cwd); |
| 128 | yield* input.fixture.ignorePath(cwd, "*.log"); | 139 | yield* input.fixture.ignorePath(cwd, "*.log"); |
| ... | @@ -140,7 +151,7 @@ export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInp | ... | @@ -140,7 +151,7 @@ export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInp |
| 140 | it.effect("returns empty input unchanged", () => | 151 | it.effect("returns empty input unchanged", () => |
| 141 | Effect.gen(function* () { | 152 | Effect.gen(function* () { |
| 142 | const cwd = yield* makeTmpDir(); | 153 | const cwd = yield* makeTmpDir(); |
| 143 | const driver = yield* VcsDriver; | 154 | const driver = yield* VcsDriver.VcsDriver; |
| 144 | 155 | ||
| 145 | yield* input.fixture.createRepo(cwd); | 156 | yield* input.fixture.createRepo(cwd); |
| 146 | 157 |
apps/server/src/ws.ts+27-1| ... | @@ -56,6 +56,14 @@ import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; | ... | @@ -56,6 +56,14 @@ import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; |
| 56 | import { ServerAuth } from "./auth/Services/ServerAuth.ts"; | 56 | import { ServerAuth } from "./auth/Services/ServerAuth.ts"; |
| 57 | import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; | 57 | import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; |
| 58 | import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; | 58 | import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; |
| 59 | import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; | ||
| 60 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; | ||
| 61 | import * as GitHubCli from "./sourceControl/GitHubCli.ts"; | ||
| 62 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; | ||
| 63 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; | ||
| 64 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | ||
| 65 | import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; | ||
| 66 | import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; | ||
| 59 | import * as VcsProcess from "./vcs/VcsProcess.ts"; | 67 | import * as VcsProcess from "./vcs/VcsProcess.ts"; |
| 60 | import { | 68 | import { |
| 61 | BootstrapCredentialService, | 69 | BootstrapCredentialService, |
| ... | @@ -1134,7 +1142,25 @@ export const websocketRpcRouteLayer = Layer.unwrap( | ... | @@ -1134,7 +1142,25 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 1134 | makeWsRpcLayer(session.sessionId).pipe( | 1142 | makeWsRpcLayer(session.sessionId).pipe( |
| 1135 | Layer.provideMerge(RpcSerialization.layerJson), | 1143 | Layer.provideMerge(RpcSerialization.layerJson), |
| 1136 | Layer.provide( | 1144 | Layer.provide( |
| 1137 | SourceControlDiscoveryLayer.layer.pipe(Layer.provide(VcsProcess.layer)), | 1145 | SourceControlDiscoveryLayer.layer.pipe( |
| 1146 | Layer.provide( | ||
| 1147 | SourceControlProviderRegistry.layer.pipe( | ||
| 1148 | Layer.provide( | ||
| 1149 | Layer.mergeAll( | ||
| 1150 | AzureDevOpsCli.layer, | ||
| 1151 | BitbucketApi.layer, | ||
| 1152 | GitHubCli.layer, | ||
| 1153 | GitLabCli.layer, | ||
| 1154 | ), | ||
| 1155 | ), | ||
| 1156 | Layer.provideMerge(GitVcsDriver.layer), | ||
| 1157 | Layer.provide( | ||
| 1158 | VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)), | ||
| 1159 | ), | ||
| 1160 | ), | ||
| 1161 | ), | ||
| 1162 | Layer.provide(VcsProcess.layer), | ||
| 1163 | ), | ||
| 1138 | ), | 1164 | ), |
| 1139 | ), | 1165 | ), |
| 1140 | ), | 1166 | ), |
apps/web/src/components/AnimatedHeight.tsx created+59| ... | @@ -0,0 +1,59 @@ | ||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { type ReactNode, useLayoutEffect, useRef, useState } from "react"; | ||
| 4 | |||
| 5 | export function AnimatedHeight({ children }: { readonly children: ReactNode }) { | ||
| 6 | const contentRef = useRef<HTMLDivElement>(null); | ||
| 7 | const [height, setHeight] = useState<number | null>(null); | ||
| 8 | |||
| 9 | useLayoutEffect(() => { | ||
| 10 | const element = contentRef.current; | ||
| 11 | if (!element) return; | ||
| 12 | let firstFrameId: number | null = null; | ||
| 13 | let secondFrameId: number | null = null; | ||
| 14 | |||
| 15 | const updateHeight = () => { | ||
| 16 | const nextHeight = Math.ceil(element.scrollHeight || element.getBoundingClientRect().height); | ||
| 17 | setHeight((currentHeight) => (currentHeight === nextHeight ? currentHeight : nextHeight)); | ||
| 18 | }; | ||
| 19 | const cancelPendingFrames = () => { | ||
| 20 | if (firstFrameId !== null) { | ||
| 21 | window.cancelAnimationFrame(firstFrameId); | ||
| 22 | firstFrameId = null; | ||
| 23 | } | ||
| 24 | if (secondFrameId !== null) { | ||
| 25 | window.cancelAnimationFrame(secondFrameId); | ||
| 26 | secondFrameId = null; | ||
| 27 | } | ||
| 28 | }; | ||
| 29 | const updateHeightAfterPaint = () => { | ||
| 30 | cancelPendingFrames(); | ||
| 31 | updateHeight(); | ||
| 32 | firstFrameId = window.requestAnimationFrame(() => { | ||
| 33 | firstFrameId = null; | ||
| 34 | updateHeight(); | ||
| 35 | secondFrameId = window.requestAnimationFrame(() => { | ||
| 36 | secondFrameId = null; | ||
| 37 | updateHeight(); | ||
| 38 | }); | ||
| 39 | }); | ||
| 40 | }; | ||
| 41 | |||
| 42 | updateHeightAfterPaint(); | ||
| 43 | const resizeObserver = new ResizeObserver(updateHeightAfterPaint); | ||
| 44 | resizeObserver.observe(element); | ||
| 45 | return () => { | ||
| 46 | resizeObserver.disconnect(); | ||
| 47 | cancelPendingFrames(); | ||
| 48 | }; | ||
| 49 | }, []); | ||
| 50 | |||
| 51 | return ( | ||
| 52 | <div | ||
| 53 | className="overflow-hidden transition-[height] duration-200 ease-out motion-reduce:transition-none" | ||
| 54 | style={height === null ? undefined : { height }} | ||
| 55 | > | ||
| 56 | <div ref={contentRef}>{children}</div> | ||
| 57 | </div> | ||
| 58 | ); | ||
| 59 | } | ||
apps/web/src/components/ChatView.browser.tsx+74-1| ... | @@ -22,6 +22,7 @@ import { | ... | @@ -22,6 +22,7 @@ import { |
| 22 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; | 22 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; |
| 23 | import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; | 23 | import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; |
| 24 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; | 24 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; |
| 25 | import { Option } from "effect"; | ||
| 25 | import { HttpResponse, http, ws } from "msw"; | 26 | import { HttpResponse, http, ws } from "msw"; |
| 26 | import { setupWorker } from "msw/browser"; | 27 | import { setupWorker } from "msw/browser"; |
| 27 | import { page } from "vitest/browser"; | 28 | import { page } from "vitest/browser"; |
| ... | @@ -953,6 +954,73 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { | ... | @@ -953,6 +954,73 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { |
| 953 | if (tag === WS_METHODS.serverGetConfig) { | 954 | if (tag === WS_METHODS.serverGetConfig) { |
| 954 | return fixture.serverConfig; | 955 | return fixture.serverConfig; |
| 955 | } | 956 | } |
| 957 | if (tag === WS_METHODS.serverDiscoverSourceControl) { | ||
| 958 | return { | ||
| 959 | versionControlSystems: [], | ||
| 960 | sourceControlProviders: [ | ||
| 961 | { | ||
| 962 | kind: "github", | ||
| 963 | label: "GitHub", | ||
| 964 | executable: "gh", | ||
| 965 | status: "available", | ||
| 966 | version: Option.some("gh version 2.0.0"), | ||
| 967 | installHint: "Install GitHub CLI.", | ||
| 968 | detail: Option.none(), | ||
| 969 | auth: { | ||
| 970 | status: "authenticated", | ||
| 971 | account: Option.some("t3-oss"), | ||
| 972 | host: Option.some("github.com"), | ||
| 973 | detail: Option.none(), | ||
| 974 | }, | ||
| 975 | }, | ||
| 976 | { | ||
| 977 | kind: "gitlab", | ||
| 978 | label: "GitLab", | ||
| 979 | executable: "glab", | ||
| 980 | status: "available", | ||
| 981 | version: Option.some("glab version 1.0.0"), | ||
| 982 | installHint: "Install GitLab CLI.", | ||
| 983 | detail: Option.none(), | ||
| 984 | auth: { | ||
| 985 | status: "authenticated", | ||
| 986 | account: Option.some("t3-oss"), | ||
| 987 | host: Option.some("gitlab.com"), | ||
| 988 | detail: Option.none(), | ||
| 989 | }, | ||
| 990 | }, | ||
| 991 | { | ||
| 992 | kind: "bitbucket", | ||
| 993 | label: "Bitbucket", | ||
| 994 | executable: "Bitbucket REST API", | ||
| 995 | status: "available", | ||
| 996 | version: Option.none(), | ||
| 997 | installHint: "Set Bitbucket API token environment variables.", | ||
| 998 | detail: Option.none(), | ||
| 999 | auth: { | ||
| 1000 | status: "authenticated", | ||
| 1001 | account: Option.some("t3-oss"), | ||
| 1002 | host: Option.some("bitbucket.org"), | ||
| 1003 | detail: Option.none(), | ||
| 1004 | }, | ||
| 1005 | }, | ||
| 1006 | { | ||
| 1007 | kind: "azure-devops", | ||
| 1008 | label: "Azure DevOps", | ||
| 1009 | executable: "az", | ||
| 1010 | status: "available", | ||
| 1011 | version: Option.some("azure-cli 2.0.0"), | ||
| 1012 | installHint: "Install Azure CLI.", | ||
| 1013 | detail: Option.none(), | ||
| 1014 | auth: { | ||
| 1015 | status: "authenticated", | ||
| 1016 | account: Option.some("t3-oss"), | ||
| 1017 | host: Option.some("dev.azure.com"), | ||
| 1018 | detail: Option.none(), | ||
| 1019 | }, | ||
| 1020 | }, | ||
| 1021 | ], | ||
| 1022 | }; | ||
| 1023 | } | ||
| 956 | if (tag === WS_METHODS.vcsListRefs) { | 1024 | if (tag === WS_METHODS.vcsListRefs) { |
| 957 | return { | 1025 | return { |
| 958 | isRepo: true, | 1026 | isRepo: true, |
| ... | @@ -4650,6 +4718,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -4650,6 +4718,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 4650 | 4718 | ||
| 4651 | const palette = page.getByTestId("command-palette"); | 4719 | const palette = page.getByTestId("command-palette"); |
| 4652 | await expect.element(palette).toBeInTheDocument(); | 4720 | await expect.element(palette).toBeInTheDocument(); |
| 4721 | await palette.getByText("Local folder", { exact: true }).click(); | ||
| 4653 | 4722 | ||
| 4654 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); | 4723 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); |
| 4655 | await expect.element(browseInput).toHaveValue("~/"); | 4724 | await expect.element(browseInput).toHaveValue("~/"); |
| ... | @@ -4712,6 +4781,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -4712,6 +4781,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 4712 | 4781 | ||
| 4713 | const palette = page.getByTestId("command-palette"); | 4782 | const palette = page.getByTestId("command-palette"); |
| 4714 | await expect.element(palette).toBeInTheDocument(); | 4783 | await expect.element(palette).toBeInTheDocument(); |
| 4784 | await palette.getByText("Local folder", { exact: true }).click(); | ||
| 4715 | 4785 | ||
| 4716 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); | 4786 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); |
| 4717 | await expect.element(browseInput).toHaveValue("~/Development/"); | 4787 | await expect.element(browseInput).toHaveValue("~/Development/"); |
| ... | @@ -4771,6 +4841,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -4771,6 +4841,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 4771 | await page.getByTestId("sidebar-add-project-trigger").click(); | 4841 | await page.getByTestId("sidebar-add-project-trigger").click(); |
| 4772 | 4842 | ||
| 4773 | await expect.element(palette).toBeInTheDocument(); | 4843 | await expect.element(palette).toBeInTheDocument(); |
| 4844 | await palette.getByText("Local folder", { exact: true }).click(); | ||
| 4774 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); | 4845 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); |
| 4775 | await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Desktop/fresh-project"); | 4846 | await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Desktop/fresh-project"); |
| 4776 | 4847 | ||
| ... | @@ -4850,6 +4921,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -4850,6 +4921,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 4850 | await page.getByTestId("sidebar-add-project-trigger").click(); | 4921 | await page.getByTestId("sidebar-add-project-trigger").click(); |
| 4851 | 4922 | ||
| 4852 | await expect.element(palette).toBeInTheDocument(); | 4923 | await expect.element(palette).toBeInTheDocument(); |
| 4924 | await palette.getByText("Local folder", { exact: true }).click(); | ||
| 4853 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); | 4925 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); |
| 4854 | await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/codex/"); | 4926 | await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/codex/"); |
| 4855 | 4927 | ||
| ... | @@ -4976,12 +5048,12 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -4976,12 +5048,12 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 4976 | 5048 | ||
| 4977 | await expect.element(palette).toBeInTheDocument(); | 5049 | await expect.element(palette).toBeInTheDocument(); |
| 4978 | await palette.getByText("Add project", { exact: true }).click(); | 5050 | await palette.getByText("Add project", { exact: true }).click(); |
| 4979 | await palette.getByText("Local folder", { exact: true }).click(); | ||
| 4980 | await expect.element(palette.getByText("Environments", { exact: true })).toBeInTheDocument(); | 5051 | await expect.element(palette.getByText("Environments", { exact: true })).toBeInTheDocument(); |
| 4981 | await expect | 5052 | await expect |
| 4982 | .element(palette.getByText("This device", { exact: true }).first()) | 5053 | .element(palette.getByText("This device", { exact: true }).first()) |
| 4983 | .toBeInTheDocument(); | 5054 | .toBeInTheDocument(); |
| 4984 | await palette.getByText("Staging", { exact: true }).click(); | 5055 | await palette.getByText("Staging", { exact: true }).click(); |
| 5056 | await palette.getByText("Local folder", { exact: true }).click(); | ||
| 4985 | 5057 | ||
| 4986 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); | 5058 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); |
| 4987 | await expect.element(browseInput).toHaveValue("~/workspaces/"); | 5059 | await expect.element(browseInput).toHaveValue("~/workspaces/"); |
| ... | @@ -5075,6 +5147,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -5075,6 +5147,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 5075 | 5147 | ||
| 5076 | const palette = page.getByTestId("command-palette"); | 5148 | const palette = page.getByTestId("command-palette"); |
| 5077 | await expect.element(palette).toBeInTheDocument(); | 5149 | await expect.element(palette).toBeInTheDocument(); |
| 5150 | await palette.getByText("Local folder", { exact: true }).click(); | ||
| 5078 | const browseInput = palette.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER); | 5151 | const browseInput = palette.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER); |
| 5079 | await browseInput.fill("~/Applications/access"); | 5152 | await browseInput.fill("~/Applications/access"); |
| 5080 | 5153 |
apps/web/src/components/CommandPalette.logic.ts+1| ... | @@ -17,6 +17,7 @@ export interface CommandPaletteItem { | ... | @@ -17,6 +17,7 @@ export interface CommandPaletteItem { |
| 17 | readonly description?: string; | 17 | readonly description?: string; |
| 18 | readonly timestamp?: string; | 18 | readonly timestamp?: string; |
| 19 | readonly icon: ReactNode; | 19 | readonly icon: ReactNode; |
| 20 | readonly disabled?: boolean; | ||
| 20 | /** Optional content rendered inline before the title text. */ | 21 | /** Optional content rendered inline before the title text. */ |
| 21 | readonly titleLeadingContent?: ReactNode; | 22 | readonly titleLeadingContent?: ReactNode; |
| 22 | /** Optional content rendered inline after the title text (before the timestamp). */ | 23 | /** Optional content rendered inline after the title text (before the timestamp). */ |
apps/web/src/components/CommandPalette.tsx+280-163| ... | @@ -7,11 +7,13 @@ import { | ... | @@ -7,11 +7,13 @@ import { |
| 7 | type FilesystemBrowseResult, | 7 | type FilesystemBrowseResult, |
| 8 | type ProjectId, | 8 | type ProjectId, |
| 9 | ProviderInstanceId, | 9 | ProviderInstanceId, |
| 10 | type SourceControlDiscoveryResult, | ||
| 10 | type SourceControlProviderKind, | 11 | type SourceControlProviderKind, |
| 11 | type SourceControlRepositoryInfo, | 12 | type SourceControlRepositoryInfo, |
| 12 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 13 | import { useQuery, useQueryClient } from "@tanstack/react-query"; | 14 | import { useQuery, useQueryClient } from "@tanstack/react-query"; |
| 14 | import { useNavigate, useParams } from "@tanstack/react-router"; | 15 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 16 | import { Option } from "effect"; | ||
| 15 | import { | 17 | import { |
| 16 | ArrowDownIcon, | 18 | ArrowDownIcon, |
| 17 | ArrowLeftIcon, | 19 | ArrowLeftIcon, |
| ... | @@ -28,6 +30,7 @@ import { | ... | @@ -28,6 +30,7 @@ import { |
| 28 | useCallback, | 30 | useCallback, |
| 29 | useDeferredValue, | 31 | useDeferredValue, |
| 30 | useEffect, | 32 | useEffect, |
| 33 | useLayoutEffect, | ||
| 31 | useMemo, | 34 | useMemo, |
| 32 | useRef, | 35 | useRef, |
| 33 | useState, | 36 | useState, |
| ... | @@ -45,6 +48,10 @@ import { | ... | @@ -45,6 +48,10 @@ import { |
| 45 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 48 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 46 | import { useSettings } from "../hooks/useSettings"; | 49 | import { useSettings } from "../hooks/useSettings"; |
| 47 | import { readLocalApi } from "../localApi"; | 50 | import { readLocalApi } from "../localApi"; |
| 51 | import { | ||
| 52 | getSourceControlDiscoverySnapshot, | ||
| 53 | refreshSourceControlDiscovery, | ||
| 54 | } from "../lib/sourceControlDiscoveryState"; | ||
| 48 | import { | 55 | import { |
| 49 | startNewThreadInProjectFromContext, | 56 | startNewThreadInProjectFromContext, |
| 50 | startNewThreadFromContext, | 57 | startNewThreadFromContext, |
| ... | @@ -92,7 +99,7 @@ import { | ... | @@ -92,7 +99,7 @@ import { |
| 92 | } from "./CommandPalette.logic"; | 99 | } from "./CommandPalette.logic"; |
| 93 | import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; | 100 | import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; |
| 94 | import { CommandPaletteResults } from "./CommandPaletteResults"; | 101 | import { CommandPaletteResults } from "./CommandPaletteResults"; |
| 95 | import { GitHubIcon, GitLabIcon } from "./Icons"; | 102 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; |
| 96 | import { ProjectFavicon } from "./ProjectFavicon"; | 103 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 97 | import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; | 104 | import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; |
| 98 | import { useServerKeybindings } from "../rpc/serverState"; | 105 | import { useServerKeybindings } from "../rpc/serverState"; |
| ... | @@ -108,6 +115,7 @@ import { | ... | @@ -108,6 +115,7 @@ import { |
| 108 | import { Button } from "./ui/button"; | 115 | import { Button } from "./ui/button"; |
| 109 | import { Kbd, KbdGroup } from "./ui/kbd"; | 116 | import { Kbd, KbdGroup } from "./ui/kbd"; |
| 110 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 117 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 118 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | ||
| 111 | import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; | 119 | import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; |
| 112 | import type { ChatComposerHandle } from "./chat/ChatComposer"; | 120 | import type { ChatComposerHandle } from "./chat/ChatComposer"; |
| 113 | 121 | ||
| ... | @@ -143,7 +151,10 @@ interface AddProjectEnvironmentOption { | ... | @@ -143,7 +151,10 @@ interface AddProjectEnvironmentOption { |
| 143 | readonly isPrimary: boolean; | 151 | readonly isPrimary: boolean; |
| 144 | } | 152 | } |
| 145 | 153 | ||
| 146 | type AddProjectRemoteProviderKind = Extract<SourceControlProviderKind, "github" | "gitlab">; | 154 | type AddProjectRemoteProviderKind = Extract< |
| 155 | SourceControlProviderKind, | ||
| 156 | "github" | "gitlab" | "bitbucket" | "azure-devops" | ||
| 157 | >; | ||
| 147 | type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; | 158 | type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; |
| 148 | 159 | ||
| 149 | type AddProjectCloneFlow = | 160 | type AddProjectCloneFlow = |
| ... | @@ -161,7 +172,19 @@ type AddProjectCloneFlow = | ... | @@ -161,7 +172,19 @@ type AddProjectCloneFlow = |
| 161 | readonly remoteUrl: string; | 172 | readonly remoteUrl: string; |
| 162 | }; | 173 | }; |
| 163 | 174 | ||
| 164 | const REMOTE_PROJECT_SOURCES: ReadonlyArray<AddProjectRemoteSource> = ["github", "gitlab", "url"]; | 175 | const REMOTE_PROJECT_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [ |
| 176 | "url", | ||
| 177 | "github", | ||
| 178 | "gitlab", | ||
| 179 | "bitbucket", | ||
| 180 | "azure-devops", | ||
| 181 | ]; | ||
| 182 | const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [ | ||
| 183 | "github", | ||
| 184 | "gitlab", | ||
| 185 | "bitbucket", | ||
| 186 | "azure-devops", | ||
| 187 | ]; | ||
| 165 | 188 | ||
| 166 | function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { | 189 | function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { |
| 167 | switch (source) { | 190 | switch (source) { |
| ... | @@ -169,11 +192,30 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { | ... | @@ -169,11 +192,30 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { |
| 169 | return "GitHub"; | 192 | return "GitHub"; |
| 170 | case "gitlab": | 193 | case "gitlab": |
| 171 | return "GitLab"; | 194 | return "GitLab"; |
| 195 | case "bitbucket": | ||
| 196 | return "Bitbucket"; | ||
| 197 | case "azure-devops": | ||
| 198 | return "Azure DevOps"; | ||
| 172 | case "url": | 199 | case "url": |
| 173 | return "Git URL"; | 200 | return "Git URL"; |
| 174 | } | 201 | } |
| 175 | } | 202 | } |
| 176 | 203 | ||
| 204 | function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string { | ||
| 205 | switch (source) { | ||
| 206 | case "github": | ||
| 207 | return "owner/repo"; | ||
| 208 | case "gitlab": | ||
| 209 | return "group/project"; | ||
| 210 | case "bitbucket": | ||
| 211 | return "workspace/repository"; | ||
| 212 | case "azure-devops": | ||
| 213 | return "project/repository"; | ||
| 214 | case "url": | ||
| 215 | return "URL"; | ||
| 216 | } | ||
| 217 | } | ||
| 218 | |||
| 177 | function remoteProjectSourceProvider( | 219 | function remoteProjectSourceProvider( |
| 178 | source: AddProjectRemoteSource, | 220 | source: AddProjectRemoteSource, |
| 179 | ): AddProjectRemoteProviderKind | null { | 221 | ): AddProjectRemoteProviderKind | null { |
| ... | @@ -186,6 +228,10 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri | ... | @@ -186,6 +228,10 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri |
| 186 | return <GitHubIcon className={className} />; | 228 | return <GitHubIcon className={className} />; |
| 187 | case "gitlab": | 229 | case "gitlab": |
| 188 | return <GitLabIcon className={className} />; | 230 | return <GitLabIcon className={className} />; |
| 231 | case "bitbucket": | ||
| 232 | return <BitbucketIcon className={className} />; | ||
| 233 | case "azure-devops": | ||
| 234 | return <AzureDevOpsIcon className={className} />; | ||
| 189 | case "url": | 235 | case "url": |
| 190 | return <LinkIcon className={className} />; | 236 | return <LinkIcon className={className} />; |
| 191 | } | 237 | } |
| ... | @@ -197,7 +243,80 @@ function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string | ... | @@ -197,7 +243,80 @@ function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string |
| 197 | if (flow.source === "url") { | 243 | if (flow.source === "url") { |
| 198 | return "Enter Git clone URL"; | 244 | return "Enter Git clone URL"; |
| 199 | } | 245 | } |
| 200 | return `Enter ${remoteProjectSourceLabel(flow.source)} repository (owner/repo)`; | 246 | return `Enter ${remoteProjectSourceLabel(flow.source)} repository (${remoteProjectSourcePathHint(flow.source)})`; |
| 247 | } | ||
| 248 | |||
| 249 | function sourceProviderKind(source: AddProjectRemoteSource): AddProjectRemoteProviderKind | null { | ||
| 250 | return source === "url" ? null : source; | ||
| 251 | } | ||
| 252 | |||
| 253 | function sortAddProjectProviderSources( | ||
| 254 | readinessBySource: AddProjectRemoteSourceReadiness, | ||
| 255 | ): ReadonlyArray<AddProjectRemoteProviderKind> { | ||
| 256 | return REMOTE_PROJECT_PROVIDER_SOURCES.toSorted((left, right) => { | ||
| 257 | const leftReady = readinessBySource[left].ready; | ||
| 258 | const rightReady = readinessBySource[right].ready; | ||
| 259 | if (leftReady !== rightReady) { | ||
| 260 | return leftReady ? -1 : 1; | ||
| 261 | } | ||
| 262 | return remoteProjectSourceLabel(left).localeCompare(remoteProjectSourceLabel(right)); | ||
| 263 | }); | ||
| 264 | } | ||
| 265 | |||
| 266 | type AddProjectRemoteSourceReadiness = Record< | ||
| 267 | AddProjectRemoteSource, | ||
| 268 | { readonly ready: boolean; readonly hint: string | null } | ||
| 269 | >; | ||
| 270 | |||
| 271 | function buildAddProjectRemoteSourceReadiness( | ||
| 272 | discovery: SourceControlDiscoveryResult | null, | ||
| 273 | ): AddProjectRemoteSourceReadiness { | ||
| 274 | const unavailable = { | ||
| 275 | ready: false, | ||
| 276 | hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", | ||
| 277 | } as const; | ||
| 278 | const defaultReadiness: AddProjectRemoteSourceReadiness = { | ||
| 279 | url: { ready: true, hint: null }, | ||
| 280 | github: unavailable, | ||
| 281 | gitlab: unavailable, | ||
| 282 | bitbucket: unavailable, | ||
| 283 | "azure-devops": unavailable, | ||
| 284 | }; | ||
| 285 | |||
| 286 | if (!discovery) { | ||
| 287 | return defaultReadiness; | ||
| 288 | } | ||
| 289 | |||
| 290 | const providerByKind = new Map( | ||
| 291 | discovery.sourceControlProviders.map((provider) => [provider.kind, provider]), | ||
| 292 | ); | ||
| 293 | const readiness = { ...defaultReadiness }; | ||
| 294 | |||
| 295 | for (const source of REMOTE_PROJECT_SOURCES) { | ||
| 296 | const kind = sourceProviderKind(source); | ||
| 297 | if (!kind) continue; | ||
| 298 | const provider = providerByKind.get(kind); | ||
| 299 | if (!provider) { | ||
| 300 | readiness[source] = unavailable; | ||
| 301 | continue; | ||
| 302 | } | ||
| 303 | if (provider.status !== "available") { | ||
| 304 | readiness[source] = { ready: false, hint: provider.installHint }; | ||
| 305 | continue; | ||
| 306 | } | ||
| 307 | if (provider.auth.status === "unauthenticated") { | ||
| 308 | readiness[source] = { | ||
| 309 | ready: false, | ||
| 310 | hint: | ||
| 311 | Option.getOrNull(provider.auth.detail) ?? | ||
| 312 | `${provider.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, | ||
| 313 | }; | ||
| 314 | continue; | ||
| 315 | } | ||
| 316 | readiness[source] = { ready: true, hint: null }; | ||
| 317 | } | ||
| 318 | |||
| 319 | return readiness; | ||
| 201 | } | 320 | } |
| 202 | 321 | ||
| 203 | function errorMessage(error: unknown): string { | 322 | function errorMessage(error: unknown): string { |
| ... | @@ -658,6 +777,145 @@ function OpenCommandPaletteDialog() { | ... | @@ -658,6 +777,145 @@ function OpenCommandPaletteDialog() { |
| 658 | [], | 777 | [], |
| 659 | ); | 778 | ); |
| 660 | 779 | ||
| 780 | const openSourceControlSettings = useCallback(() => { | ||
| 781 | setOpen(false); | ||
| 782 | void navigate({ to: "/settings/source-control" }); | ||
| 783 | }, [navigate, setOpen]); | ||
| 784 | |||
| 785 | const buildAddProjectSourceGroups = useCallback( | ||
| 786 | ( | ||
| 787 | environmentId: EnvironmentId, | ||
| 788 | readinessBySource: AddProjectRemoteSourceReadiness, | ||
| 789 | ): CommandPaletteView["groups"] => { | ||
| 790 | const sourceItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [ | ||
| 791 | { | ||
| 792 | kind: "action", | ||
| 793 | value: `action:add-project:${environmentId}:local`, | ||
| 794 | searchTerms: ["local", "folder", "directory", "browse"], | ||
| 795 | title: "Local folder", | ||
| 796 | description: "Browse a folder on disk", | ||
| 797 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | ||
| 798 | keepOpen: true, | ||
| 799 | run: async () => { | ||
| 800 | startAddProjectBrowse(environmentId); | ||
| 801 | }, | ||
| 802 | }, | ||
| 803 | ]; | ||
| 804 | |||
| 805 | const orderedSources: ReadonlyArray<AddProjectRemoteSource> = [ | ||
| 806 | "url", | ||
| 807 | ...sortAddProjectProviderSources(readinessBySource), | ||
| 808 | ]; | ||
| 809 | |||
| 810 | for (const source of orderedSources) { | ||
| 811 | const label = remoteProjectSourceLabel(source); | ||
| 812 | const title = source === "url" ? "Git URL" : `${label} repository`; | ||
| 813 | const description = | ||
| 814 | source === "url" | ||
| 815 | ? "Clone from a remote URL" | ||
| 816 | : `Clone ${label} ${remoteProjectSourcePathHint(source)}`; | ||
| 817 | const readiness = readinessBySource[source]; | ||
| 818 | const disabledHint = readiness.hint; | ||
| 819 | |||
| 820 | const titleTrailingContent = readiness.ready ? undefined : ( | ||
| 821 | <span className="ml-auto"> | ||
| 822 | <Tooltip> | ||
| 823 | <TooltipTrigger | ||
| 824 | render={ | ||
| 825 | <Button | ||
| 826 | variant="outline" | ||
| 827 | size="xs" | ||
| 828 | className="h-5 rounded-[.25rem] px-1.5 text-[10px] text-warning-foreground" | ||
| 829 | onClick={() => { | ||
| 830 | openSourceControlSettings(); | ||
| 831 | }} | ||
| 832 | > | ||
| 833 | Setup Required | ||
| 834 | </Button> | ||
| 835 | } | ||
| 836 | /> | ||
| 837 | <TooltipPopup align="end" side="left"> | ||
| 838 | {disabledHint ?? "Open Settings -> Source Control to configure this provider."} | ||
| 839 | </TooltipPopup> | ||
| 840 | </Tooltip> | ||
| 841 | </span> | ||
| 842 | ); | ||
| 843 | |||
| 844 | if (!readiness.ready) { | ||
| 845 | sourceItems.push({ | ||
| 846 | kind: "action", | ||
| 847 | value: `action:add-project:${environmentId}:${source}:not-ready`, | ||
| 848 | searchTerms: ["clone", "remote", "repository", "repo", "git", label, "setup required"], | ||
| 849 | title, | ||
| 850 | description, | ||
| 851 | disabled: true, | ||
| 852 | icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), | ||
| 853 | ...(titleTrailingContent ? { titleTrailingContent } : {}), | ||
| 854 | run: async () => {}, | ||
| 855 | }); | ||
| 856 | continue; | ||
| 857 | } | ||
| 858 | |||
| 859 | sourceItems.push({ | ||
| 860 | kind: "action", | ||
| 861 | value: `action:add-project:${environmentId}:${source}`, | ||
| 862 | searchTerms: ["clone", "remote", "repository", "repo", "git", label], | ||
| 863 | title, | ||
| 864 | description, | ||
| 865 | icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), | ||
| 866 | ...(titleTrailingContent ? { titleTrailingContent } : {}), | ||
| 867 | keepOpen: true, | ||
| 868 | run: async () => { | ||
| 869 | startAddProjectClone(environmentId, source); | ||
| 870 | }, | ||
| 871 | }); | ||
| 872 | } | ||
| 873 | |||
| 874 | return [{ value: `sources:${environmentId}`, label: "Sources", items: sourceItems }]; | ||
| 875 | }, | ||
| 876 | [openSourceControlSettings, startAddProjectBrowse, startAddProjectClone], | ||
| 877 | ); | ||
| 878 | |||
| 879 | const startAddProjectSourceSelection = useCallback( | ||
| 880 | (environmentId: EnvironmentId): void => { | ||
| 881 | setAddProjectEnvironmentId(environmentId); | ||
| 882 | setAddProjectCloneFlow(null); | ||
| 883 | const target = { environmentId }; | ||
| 884 | const initialDiscovery = getSourceControlDiscoverySnapshot(target).data; | ||
| 885 | pushPaletteView({ | ||
| 886 | addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, | ||
| 887 | groups: buildAddProjectSourceGroups( | ||
| 888 | environmentId, | ||
| 889 | buildAddProjectRemoteSourceReadiness(initialDiscovery), | ||
| 890 | ), | ||
| 891 | }); | ||
| 892 | |||
| 893 | if (initialDiscovery) { | ||
| 894 | return; | ||
| 895 | } | ||
| 896 | |||
| 897 | void refreshSourceControlDiscovery(target).then((discovery) => { | ||
| 898 | setViewStack((previousViews) => { | ||
| 899 | const currentTopView = previousViews.at(-1); | ||
| 900 | if (currentTopView?.groups[0]?.value !== `sources:${environmentId}`) { | ||
| 901 | return previousViews; | ||
| 902 | } | ||
| 903 | return [ | ||
| 904 | ...previousViews.slice(0, -1), | ||
| 905 | { | ||
| 906 | addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, | ||
| 907 | groups: buildAddProjectSourceGroups( | ||
| 908 | environmentId, | ||
| 909 | buildAddProjectRemoteSourceReadiness(discovery), | ||
| 910 | ), | ||
| 911 | }, | ||
| 912 | ]; | ||
| 913 | }); | ||
| 914 | }); | ||
| 915 | }, | ||
| 916 | [buildAddProjectSourceGroups], | ||
| 917 | ); | ||
| 918 | |||
| 661 | const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( | 919 | const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( |
| 662 | (option) => ({ | 920 | (option) => ({ |
| 663 | kind: "action", | 921 | kind: "action", |
| ... | @@ -668,7 +926,7 @@ function OpenCommandPaletteDialog() { | ... | @@ -668,7 +926,7 @@ function OpenCommandPaletteDialog() { |
| 668 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | 926 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, |
| 669 | keepOpen: true, | 927 | keepOpen: true, |
| 670 | run: async () => { | 928 | run: async () => { |
| 671 | startAddProjectBrowse(option.environmentId); | 929 | startAddProjectSourceSelection(option.environmentId); |
| 672 | }, | 930 | }, |
| 673 | }), | 931 | }), |
| 674 | ); | 932 | ); |
| ... | @@ -684,156 +942,6 @@ function OpenCommandPaletteDialog() { | ... | @@ -684,156 +942,6 @@ function OpenCommandPaletteDialog() { |
| 684 | [addProjectEnvironmentItems], | 942 | [addProjectEnvironmentItems], |
| 685 | ); | 943 | ); |
| 686 | 944 | ||
| 687 | const addProjectRemoteEnvironmentGroups = useMemo< | ||
| 688 | Record<AddProjectRemoteSource, CommandPaletteView["groups"]> | ||
| 689 | >(() => { | ||
| 690 | const buildGroups = (source: AddProjectRemoteSource): CommandPaletteView["groups"] => [ | ||
| 691 | { | ||
| 692 | value: `environments:${source}`, | ||
| 693 | label: "Environments", | ||
| 694 | items: addProjectEnvironmentOptions.map((option) => ({ | ||
| 695 | kind: "action" as const, | ||
| 696 | value: `action:add-project:${source}:environment:${option.environmentId}`, | ||
| 697 | searchTerms: [ | ||
| 698 | option.label, | ||
| 699 | option.environmentId, | ||
| 700 | option.isPrimary ? "this device" : "", | ||
| 701 | remoteProjectSourceLabel(source), | ||
| 702 | ], | ||
| 703 | title: option.label, | ||
| 704 | description: option.isPrimary ? "This device" : option.environmentId, | ||
| 705 | icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), | ||
| 706 | keepOpen: true, | ||
| 707 | run: async () => { | ||
| 708 | startAddProjectClone(option.environmentId, source); | ||
| 709 | }, | ||
| 710 | })), | ||
| 711 | }, | ||
| 712 | ]; | ||
| 713 | |||
| 714 | return { | ||
| 715 | github: buildGroups("github"), | ||
| 716 | gitlab: buildGroups("gitlab"), | ||
| 717 | url: buildGroups("url"), | ||
| 718 | }; | ||
| 719 | }, [addProjectEnvironmentOptions, startAddProjectClone]); | ||
| 720 | |||
| 721 | const openAddProjectCloneFlow = useCallback( | ||
| 722 | (source: AddProjectRemoteSource) => { | ||
| 723 | if (addProjectEnvironmentOptions.length > 1) { | ||
| 724 | pushPaletteView({ | ||
| 725 | addonIcon: remoteProjectSourceIcon(source, ADDON_ICON_CLASS), | ||
| 726 | groups: addProjectRemoteEnvironmentGroups[source], | ||
| 727 | }); | ||
| 728 | return; | ||
| 729 | } | ||
| 730 | |||
| 731 | const environmentId = defaultAddProjectEnvironmentId; | ||
| 732 | if (!environmentId) { | ||
| 733 | toastManager.add( | ||
| 734 | stackedThreadToast({ | ||
| 735 | type: "error", | ||
| 736 | title: "Unable to clone project", | ||
| 737 | description: "No environment is available.", | ||
| 738 | }), | ||
| 739 | ); | ||
| 740 | return; | ||
| 741 | } | ||
| 742 | |||
| 743 | startAddProjectClone(environmentId, source); | ||
| 744 | }, | ||
| 745 | [ | ||
| 746 | addProjectEnvironmentOptions.length, | ||
| 747 | addProjectRemoteEnvironmentGroups, | ||
| 748 | defaultAddProjectEnvironmentId, | ||
| 749 | startAddProjectClone, | ||
| 750 | ], | ||
| 751 | ); | ||
| 752 | |||
| 753 | const addProjectSourceGroups = useMemo<CommandPaletteView["groups"]>(() => { | ||
| 754 | const sourceItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = []; | ||
| 755 | |||
| 756 | if (addProjectEnvironmentOptions.length > 1) { | ||
| 757 | sourceItems.push({ | ||
| 758 | kind: "submenu", | ||
| 759 | value: "action:add-project:local", | ||
| 760 | searchTerms: ["local", "folder", "directory", "browse", "environment"], | ||
| 761 | title: "Local folder", | ||
| 762 | description: "Browse a folder on disk", | ||
| 763 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | ||
| 764 | addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, | ||
| 765 | groups: addProjectEnvironmentGroups, | ||
| 766 | }); | ||
| 767 | } else { | ||
| 768 | sourceItems.push({ | ||
| 769 | kind: "action", | ||
| 770 | value: "action:add-project:local", | ||
| 771 | searchTerms: ["local", "folder", "directory", "browse"], | ||
| 772 | title: "Local folder", | ||
| 773 | description: "Browse a folder on disk", | ||
| 774 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | ||
| 775 | keepOpen: true, | ||
| 776 | run: async () => { | ||
| 777 | const environmentId = defaultAddProjectEnvironmentId; | ||
| 778 | if (!environmentId) { | ||
| 779 | toastManager.add( | ||
| 780 | stackedThreadToast({ | ||
| 781 | type: "error", | ||
| 782 | title: "Unable to browse projects", | ||
| 783 | description: "No environment is available.", | ||
| 784 | }), | ||
| 785 | ); | ||
| 786 | return; | ||
| 787 | } | ||
| 788 | startAddProjectBrowse(environmentId); | ||
| 789 | }, | ||
| 790 | }); | ||
| 791 | } | ||
| 792 | |||
| 793 | for (const source of REMOTE_PROJECT_SOURCES) { | ||
| 794 | const label = remoteProjectSourceLabel(source); | ||
| 795 | const title = source === "url" ? "Git URL" : `${label} repository`; | ||
| 796 | const description = | ||
| 797 | source === "url" ? "Clone from a remote URL" : `Clone ${label} owner/repo`; | ||
| 798 | |||
| 799 | if (addProjectEnvironmentOptions.length > 1) { | ||
| 800 | sourceItems.push({ | ||
| 801 | kind: "submenu", | ||
| 802 | value: `action:add-project:${source}`, | ||
| 803 | searchTerms: ["clone", "remote", "repository", "repo", "git", label], | ||
| 804 | title, | ||
| 805 | description, | ||
| 806 | icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), | ||
| 807 | addonIcon: remoteProjectSourceIcon(source, ADDON_ICON_CLASS), | ||
| 808 | groups: addProjectRemoteEnvironmentGroups[source], | ||
| 809 | }); | ||
| 810 | continue; | ||
| 811 | } | ||
| 812 | |||
| 813 | sourceItems.push({ | ||
| 814 | kind: "action", | ||
| 815 | value: `action:add-project:${source}`, | ||
| 816 | searchTerms: ["clone", "remote", "repository", "repo", "git", label], | ||
| 817 | title, | ||
| 818 | description, | ||
| 819 | icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), | ||
| 820 | keepOpen: true, | ||
| 821 | run: async () => { | ||
| 822 | openAddProjectCloneFlow(source); | ||
| 823 | }, | ||
| 824 | }); | ||
| 825 | } | ||
| 826 | |||
| 827 | return [{ value: "sources", label: "Sources", items: sourceItems }]; | ||
| 828 | }, [ | ||
| 829 | addProjectEnvironmentGroups, | ||
| 830 | addProjectEnvironmentOptions.length, | ||
| 831 | addProjectRemoteEnvironmentGroups, | ||
| 832 | defaultAddProjectEnvironmentId, | ||
| 833 | openAddProjectCloneFlow, | ||
| 834 | startAddProjectBrowse, | ||
| 835 | ]); | ||
| 836 | |||
| 837 | const openAddProjectFlow = useCallback(() => { | 945 | const openAddProjectFlow = useCallback(() => { |
| 838 | if (addProjectEnvironmentOptions.length > 1) { | 946 | if (addProjectEnvironmentOptions.length > 1) { |
| 839 | pushPaletteView({ | 947 | pushPaletteView({ |
| ... | @@ -855,15 +963,15 @@ function OpenCommandPaletteDialog() { | ... | @@ -855,15 +963,15 @@ function OpenCommandPaletteDialog() { |
| 855 | return; | 963 | return; |
| 856 | } | 964 | } |
| 857 | 965 | ||
| 858 | startAddProjectBrowse(environmentId); | 966 | void startAddProjectSourceSelection(environmentId); |
| 859 | }, [ | 967 | }, [ |
| 860 | addProjectEnvironmentGroups, | 968 | addProjectEnvironmentGroups, |
| 861 | addProjectEnvironmentOptions.length, | 969 | addProjectEnvironmentOptions.length, |
| 862 | defaultAddProjectEnvironmentId, | 970 | defaultAddProjectEnvironmentId, |
| 863 | startAddProjectBrowse, | 971 | startAddProjectSourceSelection, |
| 864 | ]); | 972 | ]); |
| 865 | 973 | ||
| 866 | useEffect(() => { | 974 | useLayoutEffect(() => { |
| 867 | if (openIntent?.kind !== "add-project") { | 975 | if (openIntent?.kind !== "add-project") { |
| 868 | return; | 976 | return; |
| 869 | } | 977 | } |
| ... | @@ -914,7 +1022,7 @@ function OpenCommandPaletteDialog() { | ... | @@ -914,7 +1022,7 @@ function OpenCommandPaletteDialog() { |
| 914 | } | 1022 | } |
| 915 | 1023 | ||
| 916 | actionItems.push({ | 1024 | actionItems.push({ |
| 917 | kind: "submenu", | 1025 | kind: "action", |
| 918 | value: "action:add-project", | 1026 | value: "action:add-project", |
| 919 | searchTerms: [ | 1027 | searchTerms: [ |
| 920 | "add project", | 1028 | "add project", |
| ... | @@ -928,13 +1036,18 @@ function OpenCommandPaletteDialog() { | ... | @@ -928,13 +1036,18 @@ function OpenCommandPaletteDialog() { |
| 928 | "git", | 1036 | "git", |
| 929 | "github", | 1037 | "github", |
| 930 | "gitlab", | 1038 | "gitlab", |
| 1039 | "bitbucket", | ||
| 1040 | "azure", | ||
| 1041 | "devops", | ||
| 931 | "url", | 1042 | "url", |
| 932 | "environment", | 1043 | "environment", |
| 933 | ], | 1044 | ], |
| 934 | title: "Add project", | 1045 | title: "Add project", |
| 935 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | 1046 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, |
| 936 | addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, | 1047 | keepOpen: true, |
| 937 | groups: addProjectSourceGroups, | 1048 | run: async () => { |
| 1049 | openAddProjectFlow(); | ||
| 1050 | }, | ||
| 938 | }); | 1051 | }); |
| 939 | 1052 | ||
| 940 | actionItems.push({ | 1053 | actionItems.push({ |
| ... | @@ -1355,6 +1468,10 @@ function OpenCommandPaletteDialog() { | ... | @@ -1355,6 +1468,10 @@ function OpenCommandPaletteDialog() { |
| 1355 | } | 1468 | } |
| 1356 | 1469 | ||
| 1357 | function executeItem(item: CommandPaletteActionItem | CommandPaletteSubmenuItem): void { | 1470 | function executeItem(item: CommandPaletteActionItem | CommandPaletteSubmenuItem): void { |
| 1471 | if (item.disabled) { | ||
| 1472 | return; | ||
| 1473 | } | ||
| 1474 | |||
| 1358 | if (item.kind === "submenu") { | 1475 | if (item.kind === "submenu") { |
| 1359 | pushView(item); | 1476 | pushView(item); |
| 1360 | return; | 1477 | return; |
| ... | @@ -1471,7 +1588,7 @@ function OpenCommandPaletteDialog() { | ... | @@ -1471,7 +1588,7 @@ function OpenCommandPaletteDialog() { |
| 1471 | variant="outline" | 1588 | variant="outline" |
| 1472 | size="xs" | 1589 | size="xs" |
| 1473 | tabIndex={-1} | 1590 | tabIndex={-1} |
| 1474 | className="absolute end-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2" | 1591 | className="absolute inset-e-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2" |
| 1475 | aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`} | 1592 | aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`} |
| 1476 | disabled={!canSubmitRemoteProjectFlow} | 1593 | disabled={!canSubmitRemoteProjectFlow} |
| 1477 | onMouseDown={(event) => { | 1594 | onMouseDown={(event) => { |
| ... | @@ -1493,7 +1610,7 @@ function OpenCommandPaletteDialog() { | ... | @@ -1493,7 +1610,7 @@ function OpenCommandPaletteDialog() { |
| 1493 | size="xs" | 1610 | size="xs" |
| 1494 | tabIndex={-1} | 1611 | tabIndex={-1} |
| 1495 | className={cn( | 1612 | className={cn( |
| 1496 | "absolute end-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2", | 1613 | "absolute inset-e-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2", |
| 1497 | hasHighlightedBrowseItem ? "gap-1" : "gap-1.5", | 1614 | hasHighlightedBrowseItem ? "gap-1" : "gap-1.5", |
| 1498 | )} | 1615 | )} |
| 1499 | aria-label={`${submitActionLabel} (${addShortcutLabel})`} | 1616 | aria-label={`${submitActionLabel} (${addShortcutLabel})`} |
apps/web/src/components/CommandPaletteResults.tsx+41-11| ... | @@ -43,15 +43,19 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { | ... | @@ -43,15 +43,19 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { |
| 43 | <CommandGroup items={group.items} key={group.value}> | 43 | <CommandGroup items={group.items} key={group.value}> |
| 44 | <CommandGroupLabel>{group.label}</CommandGroupLabel> | 44 | <CommandGroupLabel>{group.label}</CommandGroupLabel> |
| 45 | <CommandCollection> | 45 | <CommandCollection> |
| 46 | {(item) => ( | 46 | {(item) => |
| 47 | <CommandPaletteResultRow | 47 | item.disabled ? ( |
| 48 | item={item} | 48 | <DisabledCommandPaletteResultRow item={item} key={item.value} /> |
| 49 | key={item.value} | 49 | ) : ( |
| 50 | keybindings={props.keybindings} | 50 | <CommandPaletteResultRow |
| 51 | isActive={props.highlightedItemValue === item.value} | 51 | item={item} |
| 52 | onExecuteItem={props.onExecuteItem} | 52 | key={item.value} |
| 53 | /> | 53 | keybindings={props.keybindings} |
| 54 | )} | 54 | isActive={props.highlightedItemValue === item.value} |
| 55 | onExecuteItem={props.onExecuteItem} | ||
| 56 | /> | ||
| 57 | ) | ||
| 58 | } | ||
| 55 | </CommandCollection> | 59 | </CommandCollection> |
| 56 | </CommandGroup> | 60 | </CommandGroup> |
| 57 | ))} | 61 | ))} |
| ... | @@ -59,6 +63,33 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { | ... | @@ -59,6 +63,33 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { |
| 59 | ); | 63 | ); |
| 60 | } | 64 | } |
| 61 | 65 | ||
| 66 | function DisabledCommandPaletteResultRow(props: { | ||
| 67 | item: CommandPaletteActionItem | CommandPaletteSubmenuItem; | ||
| 68 | }) { | ||
| 69 | return ( | ||
| 70 | <div className="flex min-h-8 select-none items-center gap-2 rounded-sm px-2 py-1.5 text-base opacity-64 sm:min-h-7 sm:text-sm"> | ||
| 71 | {props.item.icon} | ||
| 72 | {props.item.description ? ( | ||
| 73 | <span className="flex min-w-0 flex-1 flex-col"> | ||
| 74 | <span className="flex min-w-0 items-center gap-1.5 text-sm text-foreground"> | ||
| 75 | {props.item.titleLeadingContent} | ||
| 76 | <span className="truncate">{props.item.title}</span> | ||
| 77 | </span> | ||
| 78 | <span className="truncate text-muted-foreground/70 text-xs"> | ||
| 79 | {props.item.description} | ||
| 80 | </span> | ||
| 81 | </span> | ||
| 82 | ) : ( | ||
| 83 | <span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-foreground"> | ||
| 84 | {props.item.titleLeadingContent} | ||
| 85 | <span className="truncate">{props.item.title}</span> | ||
| 86 | </span> | ||
| 87 | )} | ||
| 88 | {props.item.titleTrailingContent} | ||
| 89 | </div> | ||
| 90 | ); | ||
| 91 | } | ||
| 92 | |||
| 62 | function CommandPaletteResultRow(props: { | 93 | function CommandPaletteResultRow(props: { |
| 63 | item: CommandPaletteActionItem | CommandPaletteSubmenuItem; | 94 | item: CommandPaletteActionItem | CommandPaletteSubmenuItem; |
| 64 | isActive: boolean; | 95 | isActive: boolean; |
| ... | @@ -89,7 +120,6 @@ function CommandPaletteResultRow(props: { | ... | @@ -89,7 +120,6 @@ function CommandPaletteResultRow(props: { |
| 89 | <span className="flex min-w-0 items-center gap-1.5 text-sm text-foreground"> | 120 | <span className="flex min-w-0 items-center gap-1.5 text-sm text-foreground"> |
| 90 | {props.item.titleLeadingContent} | 121 | {props.item.titleLeadingContent} |
| 91 | <span className="truncate">{props.item.title}</span> | 122 | <span className="truncate">{props.item.title}</span> |
| 92 | {props.item.titleTrailingContent} | ||
| 93 | </span> | 123 | </span> |
| 94 | <span className="truncate text-muted-foreground/70 text-xs"> | 124 | <span className="truncate text-muted-foreground/70 text-xs"> |
| 95 | {props.item.description} | 125 | {props.item.description} |
| ... | @@ -99,9 +129,9 @@ function CommandPaletteResultRow(props: { | ... | @@ -99,9 +129,9 @@ function CommandPaletteResultRow(props: { |
| 99 | <span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-foreground"> | 129 | <span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-foreground"> |
| 100 | {props.item.titleLeadingContent} | 130 | {props.item.titleLeadingContent} |
| 101 | <span className="truncate">{props.item.title}</span> | 131 | <span className="truncate">{props.item.title}</span> |
| 102 | {props.item.titleTrailingContent} | ||
| 103 | </span> | 132 | </span> |
| 104 | )} | 133 | )} |
| 134 | {props.item.titleTrailingContent} | ||
| 105 | {props.item.timestamp ? ( | 135 | {props.item.timestamp ? ( |
| 106 | <span className="min-w-12 shrink-0 text-right text-[10px] tabular-nums text-muted-foreground/70"> | 136 | <span className="min-w-12 shrink-0 text-right text-[10px] tabular-nums text-muted-foreground/70"> |
| 107 | {props.item.timestamp} | 137 | {props.item.timestamp} |
apps/web/src/components/GitActionsControl.tsx+705-502| ... | @@ -4,14 +4,17 @@ import type { | ... | @@ -4,14 +4,17 @@ import type { |
| 4 | GitRunStackedActionResult, | 4 | GitRunStackedActionResult, |
| 5 | GitStackedAction, | 5 | GitStackedAction, |
| 6 | SourceControlCloneProtocol, | 6 | SourceControlCloneProtocol, |
| 7 | SourceControlProviderDiscoveryItem, | ||
| 7 | SourceControlProviderKind, | 8 | SourceControlProviderKind, |
| 8 | SourceControlPublishRepositoryResult, | 9 | SourceControlPublishRepositoryResult, |
| 9 | SourceControlRepositoryVisibility, | 10 | SourceControlRepositoryVisibility, |
| 10 | VcsStatusResult, | 11 | VcsStatusResult, |
| 11 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 12 | import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; | 13 | import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; |
| 14 | import { useNavigate } from "@tanstack/react-router"; | ||
| 13 | import { Option } from "effect"; | 15 | import { Option } from "effect"; |
| 14 | import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; | 16 | import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; |
| 17 | import { flushSync } from "react-dom"; | ||
| 15 | import { | 18 | import { |
| 16 | CheckIcon, | 19 | CheckIcon, |
| 17 | ChevronDownIcon, | 20 | ChevronDownIcon, |
| ... | @@ -23,7 +26,7 @@ import { | ... | @@ -23,7 +26,7 @@ import { |
| 23 | GlobeIcon, | 26 | GlobeIcon, |
| 24 | } from "lucide-react"; | 27 | } from "lucide-react"; |
| 25 | import { Radio as RadioPrimitive } from "@base-ui/react/radio"; | 28 | import { Radio as RadioPrimitive } from "@base-ui/react/radio"; |
| 26 | import { GitHubIcon, GitLabIcon } from "~/components/Icons"; | 29 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "~/components/Icons"; |
| 27 | import { RadioGroup } from "~/components/ui/radio-group"; | 30 | import { RadioGroup } from "~/components/ui/radio-group"; |
| 28 | import { Spinner } from "~/components/ui/spinner"; | 31 | import { Spinner } from "~/components/ui/spinner"; |
| 29 | import { cn } from "~/lib/utils"; | 32 | import { cn } from "~/lib/utils"; |
| ... | @@ -40,6 +43,7 @@ import { | ... | @@ -40,6 +43,7 @@ import { |
| 40 | resolveQuickAction, | 43 | resolveQuickAction, |
| 41 | resolveThreadBranchUpdate, | 44 | resolveThreadBranchUpdate, |
| 42 | } from "./GitActionsControl.logic"; | 45 | } from "./GitActionsControl.logic"; |
| 46 | import { AnimatedHeight } from "./AnimatedHeight"; | ||
| 43 | import { Button } from "~/components/ui/button"; | 47 | import { Button } from "~/components/ui/button"; |
| 44 | import { Checkbox } from "~/components/ui/checkbox"; | 48 | import { Checkbox } from "~/components/ui/checkbox"; |
| 45 | import { | 49 | import { |
| ... | @@ -56,16 +60,9 @@ import { Input } from "~/components/ui/input"; | ... | @@ -56,16 +60,9 @@ import { Input } from "~/components/ui/input"; |
| 56 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; | 60 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; |
| 57 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; | 61 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; |
| 58 | import { ScrollArea } from "~/components/ui/scroll-area"; | 62 | import { ScrollArea } from "~/components/ui/scroll-area"; |
| 59 | import { | ||
| 60 | Select, | ||
| 61 | SelectGroup, | ||
| 62 | SelectItem, | ||
| 63 | SelectPopup, | ||
| 64 | SelectTrigger, | ||
| 65 | SelectValue, | ||
| 66 | } from "~/components/ui/select"; | ||
| 67 | import { Textarea } from "~/components/ui/textarea"; | 63 | import { Textarea } from "~/components/ui/textarea"; |
| 68 | import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast"; | 64 | import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast"; |
| 65 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | ||
| 69 | import { openInPreferredEditor } from "~/editorPreferences"; | 66 | import { openInPreferredEditor } from "~/editorPreferences"; |
| 70 | import { | 67 | import { |
| 71 | gitInitMutationOptions, | 68 | gitInitMutationOptions, |
| ... | @@ -100,7 +97,10 @@ interface PendingDefaultBranchAction { | ... | @@ -100,7 +97,10 @@ interface PendingDefaultBranchAction { |
| 100 | filePaths?: string[]; | 97 | filePaths?: string[]; |
| 101 | } | 98 | } |
| 102 | 99 | ||
| 103 | type PublishProviderKind = Extract<SourceControlProviderKind, "github" | "gitlab">; | 100 | type PublishProviderKind = Extract< |
| 101 | SourceControlProviderKind, | ||
| 102 | "github" | "gitlab" | "bitbucket" | "azure-devops" | ||
| 103 | >; | ||
| 104 | 104 | ||
| 105 | type GitActionToastId = ReturnType<typeof toastManager.add>; | 105 | type GitActionToastId = ReturnType<typeof toastManager.add>; |
| 106 | 106 | ||
| ... | @@ -129,6 +129,88 @@ interface RunGitActionWithToastInput { | ... | @@ -129,6 +129,88 @@ interface RunGitActionWithToastInput { |
| 129 | 129 | ||
| 130 | const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; | 130 | const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; |
| 131 | 131 | ||
| 132 | const PUBLISH_PROVIDER_OPTIONS = [ | ||
| 133 | { | ||
| 134 | value: "github", | ||
| 135 | label: "GitHub", | ||
| 136 | description: "github.com", | ||
| 137 | host: "github.com", | ||
| 138 | pathPlaceholder: "owner/repo", | ||
| 139 | Icon: GitHubIcon, | ||
| 140 | }, | ||
| 141 | { | ||
| 142 | value: "gitlab", | ||
| 143 | label: "GitLab", | ||
| 144 | description: "gitlab.com", | ||
| 145 | host: "gitlab.com", | ||
| 146 | pathPlaceholder: "group/project", | ||
| 147 | Icon: GitLabIcon, | ||
| 148 | }, | ||
| 149 | { | ||
| 150 | value: "bitbucket", | ||
| 151 | label: "Bitbucket", | ||
| 152 | description: "bitbucket.org", | ||
| 153 | host: "bitbucket.org", | ||
| 154 | pathPlaceholder: "workspace/repository", | ||
| 155 | Icon: BitbucketIcon, | ||
| 156 | }, | ||
| 157 | { | ||
| 158 | value: "azure-devops", | ||
| 159 | label: "Azure DevOps", | ||
| 160 | description: "dev.azure.com", | ||
| 161 | host: "dev.azure.com", | ||
| 162 | pathPlaceholder: "project/repository", | ||
| 163 | Icon: AzureDevOpsIcon, | ||
| 164 | }, | ||
| 165 | ] as const satisfies ReadonlyArray<{ | ||
| 166 | readonly value: PublishProviderKind; | ||
| 167 | readonly label: string; | ||
| 168 | readonly description: string; | ||
| 169 | readonly host: string; | ||
| 170 | readonly pathPlaceholder: string; | ||
| 171 | readonly Icon: typeof GitHubIcon; | ||
| 172 | }>; | ||
| 173 | |||
| 174 | function publishProviderOption(provider: PublishProviderKind) { | ||
| 175 | return ( | ||
| 176 | PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ?? | ||
| 177 | PUBLISH_PROVIDER_OPTIONS[0] | ||
| 178 | ); | ||
| 179 | } | ||
| 180 | |||
| 181 | function isPublishProviderKind( | ||
| 182 | provider: SourceControlProviderKind, | ||
| 183 | ): provider is PublishProviderKind { | ||
| 184 | return PUBLISH_PROVIDER_OPTIONS.some((option) => option.value === provider); | ||
| 185 | } | ||
| 186 | |||
| 187 | function getPublishProviderReadiness(input: { | ||
| 188 | provider: PublishProviderKind; | ||
| 189 | sourceControlProviders: ReadonlyArray<SourceControlProviderDiscoveryItem>; | ||
| 190 | }): { readonly ready: boolean; readonly hint: string | null } { | ||
| 191 | const discovered = input.sourceControlProviders.find( | ||
| 192 | (provider) => provider.kind === input.provider, | ||
| 193 | ); | ||
| 194 | if (!discovered) { | ||
| 195 | return { | ||
| 196 | ready: false, | ||
| 197 | hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", | ||
| 198 | }; | ||
| 199 | } | ||
| 200 | if (discovered.status !== "available") { | ||
| 201 | return { ready: false, hint: discovered.installHint }; | ||
| 202 | } | ||
| 203 | if (discovered.auth.status === "unauthenticated") { | ||
| 204 | return { | ||
| 205 | ready: false, | ||
| 206 | hint: | ||
| 207 | Option.getOrNull(discovered.auth.detail) ?? | ||
| 208 | `${discovered.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, | ||
| 209 | }; | ||
| 210 | } | ||
| 211 | return { ready: true, hint: null }; | ||
| 212 | } | ||
| 213 | |||
| 132 | function formatElapsedDescription(startedAtMs: number | null): string | undefined { | 214 | function formatElapsedDescription(startedAtMs: number | null): string | undefined { |
| 133 | if (startedAtMs === null) { | 215 | if (startedAtMs === null) { |
| 134 | return undefined; | 216 | return undefined; |
| ... | @@ -256,6 +338,613 @@ function GitQuickActionIcon({ | ... | @@ -256,6 +338,613 @@ function GitQuickActionIcon({ |
| 256 | return <InfoIcon className={iconClassName} />; | 338 | return <InfoIcon className={iconClassName} />; |
| 257 | } | 339 | } |
| 258 | 340 | ||
| 341 | interface PublishRepositoryDialogProps { | ||
| 342 | readonly open: boolean; | ||
| 343 | readonly onOpenChange: (open: boolean) => void; | ||
| 344 | readonly environmentId: ScopedThreadRef["environmentId"] | null; | ||
| 345 | readonly gitCwd: string; | ||
| 346 | } | ||
| 347 | |||
| 348 | function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ||
| 349 | const queryClient = useQueryClient(); | ||
| 350 | const navigate = useNavigate(); | ||
| 351 | const sourceControlDiscovery = useSourceControlDiscovery(); | ||
| 352 | const [publishProvider, setPublishProvider] = useState<PublishProviderKind>("github"); | ||
| 353 | const [publishRepository, setPublishRepository] = useState(""); | ||
| 354 | const [publishVisibility, setPublishVisibility] = | ||
| 355 | useState<SourceControlRepositoryVisibility>("private"); | ||
| 356 | const [publishRemoteName, setPublishRemoteName] = useState("origin"); | ||
| 357 | const [publishProtocol, setPublishProtocol] = useState<SourceControlCloneProtocol>("ssh"); | ||
| 358 | const [publishWizardStep, setPublishWizardStep] = useState(0); | ||
| 359 | const [publishAdvancedOpen, setPublishAdvancedOpen] = useState(false); | ||
| 360 | const [publishError, setPublishError] = useState<string | null>(null); | ||
| 361 | const [publishResult, setPublishResult] = useState<SourceControlPublishRepositoryResult | null>( | ||
| 362 | null, | ||
| 363 | ); | ||
| 364 | const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false); | ||
| 365 | const publishRepositoryMutation = useMutation( | ||
| 366 | sourceControlPublishRepositoryMutationOptions({ | ||
| 367 | environmentId: props.environmentId, | ||
| 368 | cwd: props.gitCwd, | ||
| 369 | queryClient, | ||
| 370 | }), | ||
| 371 | ); | ||
| 372 | const publishAccountByProvider = useMemo(() => { | ||
| 373 | const accounts: Record<PublishProviderKind, string | null> = { | ||
| 374 | github: null, | ||
| 375 | gitlab: null, | ||
| 376 | bitbucket: null, | ||
| 377 | "azure-devops": null, | ||
| 378 | }; | ||
| 379 | for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { | ||
| 380 | if (isPublishProviderKind(provider.kind)) { | ||
| 381 | accounts[provider.kind] = Option.getOrNull(provider.auth.account); | ||
| 382 | } | ||
| 383 | } | ||
| 384 | return accounts; | ||
| 385 | }, [sourceControlDiscovery.data]); | ||
| 386 | const publishProviderReadiness = useMemo(() => { | ||
| 387 | const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? []; | ||
| 388 | return Object.fromEntries( | ||
| 389 | PUBLISH_PROVIDER_OPTIONS.map((option) => [ | ||
| 390 | option.value, | ||
| 391 | getPublishProviderReadiness({ | ||
| 392 | provider: option.value, | ||
| 393 | sourceControlProviders, | ||
| 394 | }), | ||
| 395 | ]), | ||
| 396 | ) as Record<PublishProviderKind, { readonly ready: boolean; readonly hint: string | null }>; | ||
| 397 | }, [sourceControlDiscovery.data]); | ||
| 398 | const hasReadyPublishProvider = useMemo( | ||
| 399 | () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness[option.value].ready), | ||
| 400 | [publishProviderReadiness], | ||
| 401 | ); | ||
| 402 | const sortedPublishProviderOptions = useMemo( | ||
| 403 | () => | ||
| 404 | PUBLISH_PROVIDER_OPTIONS.toSorted((left, right) => { | ||
| 405 | const leftReady = publishProviderReadiness[left.value].ready; | ||
| 406 | const rightReady = publishProviderReadiness[right.value].ready; | ||
| 407 | if (leftReady !== rightReady) { | ||
| 408 | return leftReady ? -1 : 1; | ||
| 409 | } | ||
| 410 | return left.label.localeCompare(right.label); | ||
| 411 | }), | ||
| 412 | [publishProviderReadiness], | ||
| 413 | ); | ||
| 414 | const selectedPublishProviderReadiness = publishProviderReadiness[publishProvider]; | ||
| 415 | const publishRepositoryPrefill = publishAccountByProvider[publishProvider] | ||
| 416 | ? `${publishAccountByProvider[publishProvider]}/` | ||
| 417 | : ""; | ||
| 418 | const currentPublishProvider = publishProviderOption(publishProvider); | ||
| 419 | const publishHost = currentPublishProvider.host; | ||
| 420 | const publishPathPlaceholder = currentPublishProvider.pathPlaceholder; | ||
| 421 | const publishProviderLabel = currentPublishProvider.label; | ||
| 422 | const publishWizardSteps = ["Provider", "Repository", "Summary"] as const; | ||
| 423 | const publishWizardStepSummaries = [ | ||
| 424 | publishProviderLabel, | ||
| 425 | publishResult?.repository.nameWithOwner ?? null, | ||
| 426 | null, | ||
| 427 | ] as const; | ||
| 428 | |||
| 429 | useEffect(() => { | ||
| 430 | if (!props.open || hasUserEditedPublishRepository) { | ||
| 431 | return; | ||
| 432 | } | ||
| 433 | setPublishRepository(publishRepositoryPrefill); | ||
| 434 | }, [hasUserEditedPublishRepository, props.open, publishRepositoryPrefill]); | ||
| 435 | |||
| 436 | const canSubmitPublishRepository = useMemo(() => { | ||
| 437 | if (!selectedPublishProviderReadiness.ready) return false; | ||
| 438 | if (publishRepositoryMutation.isPending) return false; | ||
| 439 | const repositoryParts = publishRepository.trim().split("/"); | ||
| 440 | const owner = repositoryParts[0]?.trim() ?? ""; | ||
| 441 | const rest = repositoryParts.slice(1); | ||
| 442 | const name = rest.join("/").trim(); | ||
| 443 | return owner.length > 0 && name.length > 0; | ||
| 444 | }, [publishRepository, publishRepositoryMutation.isPending, selectedPublishProviderReadiness]); | ||
| 445 | |||
| 446 | useEffect(() => { | ||
| 447 | if (!props.open) { | ||
| 448 | return; | ||
| 449 | } | ||
| 450 | if (publishProviderReadiness[publishProvider].ready) { | ||
| 451 | return; | ||
| 452 | } | ||
| 453 | const firstReadyProvider = PUBLISH_PROVIDER_OPTIONS.find( | ||
| 454 | (option) => publishProviderReadiness[option.value].ready, | ||
| 455 | ); | ||
| 456 | if (firstReadyProvider) { | ||
| 457 | setPublishProvider(firstReadyProvider.value); | ||
| 458 | } | ||
| 459 | }, [props.open, publishProvider, publishProviderReadiness]); | ||
| 460 | |||
| 461 | const submitPublishRepository = useCallback(() => { | ||
| 462 | if (!canSubmitPublishRepository) { | ||
| 463 | return; | ||
| 464 | } | ||
| 465 | |||
| 466 | setPublishError(null); | ||
| 467 | |||
| 468 | void publishRepositoryMutation | ||
| 469 | .mutateAsync({ | ||
| 470 | provider: publishProvider, | ||
| 471 | repository: publishRepository.trim(), | ||
| 472 | visibility: publishVisibility, | ||
| 473 | remoteName: publishRemoteName.trim() || "origin", | ||
| 474 | protocol: publishProtocol, | ||
| 475 | }) | ||
| 476 | .then((result) => { | ||
| 477 | flushSync(() => { | ||
| 478 | setPublishResult(result); | ||
| 479 | setPublishWizardStep(2); | ||
| 480 | }); | ||
| 481 | void refreshGitStatus({ environmentId: props.environmentId, cwd: props.gitCwd }).catch( | ||
| 482 | () => undefined, | ||
| 483 | ); | ||
| 484 | }) | ||
| 485 | .catch((err: unknown) => { | ||
| 486 | setPublishError(err instanceof Error ? err.message : "An error occurred."); | ||
| 487 | }); | ||
| 488 | }, [ | ||
| 489 | canSubmitPublishRepository, | ||
| 490 | props.environmentId, | ||
| 491 | props.gitCwd, | ||
| 492 | publishProtocol, | ||
| 493 | publishProvider, | ||
| 494 | publishRemoteName, | ||
| 495 | publishRepository, | ||
| 496 | publishRepositoryMutation, | ||
| 497 | publishVisibility, | ||
| 498 | ]); | ||
| 499 | |||
| 500 | const resetState = useCallback(() => { | ||
| 501 | setPublishRemoteName("origin"); | ||
| 502 | setPublishRepository(""); | ||
| 503 | setHasUserEditedPublishRepository(false); | ||
| 504 | setPublishWizardStep(0); | ||
| 505 | setPublishAdvancedOpen(false); | ||
| 506 | setPublishError(null); | ||
| 507 | setPublishResult(null); | ||
| 508 | }, []); | ||
| 509 | |||
| 510 | const handleOpenChange = useCallback( | ||
| 511 | (open: boolean) => { | ||
| 512 | props.onOpenChange(open); | ||
| 513 | if (!open) { | ||
| 514 | resetState(); | ||
| 515 | } | ||
| 516 | }, | ||
| 517 | [props, resetState], | ||
| 518 | ); | ||
| 519 | |||
| 520 | const openSourceControlSettings = useCallback(() => { | ||
| 521 | handleOpenChange(false); | ||
| 522 | void navigate({ to: "/settings/source-control" }); | ||
| 523 | }, [handleOpenChange, navigate]); | ||
| 524 | |||
| 525 | return ( | ||
| 526 | <Dialog open={props.open} onOpenChange={handleOpenChange}> | ||
| 527 | <DialogPopup className="max-w-xl overflow-hidden"> | ||
| 528 | <div className="flex min-h-0 flex-col overflow-hidden border-foreground/10 bg-background shadow-2xl"> | ||
| 529 | <DialogHeader className="border-b border-border/70 bg-background"> | ||
| 530 | <DialogTitle>Publish repository</DialogTitle> | ||
| 531 | <DialogDescription> | ||
| 532 | Pick where to host it, then point us at a repo to push to. | ||
| 533 | </DialogDescription> | ||
| 534 | <div className="grid grid-cols-3 gap-2"> | ||
| 535 | {publishWizardSteps.map((label, index) => { | ||
| 536 | const isComplete = index < publishWizardStep; | ||
| 537 | const isClickable = | ||
| 538 | publishWizardStep !== 2 && | ||
| 539 | index < publishWizardSteps.length - 1 && | ||
| 540 | index <= publishWizardStep; | ||
| 541 | return ( | ||
| 542 | <button | ||
| 543 | key={label} | ||
| 544 | type="button" | ||
| 545 | onClick={isClickable ? () => setPublishWizardStep(index) : undefined} | ||
| 546 | disabled={!isClickable} | ||
| 547 | className={cn( | ||
| 548 | "grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 rounded-lg border px-3 py-2 text-left", | ||
| 549 | index === publishWizardStep | ||
| 550 | ? "border-primary bg-primary/10 ring-1 ring-primary/25" | ||
| 551 | : isComplete | ||
| 552 | ? "border-border bg-background" | ||
| 553 | : "border-border bg-muted/40", | ||
| 554 | !isClickable && "cursor-default", | ||
| 555 | )} | ||
| 556 | > | ||
| 557 | <span | ||
| 558 | aria-hidden | ||
| 559 | className={cn( | ||
| 560 | "row-span-2 mt-0.5 grid size-4 place-items-center rounded-full border", | ||
| 561 | isComplete | ||
| 562 | ? "border-primary bg-primary text-primary-foreground" | ||
| 563 | : index === publishWizardStep | ||
| 564 | ? "border-primary bg-background" | ||
| 565 | : "border-muted-foreground/35 bg-background", | ||
| 566 | )} | ||
| 567 | > | ||
| 568 | {isComplete ? <CheckIcon className="size-3" /> : null} | ||
| 569 | </span> | ||
| 570 | <span className="text-[10px] font-medium uppercase text-muted-foreground"> | ||
| 571 | Step {index + 1} | ||
| 572 | </span> | ||
| 573 | <span className="truncate text-xs font-semibold text-foreground"> | ||
| 574 | {label} | ||
| 575 | {isComplete && publishWizardStepSummaries[index] | ||
| 576 | ? `: ${publishWizardStepSummaries[index]}` | ||
| 577 | : ""} | ||
| 578 | </span> | ||
| 579 | </button> | ||
| 580 | ); | ||
| 581 | })} | ||
| 582 | </div> | ||
| 583 | </DialogHeader> | ||
| 584 | |||
| 585 | <DialogPanel className="space-y-5 border-b border-border/70 bg-muted/20 px-6 py-5"> | ||
| 586 | <AnimatedHeight> | ||
| 587 | <div className={cn("space-y-2", publishWizardStep !== 0 && "hidden")}> | ||
| 588 | <span | ||
| 589 | id="publish-provider-cards-label" | ||
| 590 | className="text-xs font-medium text-foreground" | ||
| 591 | > | ||
| 592 | Provider | ||
| 593 | </span> | ||
| 594 | <RadioGroup | ||
| 595 | value={publishProvider} | ||
| 596 | onValueChange={(value) => setPublishProvider(value as PublishProviderKind)} | ||
| 597 | aria-labelledby="publish-provider-cards-label" | ||
| 598 | className="grid grid-cols-2 gap-2.5" | ||
| 599 | > | ||
| 600 | {sortedPublishProviderOptions.map((option) => { | ||
| 601 | const readiness = publishProviderReadiness[option.value]; | ||
| 602 | const isSelected = publishProvider === option.value && readiness.ready; | ||
| 603 | if (!readiness.ready) { | ||
| 604 | return ( | ||
| 605 | <div | ||
| 606 | key={option.value} | ||
| 607 | className="relative flex cursor-not-allowed items-center gap-3 rounded-lg border border-border bg-background px-3 py-3 text-left opacity-55" | ||
| 608 | > | ||
| 609 | <option.Icon | ||
| 610 | className="size-5 shrink-0 text-muted-foreground" | ||
| 611 | aria-hidden | ||
| 612 | /> | ||
| 613 | <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"> | ||
| 614 | {option.label} | ||
| 615 | </span> | ||
| 616 | <Tooltip> | ||
| 617 | <TooltipTrigger | ||
| 618 | render={ | ||
| 619 | <Button | ||
| 620 | variant="outline" | ||
| 621 | size="xs" | ||
| 622 | className="h-5 rounded-[.25rem] px-1.5 text-[10px] text-warning-foreground" | ||
| 623 | onClick={(event) => { | ||
| 624 | event.preventDefault(); | ||
| 625 | event.stopPropagation(); | ||
| 626 | openSourceControlSettings(); | ||
| 627 | }} | ||
| 628 | > | ||
| 629 | Setup Required | ||
| 630 | </Button> | ||
| 631 | } | ||
| 632 | /> | ||
| 633 | <TooltipPopup side="top" align="end" className="max-w-72"> | ||
| 634 | {readiness.hint ?? | ||
| 635 | "Open Settings -> Source Control to configure this provider."} | ||
| 636 | </TooltipPopup> | ||
| 637 | </Tooltip> | ||
| 638 | </div> | ||
| 639 | ); | ||
| 640 | } | ||
| 641 | |||
| 642 | return ( | ||
| 643 | <RadioPrimitive.Root | ||
| 644 | key={option.value} | ||
| 645 | value={option.value} | ||
| 646 | className={cn( | ||
| 647 | "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-3 text-left outline-none transition-[background-color,border-color,box-shadow]", | ||
| 648 | "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | ||
| 649 | isSelected | ||
| 650 | ? "border-primary bg-background shadow-sm ring-2 ring-primary/35" | ||
| 651 | : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50", | ||
| 652 | )} | ||
| 653 | > | ||
| 654 | <option.Icon className="size-5 shrink-0" aria-hidden /> | ||
| 655 | <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"> | ||
| 656 | {option.label} | ||
| 657 | </span> | ||
| 658 | </RadioPrimitive.Root> | ||
| 659 | ); | ||
| 660 | })} | ||
| 661 | </RadioGroup> | ||
| 662 | </div> | ||
| 663 | |||
| 664 | <div className={cn("space-y-5", publishWizardStep !== 1 && "hidden")}> | ||
| 665 | <div className="space-y-2"> | ||
| 666 | <label | ||
| 667 | htmlFor="publish-repository-path" | ||
| 668 | className="text-xs font-medium text-foreground" | ||
| 669 | > | ||
| 670 | Repository | ||
| 671 | </label> | ||
| 672 | <div className="flex items-stretch overflow-hidden rounded-md border border-input bg-background focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-ring"> | ||
| 673 | <span className="flex shrink-0 items-center gap-1.5 border-r border-input bg-muted/50 px-2.5 font-mono text-xs text-muted-foreground"> | ||
| 674 | <currentPublishProvider.Icon className="size-3.5" /> | ||
| 675 | {publishHost}/ | ||
| 676 | </span> | ||
| 677 | <input | ||
| 678 | id="publish-repository-path" | ||
| 679 | name="publish-repository-path" | ||
| 680 | value={publishRepository} | ||
| 681 | onChange={(event) => { | ||
| 682 | setPublishRepository(event.target.value); | ||
| 683 | setHasUserEditedPublishRepository(true); | ||
| 684 | }} | ||
| 685 | onKeyDown={(event) => { | ||
| 686 | if (event.key === "Enter") { | ||
| 687 | event.preventDefault(); | ||
| 688 | submitPublishRepository(); | ||
| 689 | } | ||
| 690 | }} | ||
| 691 | placeholder={publishPathPlaceholder} | ||
| 692 | disabled={publishRepositoryMutation.isPending} | ||
| 693 | className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" | ||
| 694 | /> | ||
| 695 | </div> | ||
| 696 | </div> | ||
| 697 | |||
| 698 | <div className="space-y-2"> | ||
| 699 | <span | ||
| 700 | id="publish-visibility-cards-label" | ||
| 701 | className="text-xs font-medium text-foreground" | ||
| 702 | > | ||
| 703 | Visibility | ||
| 704 | </span> | ||
| 705 | <RadioGroup | ||
| 706 | value={publishVisibility} | ||
| 707 | onValueChange={(value) => | ||
| 708 | setPublishVisibility(value as SourceControlRepositoryVisibility) | ||
| 709 | } | ||
| 710 | aria-labelledby="publish-visibility-cards-label" | ||
| 711 | disabled={publishRepositoryMutation.isPending} | ||
| 712 | className="grid grid-cols-2 gap-2.5" | ||
| 713 | > | ||
| 714 | {[ | ||
| 715 | { | ||
| 716 | value: "private" as const, | ||
| 717 | label: "Private", | ||
| 718 | description: "Only invited people", | ||
| 719 | Icon: LockIcon, | ||
| 720 | }, | ||
| 721 | { | ||
| 722 | value: "public" as const, | ||
| 723 | label: "Public", | ||
| 724 | description: "Anyone on the web", | ||
| 725 | Icon: GlobeIcon, | ||
| 726 | }, | ||
| 727 | ].map((option) => { | ||
| 728 | const isSelected = publishVisibility === option.value; | ||
| 729 | return ( | ||
| 730 | <RadioPrimitive.Root | ||
| 731 | key={option.value} | ||
| 732 | value={option.value} | ||
| 733 | className={cn( | ||
| 734 | "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]", | ||
| 735 | "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | ||
| 736 | isSelected | ||
| 737 | ? "border-primary bg-background shadow-sm ring-2 ring-primary/35" | ||
| 738 | : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50", | ||
| 739 | )} | ||
| 740 | > | ||
| 741 | <option.Icon | ||
| 742 | className="size-4 shrink-0 text-muted-foreground" | ||
| 743 | aria-hidden | ||
| 744 | /> | ||
| 745 | <span className="min-w-0 flex-1"> | ||
| 746 | <span className="block text-sm font-medium text-foreground"> | ||
| 747 | {option.label} | ||
| 748 | </span> | ||
| 749 | <span className="block text-xs text-muted-foreground"> | ||
| 750 | {option.description} | ||
| 751 | </span> | ||
| 752 | </span> | ||
| 753 | </RadioPrimitive.Root> | ||
| 754 | ); | ||
| 755 | })} | ||
| 756 | </RadioGroup> | ||
| 757 | </div> | ||
| 758 | |||
| 759 | <div> | ||
| 760 | <button | ||
| 761 | type="button" | ||
| 762 | onClick={() => setPublishAdvancedOpen((prev) => !prev)} | ||
| 763 | aria-expanded={publishAdvancedOpen} | ||
| 764 | className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground" | ||
| 765 | > | ||
| 766 | <ChevronDownIcon | ||
| 767 | className={cn( | ||
| 768 | "size-3.5 transition-transform", | ||
| 769 | publishAdvancedOpen ? "" : "-rotate-90", | ||
| 770 | )} | ||
| 771 | /> | ||
| 772 | Advanced | ||
| 773 | </button> | ||
| 774 | {publishAdvancedOpen ? ( | ||
| 775 | <div className="mt-3 grid gap-3 sm:grid-cols-2"> | ||
| 776 | <label className="space-y-1.5" htmlFor="publish-remote-name"> | ||
| 777 | <span className="text-xs font-medium text-foreground">Remote</span> | ||
| 778 | <Input | ||
| 779 | id="publish-remote-name" | ||
| 780 | value={publishRemoteName} | ||
| 781 | onChange={(event) => setPublishRemoteName(event.target.value)} | ||
| 782 | placeholder="origin" | ||
| 783 | disabled={publishRepositoryMutation.isPending} | ||
| 784 | /> | ||
| 785 | </label> | ||
| 786 | <div className="space-y-1.5"> | ||
| 787 | <span | ||
| 788 | id="publish-protocol-label" | ||
| 789 | className="text-xs font-medium text-foreground" | ||
| 790 | > | ||
| 791 | Protocol | ||
| 792 | </span> | ||
| 793 | <RadioGroup | ||
| 794 | value={publishProtocol} | ||
| 795 | onValueChange={(value) => | ||
| 796 | setPublishProtocol(value as SourceControlCloneProtocol) | ||
| 797 | } | ||
| 798 | aria-labelledby="publish-protocol-label" | ||
| 799 | disabled={publishRepositoryMutation.isPending} | ||
| 800 | className="grid grid-cols-2 gap-2" | ||
| 801 | > | ||
| 802 | {(["ssh", "https"] as const).map((value) => { | ||
| 803 | const isSelected = publishProtocol === value; | ||
| 804 | return ( | ||
| 805 | <RadioPrimitive.Root | ||
| 806 | key={value} | ||
| 807 | value={value} | ||
| 808 | className={cn( | ||
| 809 | "rounded-md border px-3 py-1.5 text-center text-sm font-medium outline-none transition", | ||
| 810 | "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | ||
| 811 | isSelected | ||
| 812 | ? "border-primary bg-background ring-2 ring-primary/35 text-foreground" | ||
| 813 | : "border-border bg-background text-muted-foreground hover:border-foreground/20 hover:text-foreground", | ||
| 814 | )} | ||
| 815 | > | ||
| 816 | {value === "ssh" ? "SSH" : "HTTPS"} | ||
| 817 | </RadioPrimitive.Root> | ||
| 818 | ); | ||
| 819 | })} | ||
| 820 | </RadioGroup> | ||
| 821 | </div> | ||
| 822 | </div> | ||
| 823 | ) : null} | ||
| 824 | </div> | ||
| 825 | |||
| 826 | {publishRepositoryMutation.isPending ? ( | ||
| 827 | <div | ||
| 828 | role="status" | ||
| 829 | aria-live="polite" | ||
| 830 | className="flex items-center gap-2 rounded-md border border-input bg-muted/40 px-3 py-2 text-xs text-muted-foreground" | ||
| 831 | > | ||
| 832 | <Spinner className="size-3.5" aria-hidden /> | ||
| 833 | Publishing repository to {publishProviderLabel}... | ||
| 834 | </div> | ||
| 835 | ) : null} | ||
| 836 | {publishError && !publishRepositoryMutation.isPending ? ( | ||
| 837 | <div | ||
| 838 | role="alert" | ||
| 839 | className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive" | ||
| 840 | > | ||
| 841 | <p className="font-medium">Publish failed</p> | ||
| 842 | <p className="mt-0.5 text-destructive/90">{publishError}</p> | ||
| 843 | </div> | ||
| 844 | ) : null} | ||
| 845 | </div> | ||
| 846 | |||
| 847 | <div className={cn("space-y-4", publishWizardStep !== 2 && "hidden")}> | ||
| 848 | {publishResult ? ( | ||
| 849 | <> | ||
| 850 | <div className="flex flex-col items-center gap-2 py-1 text-center"> | ||
| 851 | <span className="grid size-8 place-items-center rounded-full bg-success/15 text-success"> | ||
| 852 | <CheckIcon className="size-4" aria-hidden /> | ||
| 853 | </span> | ||
| 854 | <h3 className="text-sm font-semibold text-foreground"> | ||
| 855 | {publishResult.status === "pushed" | ||
| 856 | ? "Repository published" | ||
| 857 | : "Repository created"} | ||
| 858 | </h3> | ||
| 859 | <p className="max-w-xs text-pretty text-xs text-muted-foreground"> | ||
| 860 | {publishResult.status === "pushed" | ||
| 861 | ? `${publishResult.branch} is now live on ${publishProviderLabel}.` | ||
| 862 | : `Remote "${publishResult.remoteName}" is set up. Make a commit and push it to share your code.`} | ||
| 863 | </p> | ||
| 864 | </div> | ||
| 865 | <div className="flex items-center gap-2 rounded-lg border border-input bg-muted/40 px-3 py-2"> | ||
| 866 | <currentPublishProvider.Icon className="size-3.5 shrink-0 text-muted-foreground" /> | ||
| 867 | <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground"> | ||
| 868 | {publishResult.repository.nameWithOwner} | ||
| 869 | </span> | ||
| 870 | </div> | ||
| 871 | <Button | ||
| 872 | variant="outline" | ||
| 873 | size="sm" | ||
| 874 | className="w-full" | ||
| 875 | onClick={() => { | ||
| 876 | const api = readLocalApi(); | ||
| 877 | if (!api) return; | ||
| 878 | void api.shell.openExternal(publishResult.repository.url); | ||
| 879 | }} | ||
| 880 | > | ||
| 881 | <ExternalLinkIcon className="size-3.5" aria-hidden /> | ||
| 882 | Open on {publishProviderLabel} | ||
| 883 | </Button> | ||
| 884 | </> | ||
| 885 | ) : ( | ||
| 886 | <div className="rounded-md border border-input bg-background px-3 py-2 text-xs text-muted-foreground"> | ||
| 887 | Publish result unavailable. | ||
| 888 | </div> | ||
| 889 | )} | ||
| 890 | </div> | ||
| 891 | </AnimatedHeight> | ||
| 892 | </DialogPanel> | ||
| 893 | |||
| 894 | <DialogFooter> | ||
| 895 | {publishWizardStep === 2 ? ( | ||
| 896 | <Button size="sm" onClick={() => handleOpenChange(false)}> | ||
| 897 | Done | ||
| 898 | </Button> | ||
| 899 | ) : ( | ||
| 900 | <> | ||
| 901 | <Button | ||
| 902 | variant="outline" | ||
| 903 | size="sm" | ||
| 904 | disabled={publishRepositoryMutation.isPending} | ||
| 905 | onClick={() => { | ||
| 906 | if (publishWizardStep === 0) { | ||
| 907 | handleOpenChange(false); | ||
| 908 | return; | ||
| 909 | } | ||
| 910 | setPublishWizardStep((step) => Math.max(0, step - 1)); | ||
| 911 | }} | ||
| 912 | > | ||
| 913 | {publishWizardStep === 0 ? "Cancel" : "Back"} | ||
| 914 | </Button> | ||
| 915 | {publishWizardStep < 1 ? ( | ||
| 916 | <Button | ||
| 917 | size="sm" | ||
| 918 | disabled={!hasReadyPublishProvider || !selectedPublishProviderReadiness.ready} | ||
| 919 | onClick={() => setPublishWizardStep((step) => Math.min(1, step + 1))} | ||
| 920 | > | ||
| 921 | Next | ||
| 922 | </Button> | ||
| 923 | ) : ( | ||
| 924 | <Button | ||
| 925 | size="sm" | ||
| 926 | disabled={!canSubmitPublishRepository} | ||
| 927 | onClick={submitPublishRepository} | ||
| 928 | > | ||
| 929 | {publishRepositoryMutation.isPending ? ( | ||
| 930 | <> | ||
| 931 | <Spinner className="size-3.5" aria-hidden /> | ||
| 932 | Publishing... | ||
| 933 | </> | ||
| 934 | ) : ( | ||
| 935 | "Publish" | ||
| 936 | )} | ||
| 937 | </Button> | ||
| 938 | )} | ||
| 939 | </> | ||
| 940 | )} | ||
| 941 | </DialogFooter> | ||
| 942 | </div> | ||
| 943 | </DialogPopup> | ||
| 944 | </Dialog> | ||
| 945 | ); | ||
| 946 | } | ||
| 947 | |||
| 259 | export default function GitActionsControl({ | 948 | export default function GitActionsControl({ |
| 260 | gitCwd, | 949 | gitCwd, |
| 261 | activeThreadRef, | 950 | activeThreadRef, |
| ... | @@ -286,34 +975,8 @@ export default function GitActionsControl({ | ... | @@ -286,34 +975,8 @@ export default function GitActionsControl({ |
| 286 | const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set()); | 975 | const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set()); |
| 287 | const [isEditingFiles, setIsEditingFiles] = useState(false); | 976 | const [isEditingFiles, setIsEditingFiles] = useState(false); |
| 288 | const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false); | 977 | const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false); |
| 289 | const [publishProvider, setPublishProvider] = useState<PublishProviderKind>("github"); | ||
| 290 | const [publishRepository, setPublishRepository] = useState(""); | ||
| 291 | const [publishVisibility, setPublishVisibility] = | ||
| 292 | useState<SourceControlRepositoryVisibility>("private"); | ||
| 293 | const [publishRemoteName, setPublishRemoteName] = useState("origin"); | ||
| 294 | const [publishProtocol, setPublishProtocol] = useState<SourceControlCloneProtocol>("ssh"); | ||
| 295 | const [publishWizardStep, setPublishWizardStep] = useState(0); | ||
| 296 | const [publishAdvancedOpen, setPublishAdvancedOpen] = useState(false); | ||
| 297 | const [publishError, setPublishError] = useState<string | null>(null); | ||
| 298 | const [publishResult, setPublishResult] = useState<SourceControlPublishRepositoryResult | null>( | ||
| 299 | null, | ||
| 300 | ); | ||
| 301 | const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false); | ||
| 302 | const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = | 978 | const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = |
| 303 | useState<PendingDefaultBranchAction | null>(null); | 979 | useState<PendingDefaultBranchAction | null>(null); |
| 304 | const sourceControlDiscovery = useSourceControlDiscovery(); | ||
| 305 | const publishAccountByProvider = useMemo(() => { | ||
| 306 | const accounts: Record<PublishProviderKind, string | null> = { github: null, gitlab: null }; | ||
| 307 | for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { | ||
| 308 | if (provider.kind === "github" || provider.kind === "gitlab") { | ||
| 309 | accounts[provider.kind] = Option.getOrNull(provider.auth.account); | ||
| 310 | } | ||
| 311 | } | ||
| 312 | return accounts; | ||
| 313 | }, [sourceControlDiscovery.data]); | ||
| 314 | const publishRepositoryPrefill = publishAccountByProvider[publishProvider] | ||
| 315 | ? `${publishAccountByProvider[publishProvider]}/` | ||
| 316 | : ""; | ||
| 317 | const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null); | 980 | const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null); |
| 318 | let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>; | 981 | let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>; |
| 319 | 982 | ||
| ... | @@ -425,13 +1088,6 @@ export default function GitActionsControl({ | ... | @@ -425,13 +1088,6 @@ export default function GitActionsControl({ |
| 425 | const pullMutation = useMutation( | 1088 | const pullMutation = useMutation( |
| 426 | gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), | 1089 | gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), |
| 427 | ); | 1090 | ); |
| 428 | const publishRepositoryMutation = useMutation( | ||
| 429 | sourceControlPublishRepositoryMutationOptions({ | ||
| 430 | environmentId: activeEnvironmentId, | ||
| 431 | cwd: gitCwd, | ||
| 432 | queryClient, | ||
| 433 | }), | ||
| 434 | ); | ||
| 435 | 1091 | ||
| 436 | const isRunStackedActionRunning = | 1092 | const isRunStackedActionRunning = |
| 437 | useIsMutating({ | 1093 | useIsMutating({ |
| ... | @@ -510,13 +1166,6 @@ export default function GitActionsControl({ | ... | @@ -510,13 +1166,6 @@ export default function GitActionsControl({ |
| 510 | }; | 1166 | }; |
| 511 | }, [updateActiveProgressToast]); | 1167 | }, [updateActiveProgressToast]); |
| 512 | 1168 | ||
| 513 | useEffect(() => { | ||
| 514 | if (!isPublishDialogOpen || hasUserEditedPublishRepository) { | ||
| 515 | return; | ||
| 516 | } | ||
| 517 | setPublishRepository(publishRepositoryPrefill); | ||
| 518 | }, [isPublishDialogOpen, hasUserEditedPublishRepository, publishRepositoryPrefill]); | ||
| 519 | |||
| 520 | useEffect(() => { | 1169 | useEffect(() => { |
| 521 | if (gitCwd === null) { | 1170 | if (gitCwd === null) { |
| 522 | return; | 1171 | return; |
| ... | @@ -965,45 +1614,6 @@ export default function GitActionsControl({ | ... | @@ -965,45 +1614,6 @@ export default function GitActionsControl({ |
| 965 | ); | 1614 | ); |
| 966 | 1615 | ||
| 967 | const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; | 1616 | const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; |
| 968 | const canSubmitPublishRepository = (() => { | ||
| 969 | if (publishRepositoryMutation.isPending) return false; | ||
| 970 | const repositoryParts = publishRepository.trim().split("/"); | ||
| 971 | const owner = repositoryParts[0]?.trim() ?? ""; | ||
| 972 | const rest = repositoryParts.slice(1); | ||
| 973 | const name = rest.join("/").trim(); | ||
| 974 | return owner.length > 0 && name.length > 0; | ||
| 975 | })(); | ||
| 976 | |||
| 977 | const publishHost = publishProvider === "github" ? "github.com" : "gitlab.com"; | ||
| 978 | const publishPathPlaceholder = publishProvider === "github" ? "owner/repo" : "group/project"; | ||
| 979 | const publishProviderLabel = publishProvider === "github" ? "GitHub" : "GitLab"; | ||
| 980 | |||
| 981 | const submitPublishRepository = () => { | ||
| 982 | if (!canSubmitPublishRepository) { | ||
| 983 | return; | ||
| 984 | } | ||
| 985 | |||
| 986 | setPublishError(null); | ||
| 987 | |||
| 988 | void publishRepositoryMutation | ||
| 989 | .mutateAsync({ | ||
| 990 | provider: publishProvider, | ||
| 991 | repository: publishRepository.trim(), | ||
| 992 | visibility: publishVisibility, | ||
| 993 | remoteName: publishRemoteName.trim() || "origin", | ||
| 994 | protocol: publishProtocol, | ||
| 995 | }) | ||
| 996 | .then((result) => { | ||
| 997 | setPublishResult(result); | ||
| 998 | setPublishWizardStep(2); | ||
| 999 | void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( | ||
| 1000 | () => undefined, | ||
| 1001 | ); | ||
| 1002 | }) | ||
| 1003 | .catch((err: unknown) => { | ||
| 1004 | setPublishError(err instanceof Error ? err.message : "An error occurred."); | ||
| 1005 | }); | ||
| 1006 | }; | ||
| 1007 | 1617 | ||
| 1008 | if (!gitCwd) return null; | 1618 | if (!gitCwd) return null; |
| 1009 | 1619 | ||
| ... | @@ -1322,419 +1932,12 @@ export default function GitActionsControl({ | ... | @@ -1322,419 +1932,12 @@ export default function GitActionsControl({ |
| 1322 | </DialogPopup> | 1932 | </DialogPopup> |
| 1323 | </Dialog> | 1933 | </Dialog> |
| 1324 | 1934 | ||
| 1325 | <Dialog | 1935 | <PublishRepositoryDialog |
| 1326 | open={isPublishDialogOpen} | 1936 | open={isPublishDialogOpen} |
| 1327 | onOpenChange={(open) => { | 1937 | onOpenChange={setIsPublishDialogOpen} |
| 1328 | setIsPublishDialogOpen(open); | 1938 | environmentId={activeEnvironmentId} |
| 1329 | if (!open) { | 1939 | gitCwd={gitCwd} |
| 1330 | setPublishRemoteName("origin"); | 1940 | /> |
| 1331 | setPublishRepository(""); | ||
| 1332 | setHasUserEditedPublishRepository(false); | ||
| 1333 | setPublishWizardStep(0); | ||
| 1334 | setPublishAdvancedOpen(false); | ||
| 1335 | setPublishError(null); | ||
| 1336 | setPublishResult(null); | ||
| 1337 | } | ||
| 1338 | }} | ||
| 1339 | > | ||
| 1340 | <DialogPopup className="max-w-lg"> | ||
| 1341 | <DialogHeader> | ||
| 1342 | <DialogTitle>Publish repository</DialogTitle> | ||
| 1343 | <DialogDescription> | ||
| 1344 | Pick where to host it, then point us at a repo to push to. | ||
| 1345 | </DialogDescription> | ||
| 1346 | <div className="mt-1 grid grid-cols-3 gap-2"> | ||
| 1347 | {(["Provider", "Repository", "Summary"] as const).map((label, index) => { | ||
| 1348 | const summary = | ||
| 1349 | index === 0 && publishWizardStep > 0 | ||
| 1350 | ? publishProviderLabel | ||
| 1351 | : index === 1 && publishWizardStep > 1 && publishResult | ||
| 1352 | ? publishResult.repository.nameWithOwner | ||
| 1353 | : null; | ||
| 1354 | const isComplete = index < publishWizardStep; | ||
| 1355 | const isDoneStep = index === 2; | ||
| 1356 | // The summary step is only reachable via a successful submit; | ||
| 1357 | // disallow user-driven navigation to or from it. | ||
| 1358 | const isClickable = | ||
| 1359 | publishWizardStep !== 2 && !isDoneStep && index <= publishWizardStep; | ||
| 1360 | return ( | ||
| 1361 | <button | ||
| 1362 | key={label} | ||
| 1363 | type="button" | ||
| 1364 | onClick={isClickable ? () => setPublishWizardStep(index) : undefined} | ||
| 1365 | disabled={!isClickable} | ||
| 1366 | className={cn( | ||
| 1367 | "grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 rounded-lg border px-3 py-2 text-left transition", | ||
| 1368 | index === publishWizardStep | ||
| 1369 | ? "border-primary bg-primary/10 ring-1 ring-primary/25" | ||
| 1370 | : isComplete | ||
| 1371 | ? "border-border bg-background" | ||
| 1372 | : "border-border bg-muted/40", | ||
| 1373 | !isClickable && "cursor-default", | ||
| 1374 | )} | ||
| 1375 | > | ||
| 1376 | <span | ||
| 1377 | aria-hidden | ||
| 1378 | className={cn( | ||
| 1379 | "row-span-2 mt-0.5 grid size-4 place-items-center rounded-full border", | ||
| 1380 | isComplete | ||
| 1381 | ? "border-primary bg-primary text-primary-foreground" | ||
| 1382 | : index === publishWizardStep | ||
| 1383 | ? "border-primary bg-background" | ||
| 1384 | : "border-muted-foreground/35 bg-background", | ||
| 1385 | )} | ||
| 1386 | > | ||
| 1387 | {isComplete ? <CheckIcon className="size-3" /> : null} | ||
| 1388 | </span> | ||
| 1389 | <span className="text-[10px] font-medium uppercase text-muted-foreground"> | ||
| 1390 | Step {index + 1} | ||
| 1391 | </span> | ||
| 1392 | <span className="truncate text-xs font-semibold text-foreground"> | ||
| 1393 | {label} | ||
| 1394 | {summary ? `: ${summary}` : ""} | ||
| 1395 | </span> | ||
| 1396 | </button> | ||
| 1397 | ); | ||
| 1398 | })} | ||
| 1399 | </div> | ||
| 1400 | </DialogHeader> | ||
| 1401 | |||
| 1402 | {/* Step 1: Provider */} | ||
| 1403 | {publishWizardStep === 0 && ( | ||
| 1404 | <DialogPanel className="space-y-4"> | ||
| 1405 | <div className="space-y-2"> | ||
| 1406 | <span | ||
| 1407 | id="publish-provider-cards-label" | ||
| 1408 | className="text-xs font-medium text-foreground" | ||
| 1409 | > | ||
| 1410 | Provider | ||
| 1411 | </span> | ||
| 1412 | <RadioGroup | ||
| 1413 | value={publishProvider} | ||
| 1414 | onValueChange={(value) => setPublishProvider(value as PublishProviderKind)} | ||
| 1415 | aria-labelledby="publish-provider-cards-label" | ||
| 1416 | className="grid grid-cols-2 gap-2.5" | ||
| 1417 | > | ||
| 1418 | {[ | ||
| 1419 | { | ||
| 1420 | value: "github" as const, | ||
| 1421 | label: "GitHub", | ||
| 1422 | description: "github.com", | ||
| 1423 | Icon: GitHubIcon, | ||
| 1424 | }, | ||
| 1425 | { | ||
| 1426 | value: "gitlab" as const, | ||
| 1427 | label: "GitLab", | ||
| 1428 | description: "gitlab.com", | ||
| 1429 | Icon: GitLabIcon, | ||
| 1430 | }, | ||
| 1431 | ].map((option) => { | ||
| 1432 | const isSelected = publishProvider === option.value; | ||
| 1433 | return ( | ||
| 1434 | <RadioPrimitive.Root | ||
| 1435 | key={option.value} | ||
| 1436 | value={option.value} | ||
| 1437 | className={cn( | ||
| 1438 | "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-3 text-left outline-none transition-[background-color,border-color,box-shadow]", | ||
| 1439 | "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | ||
| 1440 | isSelected | ||
| 1441 | ? "border-primary bg-background shadow-sm ring-2 ring-primary/35" | ||
| 1442 | : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50", | ||
| 1443 | )} | ||
| 1444 | > | ||
| 1445 | <option.Icon className="size-5 shrink-0" aria-hidden /> | ||
| 1446 | <span className="min-w-0 flex-1"> | ||
| 1447 | <span className="block text-sm font-medium text-foreground"> | ||
| 1448 | {option.label} | ||
| 1449 | </span> | ||
| 1450 | <span className="block text-xs text-muted-foreground"> | ||
| 1451 | {option.description} | ||
| 1452 | </span> | ||
| 1453 | </span> | ||
| 1454 | </RadioPrimitive.Root> | ||
| 1455 | ); | ||
| 1456 | })} | ||
| 1457 | </RadioGroup> | ||
| 1458 | </div> | ||
| 1459 | </DialogPanel> | ||
| 1460 | )} | ||
| 1461 | {publishWizardStep === 1 && ( | ||
| 1462 | <DialogPanel className="space-y-5"> | ||
| 1463 | <div className="space-y-2"> | ||
| 1464 | <label | ||
| 1465 | htmlFor="publish-repository-path" | ||
| 1466 | className="text-xs font-medium text-foreground" | ||
| 1467 | > | ||
| 1468 | Repository | ||
| 1469 | </label> | ||
| 1470 | <div className="flex items-stretch overflow-hidden rounded-md border border-input bg-background focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-ring"> | ||
| 1471 | <span className="flex shrink-0 items-center gap-1.5 border-r border-input bg-muted/50 px-2.5 font-mono text-xs text-muted-foreground"> | ||
| 1472 | {publishProvider === "github" ? ( | ||
| 1473 | <GitHubIcon className="size-3.5" /> | ||
| 1474 | ) : ( | ||
| 1475 | <GitLabIcon className="size-3.5" /> | ||
| 1476 | )} | ||
| 1477 | {publishHost}/ | ||
| 1478 | </span> | ||
| 1479 | <input | ||
| 1480 | id="publish-repository-path" | ||
| 1481 | name="publish-repository-path" | ||
| 1482 | value={publishRepository} | ||
| 1483 | onChange={(event) => { | ||
| 1484 | setPublishRepository(event.target.value); | ||
| 1485 | setHasUserEditedPublishRepository(true); | ||
| 1486 | }} | ||
| 1487 | onKeyDown={(event) => { | ||
| 1488 | if (event.key === "Enter") { | ||
| 1489 | event.preventDefault(); | ||
| 1490 | submitPublishRepository(); | ||
| 1491 | } | ||
| 1492 | }} | ||
| 1493 | placeholder={publishPathPlaceholder} | ||
| 1494 | disabled={publishRepositoryMutation.isPending} | ||
| 1495 | className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" | ||
| 1496 | /> | ||
| 1497 | </div> | ||
| 1498 | </div> | ||
| 1499 | <div className="space-y-2"> | ||
| 1500 | <span | ||
| 1501 | id="publish-visibility-cards-label" | ||
| 1502 | className="text-xs font-medium text-foreground" | ||
| 1503 | > | ||
| 1504 | Visibility | ||
| 1505 | </span> | ||
| 1506 | <RadioGroup | ||
| 1507 | value={publishVisibility} | ||
| 1508 | onValueChange={(value) => | ||
| 1509 | setPublishVisibility(value as SourceControlRepositoryVisibility) | ||
| 1510 | } | ||
| 1511 | aria-labelledby="publish-visibility-cards-label" | ||
| 1512 | disabled={publishRepositoryMutation.isPending} | ||
| 1513 | className="grid grid-cols-2 gap-2.5" | ||
| 1514 | > | ||
| 1515 | {[ | ||
| 1516 | { | ||
| 1517 | value: "private" as const, | ||
| 1518 | label: "Private", | ||
| 1519 | description: "Only invited people", | ||
| 1520 | Icon: LockIcon, | ||
| 1521 | }, | ||
| 1522 | { | ||
| 1523 | value: "public" as const, | ||
| 1524 | label: "Public", | ||
| 1525 | description: "Anyone on the web", | ||
| 1526 | Icon: GlobeIcon, | ||
| 1527 | }, | ||
| 1528 | ].map((option) => { | ||
| 1529 | const isSelected = publishVisibility === option.value; | ||
| 1530 | return ( | ||
| 1531 | <RadioPrimitive.Root | ||
| 1532 | key={option.value} | ||
| 1533 | value={option.value} | ||
| 1534 | className={cn( | ||
| 1535 | "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]", | ||
| 1536 | "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | ||
| 1537 | isSelected | ||
| 1538 | ? "border-primary bg-background shadow-sm ring-2 ring-primary/35" | ||
| 1539 | : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50", | ||
| 1540 | )} | ||
| 1541 | > | ||
| 1542 | <option.Icon | ||
| 1543 | className="size-4 shrink-0 text-muted-foreground" | ||
| 1544 | aria-hidden | ||
| 1545 | /> | ||
| 1546 | <span className="min-w-0 flex-1"> | ||
| 1547 | <span className="block text-sm font-medium text-foreground"> | ||
| 1548 | {option.label} | ||
| 1549 | </span> | ||
| 1550 | <span className="block text-xs text-muted-foreground"> | ||
| 1551 | {option.description} | ||
| 1552 | </span> | ||
| 1553 | </span> | ||
| 1554 | </RadioPrimitive.Root> | ||
| 1555 | ); | ||
| 1556 | })} | ||
| 1557 | </RadioGroup> | ||
| 1558 | </div> | ||
| 1559 | <div> | ||
| 1560 | <button | ||
| 1561 | type="button" | ||
| 1562 | onClick={() => setPublishAdvancedOpen((prev) => !prev)} | ||
| 1563 | aria-expanded={publishAdvancedOpen} | ||
| 1564 | className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground" | ||
| 1565 | > | ||
| 1566 | <ChevronDownIcon | ||
| 1567 | className={cn( | ||
| 1568 | "size-3.5 transition-transform", | ||
| 1569 | publishAdvancedOpen ? "" : "-rotate-90", | ||
| 1570 | )} | ||
| 1571 | /> | ||
| 1572 | Advanced | ||
| 1573 | </button> | ||
| 1574 | {publishAdvancedOpen && ( | ||
| 1575 | <div className="mt-3 grid gap-3 sm:grid-cols-2"> | ||
| 1576 | <label className="space-y-1.5" htmlFor="publish-remote-name"> | ||
| 1577 | <span className="text-xs font-medium text-foreground">Remote</span> | ||
| 1578 | <Input | ||
| 1579 | id="publish-remote-name" | ||
| 1580 | value={publishRemoteName} | ||
| 1581 | onChange={(event) => setPublishRemoteName(event.target.value)} | ||
| 1582 | placeholder="origin" | ||
| 1583 | disabled={publishRepositoryMutation.isPending} | ||
| 1584 | /> | ||
| 1585 | </label> | ||
| 1586 | <div className="space-y-1.5"> | ||
| 1587 | <span | ||
| 1588 | id="publish-protocol-label" | ||
| 1589 | className="text-xs font-medium text-foreground" | ||
| 1590 | > | ||
| 1591 | Protocol | ||
| 1592 | </span> | ||
| 1593 | <RadioGroup | ||
| 1594 | value={publishProtocol} | ||
| 1595 | onValueChange={(value) => | ||
| 1596 | setPublishProtocol(value as SourceControlCloneProtocol) | ||
| 1597 | } | ||
| 1598 | aria-labelledby="publish-protocol-label" | ||
| 1599 | disabled={publishRepositoryMutation.isPending} | ||
| 1600 | className="grid grid-cols-2 gap-2" | ||
| 1601 | > | ||
| 1602 | {(["ssh", "https"] as const).map((value) => { | ||
| 1603 | const isSelected = publishProtocol === value; | ||
| 1604 | return ( | ||
| 1605 | <RadioPrimitive.Root | ||
| 1606 | key={value} | ||
| 1607 | value={value} | ||
| 1608 | className={cn( | ||
| 1609 | "rounded-md border px-3 py-1.5 text-center text-sm font-medium outline-none transition", | ||
| 1610 | "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | ||
| 1611 | isSelected | ||
| 1612 | ? "border-primary bg-background ring-2 ring-primary/35 text-foreground" | ||
| 1613 | : "border-border bg-background text-muted-foreground hover:border-foreground/20 hover:text-foreground", | ||
| 1614 | )} | ||
| 1615 | > | ||
| 1616 | {value === "ssh" ? "SSH" : "HTTPS"} | ||
| 1617 | </RadioPrimitive.Root> | ||
| 1618 | ); | ||
| 1619 | })} | ||
| 1620 | </RadioGroup> | ||
| 1621 | </div> | ||
| 1622 | </div> | ||
| 1623 | )} | ||
| 1624 | </div> | ||
| 1625 | {publishRepositoryMutation.isPending && ( | ||
| 1626 | <div | ||
| 1627 | role="status" | ||
| 1628 | aria-live="polite" | ||
| 1629 | className="flex items-center gap-2 rounded-md border border-input bg-muted/40 px-3 py-2 text-xs text-muted-foreground" | ||
| 1630 | > | ||
| 1631 | <Spinner className="size-3.5" aria-hidden /> | ||
| 1632 | Publishing repository to {publishProviderLabel}... | ||
| 1633 | </div> | ||
| 1634 | )} | ||
| 1635 | {publishError && !publishRepositoryMutation.isPending && ( | ||
| 1636 | <div | ||
| 1637 | role="alert" | ||
| 1638 | className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive" | ||
| 1639 | > | ||
| 1640 | <p className="font-medium">Publish failed</p> | ||
| 1641 | <p className="mt-0.5 text-destructive/90">{publishError}</p> | ||
| 1642 | </div> | ||
| 1643 | )} | ||
| 1644 | </DialogPanel> | ||
| 1645 | )} | ||
| 1646 | {publishWizardStep === 2 && publishResult && ( | ||
| 1647 | <DialogPanel className="space-y-4"> | ||
| 1648 | <div className="flex flex-col items-center gap-2 py-1 text-center"> | ||
| 1649 | <span className="grid size-8 place-items-center rounded-full bg-success/15 text-success"> | ||
| 1650 | <CheckIcon className="size-4" aria-hidden /> | ||
| 1651 | </span> | ||
| 1652 | <h3 className="text-sm font-semibold text-foreground"> | ||
| 1653 | {publishResult.status === "pushed" | ||
| 1654 | ? "Repository published" | ||
| 1655 | : "Repository created"} | ||
| 1656 | </h3> | ||
| 1657 | <p className="max-w-xs text-pretty text-xs text-muted-foreground"> | ||
| 1658 | {publishResult.status === "pushed" | ||
| 1659 | ? `${publishResult.branch} is now live on ${publishProviderLabel}.` | ||
| 1660 | : `Remote "${publishResult.remoteName}" is set up. Make a commit and push it to share your code.`} | ||
| 1661 | </p> | ||
| 1662 | </div> | ||
| 1663 | <div className="flex items-center gap-2 rounded-lg border border-input bg-muted/40 px-3 py-2"> | ||
| 1664 | {publishProvider === "github" ? ( | ||
| 1665 | <GitHubIcon className="size-3.5 shrink-0 text-muted-foreground" /> | ||
| 1666 | ) : ( | ||
| 1667 | <GitLabIcon className="size-3.5 shrink-0 text-muted-foreground" /> | ||
| 1668 | )} | ||
| 1669 | <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground"> | ||
| 1670 | {publishResult.repository.nameWithOwner} | ||
| 1671 | </span> | ||
| 1672 | </div> | ||
| 1673 | <Button | ||
| 1674 | variant="outline" | ||
| 1675 | size="sm" | ||
| 1676 | className="w-full" | ||
| 1677 | onClick={() => { | ||
| 1678 | const api = readLocalApi(); | ||
| 1679 | if (!api) return; | ||
| 1680 | void api.shell.openExternal(publishResult.repository.url); | ||
| 1681 | }} | ||
| 1682 | > | ||
| 1683 | <ExternalLinkIcon className="size-3.5" aria-hidden /> | ||
| 1684 | Open on {publishProviderLabel} | ||
| 1685 | </Button> | ||
| 1686 | </DialogPanel> | ||
| 1687 | )} | ||
| 1688 | |||
| 1689 | <DialogFooter> | ||
| 1690 | {publishWizardStep === 2 ? ( | ||
| 1691 | <Button size="sm" onClick={() => setIsPublishDialogOpen(false)}> | ||
| 1692 | Done | ||
| 1693 | </Button> | ||
| 1694 | ) : ( | ||
| 1695 | <> | ||
| 1696 | <Button | ||
| 1697 | variant="outline" | ||
| 1698 | size="sm" | ||
| 1699 | disabled={publishRepositoryMutation.isPending} | ||
| 1700 | onClick={() => { | ||
| 1701 | if (publishWizardStep === 0) { | ||
| 1702 | setIsPublishDialogOpen(false); | ||
| 1703 | return; | ||
| 1704 | } | ||
| 1705 | setPublishWizardStep((step) => Math.max(0, step - 1)); | ||
| 1706 | }} | ||
| 1707 | > | ||
| 1708 | {publishWizardStep === 0 ? "Cancel" : "Back"} | ||
| 1709 | </Button> | ||
| 1710 | {publishWizardStep < 1 ? ( | ||
| 1711 | <Button | ||
| 1712 | size="sm" | ||
| 1713 | onClick={() => setPublishWizardStep((step) => Math.min(1, step + 1))} | ||
| 1714 | > | ||
| 1715 | Next | ||
| 1716 | </Button> | ||
| 1717 | ) : ( | ||
| 1718 | <Button | ||
| 1719 | size="sm" | ||
| 1720 | disabled={!canSubmitPublishRepository} | ||
| 1721 | onClick={submitPublishRepository} | ||
| 1722 | > | ||
| 1723 | {publishRepositoryMutation.isPending ? ( | ||
| 1724 | <> | ||
| 1725 | <Spinner className="size-3.5" aria-hidden /> | ||
| 1726 | Publishing... | ||
| 1727 | </> | ||
| 1728 | ) : ( | ||
| 1729 | "Publish" | ||
| 1730 | )} | ||
| 1731 | </Button> | ||
| 1732 | )} | ||
| 1733 | </> | ||
| 1734 | )} | ||
| 1735 | </DialogFooter> | ||
| 1736 | </DialogPopup> | ||
| 1737 | </Dialog> | ||
| 1738 | 1941 | ||
| 1739 | <Dialog | 1942 | <Dialog |
| 1740 | open={pendingDefaultBranchAction !== null} | 1943 | open={pendingDefaultBranchAction !== null} |
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+159-153| ... | @@ -28,6 +28,7 @@ import { RadioGroup } from "../ui/radio-group"; | ... | @@ -28,6 +28,7 @@ import { RadioGroup } from "../ui/radio-group"; |
| 28 | import { toastManager } from "../ui/toast"; | 28 | import { toastManager } from "../ui/toast"; |
| 29 | import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; | 29 | import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; |
| 30 | import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; | 30 | import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; |
| 31 | import { AnimatedHeight } from "../AnimatedHeight"; | ||
| 31 | 32 | ||
| 32 | const PROVIDER_ACCENT_SWATCHES = [ | 33 | const PROVIDER_ACCENT_SWATCHES = [ |
| 33 | "#2563eb", | 34 | "#2563eb", |
| ... | @@ -250,7 +251,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns | ... | @@ -250,7 +251,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns |
| 250 | key={step} | 251 | key={step} |
| 251 | type="button" | 252 | type="button" |
| 252 | className={cn( | 253 | className={cn( |
| 253 | "grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 rounded-lg border px-3 py-2 text-left transition", | 254 | "grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 rounded-lg border px-3 py-2 text-left", |
| 254 | index === wizardStep | 255 | index === wizardStep |
| 255 | ? "border-primary bg-primary/10 ring-1 ring-primary/25" | 256 | ? "border-primary bg-primary/10 ring-1 ring-primary/25" |
| 256 | : index < wizardStep | 257 | : index < wizardStep |
| ... | @@ -290,168 +291,173 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns | ... | @@ -290,168 +291,173 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns |
| 290 | data-slot="dialog-panel" | 291 | data-slot="dialog-panel" |
| 291 | className="space-y-4 border-b border-border/70 bg-muted/20 px-6 py-5" | 292 | className="space-y-4 border-b border-border/70 bg-muted/20 px-6 py-5" |
| 292 | > | 293 | > |
| 293 | <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}> | 294 | <AnimatedHeight> |
| 294 | <span id="add-instance-driver-label" className="text-xs font-medium text-foreground"> | 295 | <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}> |
| 295 | Driver | 296 | <span |
| 296 | </span> | 297 | id="add-instance-driver-label" |
| 297 | <RadioGroup | 298 | className="text-xs font-medium text-foreground" |
| 298 | value={driver} | 299 | > |
| 299 | onValueChange={(value) => setDriver(ProviderDriverKind.make(value))} | 300 | Driver |
| 300 | aria-labelledby="add-instance-driver-label" | ||
| 301 | className="grid grid-cols-2 gap-2.5" | ||
| 302 | > | ||
| 303 | {DRIVER_OPTIONS.map((option) => { | ||
| 304 | const IconComponent = option.icon; | ||
| 305 | const isSelected = option.value === driver; | ||
| 306 | return ( | ||
| 307 | <RadioPrimitive.Root | ||
| 308 | key={option.value} | ||
| 309 | value={option.value} | ||
| 310 | className={cn( | ||
| 311 | "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-3 text-left outline-none transition-[background-color,border-color,box-shadow]", | ||
| 312 | "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | ||
| 313 | isSelected | ||
| 314 | ? "border-primary bg-background shadow-sm ring-2 ring-primary/35" | ||
| 315 | : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50", | ||
| 316 | )} | ||
| 317 | > | ||
| 318 | <IconComponent className="size-5 shrink-0" aria-hidden /> | ||
| 319 | <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"> | ||
| 320 | {option.label} | ||
| 321 | </span> | ||
| 322 | {option.badgeLabel ? ( | ||
| 323 | <Badge variant="warning" size="sm"> | ||
| 324 | {option.badgeLabel} | ||
| 325 | </Badge> | ||
| 326 | ) : null} | ||
| 327 | </RadioPrimitive.Root> | ||
| 328 | ); | ||
| 329 | })} | ||
| 330 | {COMING_SOON_DRIVER_OPTIONS.map((option) => { | ||
| 331 | const IconComponent = option.icon; | ||
| 332 | return ( | ||
| 333 | <RadioPrimitive.Root | ||
| 334 | key={option.value} | ||
| 335 | value={option.value} | ||
| 336 | disabled | ||
| 337 | className={cn( | ||
| 338 | "relative flex cursor-not-allowed items-center gap-3 rounded-lg border border-border bg-background px-3 py-3 text-left opacity-55 outline-none", | ||
| 339 | )} | ||
| 340 | > | ||
| 341 | <IconComponent | ||
| 342 | className="size-5 shrink-0 text-muted-foreground" | ||
| 343 | aria-hidden | ||
| 344 | /> | ||
| 345 | <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"> | ||
| 346 | {option.label} | ||
| 347 | </span> | ||
| 348 | <Badge variant="warning" size="sm"> | ||
| 349 | Coming Soon | ||
| 350 | </Badge> | ||
| 351 | </RadioPrimitive.Root> | ||
| 352 | ); | ||
| 353 | })} | ||
| 354 | </RadioGroup> | ||
| 355 | </div> | ||
| 356 | |||
| 357 | <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> | ||
| 358 | <span className="text-xs font-medium text-foreground">Label</span> | ||
| 359 | <Input | ||
| 360 | className="bg-background" | ||
| 361 | placeholder="e.g. Work" | ||
| 362 | value={label} | ||
| 363 | onChange={(event) => setLabel(event.target.value)} | ||
| 364 | /> | ||
| 365 | <span className="text-[11px] text-muted-foreground"> | ||
| 366 | Shown in the provider list. Optional. | ||
| 367 | </span> | ||
| 368 | </label> | ||
| 369 | |||
| 370 | <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> | ||
| 371 | <span className="text-xs font-medium text-foreground">Instance ID</span> | ||
| 372 | <Input | ||
| 373 | className="bg-background" | ||
| 374 | placeholder={`${driver}_work`} | ||
| 375 | value={instanceId} | ||
| 376 | onChange={(event) => { | ||
| 377 | setInstanceIdDirty(true); | ||
| 378 | setInstanceId(event.target.value); | ||
| 379 | }} | ||
| 380 | aria-invalid={showInstanceIdError} | ||
| 381 | /> | ||
| 382 | {showInstanceIdError ? ( | ||
| 383 | <span className="text-[11px] text-destructive">{instanceIdError}</span> | ||
| 384 | ) : ( | ||
| 385 | <span className="text-[11px] text-muted-foreground"> | ||
| 386 | Routing key used by threads and sessions. Letters, digits, '-', or '_'. | ||
| 387 | </span> | 301 | </span> |
| 388 | )} | 302 | <RadioGroup |
| 389 | </label> | 303 | value={driver} |
| 390 | 304 | onValueChange={(value) => setDriver(ProviderDriverKind.make(value))} | |
| 391 | <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> | 305 | aria-labelledby="add-instance-driver-label" |
| 392 | <span className="text-xs font-medium text-foreground">Accent color</span> | 306 | className="grid grid-cols-2 gap-2.5" |
| 393 | <div className="flex min-w-0 flex-wrap items-center gap-2"> | 307 | > |
| 394 | <input | 308 | {DRIVER_OPTIONS.map((option) => { |
| 395 | type="color" | 309 | const IconComponent = option.icon; |
| 396 | value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]} | 310 | const isSelected = option.value === driver; |
| 397 | onChange={(event) => setAccentColor(event.target.value)} | ||
| 398 | aria-label="Provider instance accent color" | ||
| 399 | className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5" | ||
| 400 | /> | ||
| 401 | <div className="flex flex-wrap gap-1.5"> | ||
| 402 | {PROVIDER_ACCENT_SWATCHES.map((swatch) => { | ||
| 403 | const selected = accentColor.toLowerCase() === swatch; | ||
| 404 | return ( | 311 | return ( |
| 405 | <button | 312 | <RadioPrimitive.Root |
| 406 | key={swatch} | 313 | key={option.value} |
| 407 | type="button" | 314 | value={option.value} |
| 408 | className={cn( | 315 | className={cn( |
| 409 | "size-6 cursor-pointer rounded-full border transition", | 316 | "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-3 text-left outline-none transition-[background-color,border-color,box-shadow]", |
| 410 | selected | 317 | "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", |
| 411 | ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background" | 318 | isSelected |
| 412 | : "border-black/10 hover:scale-105 dark:border-white/20", | 319 | ? "border-primary bg-background shadow-sm ring-2 ring-primary/35" |
| 320 | : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50", | ||
| 413 | )} | 321 | )} |
| 414 | style={{ backgroundColor: swatch }} | 322 | > |
| 415 | onClick={() => setAccentColor(swatch)} | 323 | <IconComponent className="size-5 shrink-0" aria-hidden /> |
| 416 | aria-label={`Use ${swatch} accent`} | 324 | <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"> |
| 417 | /> | 325 | {option.label} |
| 326 | </span> | ||
| 327 | {option.badgeLabel ? ( | ||
| 328 | <Badge variant="warning" size="sm"> | ||
| 329 | {option.badgeLabel} | ||
| 330 | </Badge> | ||
| 331 | ) : null} | ||
| 332 | </RadioPrimitive.Root> | ||
| 418 | ); | 333 | ); |
| 419 | })} | 334 | })} |
| 420 | </div> | 335 | {COMING_SOON_DRIVER_OPTIONS.map((option) => { |
| 421 | {accentColor ? ( | 336 | const IconComponent = option.icon; |
| 422 | <Button | 337 | return ( |
| 423 | type="button" | 338 | <RadioPrimitive.Root |
| 424 | size="sm" | 339 | key={option.value} |
| 425 | variant="ghost" | 340 | value={option.value} |
| 426 | className="h-7 px-2 text-xs text-muted-foreground" | 341 | disabled |
| 427 | onClick={() => setAccentColor("")} | 342 | className={cn( |
| 428 | > | 343 | "relative flex cursor-not-allowed items-center gap-3 rounded-lg border border-border bg-background px-3 py-3 text-left opacity-55 outline-none", |
| 429 | Clear | 344 | )} |
| 430 | </Button> | 345 | > |
| 431 | ) : null} | 346 | <IconComponent |
| 347 | className="size-5 shrink-0 text-muted-foreground" | ||
| 348 | aria-hidden | ||
| 349 | /> | ||
| 350 | <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"> | ||
| 351 | {option.label} | ||
| 352 | </span> | ||
| 353 | <Badge variant="warning" size="sm"> | ||
| 354 | Coming Soon | ||
| 355 | </Badge> | ||
| 356 | </RadioPrimitive.Root> | ||
| 357 | ); | ||
| 358 | })} | ||
| 359 | </RadioGroup> | ||
| 432 | </div> | 360 | </div> |
| 433 | <span className="text-[11px] text-muted-foreground"> | ||
| 434 | Optional marker shown in the picker. | ||
| 435 | </span> | ||
| 436 | </div> | ||
| 437 | 361 | ||
| 438 | {driverSettingsFields.length > 0 ? ( | 362 | <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> |
| 439 | <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}> | 363 | <span className="text-xs font-medium text-foreground">Label</span> |
| 440 | <ProviderSettingsForm | 364 | <Input |
| 441 | definition={driverOption} | 365 | className="bg-background" |
| 442 | value={configDraft} | 366 | placeholder="e.g. Work" |
| 443 | idPrefix={`add-provider-${driver}`} | 367 | value={label} |
| 444 | variant="dialog" | 368 | onChange={(event) => setLabel(event.target.value)} |
| 445 | onChange={setConfigDraft} | ||
| 446 | /> | 369 | /> |
| 370 | <span className="text-[11px] text-muted-foreground"> | ||
| 371 | Shown in the provider list. Optional. | ||
| 372 | </span> | ||
| 373 | </label> | ||
| 374 | |||
| 375 | <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> | ||
| 376 | <span className="text-xs font-medium text-foreground">Instance ID</span> | ||
| 377 | <Input | ||
| 378 | className="bg-background" | ||
| 379 | placeholder={`${driver}_work`} | ||
| 380 | value={instanceId} | ||
| 381 | onChange={(event) => { | ||
| 382 | setInstanceIdDirty(true); | ||
| 383 | setInstanceId(event.target.value); | ||
| 384 | }} | ||
| 385 | aria-invalid={showInstanceIdError} | ||
| 386 | /> | ||
| 387 | {showInstanceIdError ? ( | ||
| 388 | <span className="text-[11px] text-destructive">{instanceIdError}</span> | ||
| 389 | ) : ( | ||
| 390 | <span className="text-[11px] text-muted-foreground"> | ||
| 391 | Routing key used by threads and sessions. Letters, digits, '-', or '_'. | ||
| 392 | </span> | ||
| 393 | )} | ||
| 394 | </label> | ||
| 395 | |||
| 396 | <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> | ||
| 397 | <span className="text-xs font-medium text-foreground">Accent color</span> | ||
| 398 | <div className="flex min-w-0 flex-wrap items-center gap-2"> | ||
| 399 | <input | ||
| 400 | type="color" | ||
| 401 | value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]} | ||
| 402 | onChange={(event) => setAccentColor(event.target.value)} | ||
| 403 | aria-label="Provider instance accent color" | ||
| 404 | className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5" | ||
| 405 | /> | ||
| 406 | <div className="flex flex-wrap gap-1.5"> | ||
| 407 | {PROVIDER_ACCENT_SWATCHES.map((swatch) => { | ||
| 408 | const selected = accentColor.toLowerCase() === swatch; | ||
| 409 | return ( | ||
| 410 | <button | ||
| 411 | key={swatch} | ||
| 412 | type="button" | ||
| 413 | className={cn( | ||
| 414 | "size-6 cursor-pointer rounded-full border transition", | ||
| 415 | selected | ||
| 416 | ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background" | ||
| 417 | : "border-black/10 hover:scale-105 dark:border-white/20", | ||
| 418 | )} | ||
| 419 | style={{ backgroundColor: swatch }} | ||
| 420 | onClick={() => setAccentColor(swatch)} | ||
| 421 | aria-label={`Use ${swatch} accent`} | ||
| 422 | /> | ||
| 423 | ); | ||
| 424 | })} | ||
| 425 | </div> | ||
| 426 | {accentColor ? ( | ||
| 427 | <Button | ||
| 428 | type="button" | ||
| 429 | size="sm" | ||
| 430 | variant="ghost" | ||
| 431 | className="h-7 px-2 text-xs text-muted-foreground" | ||
| 432 | onClick={() => setAccentColor("")} | ||
| 433 | > | ||
| 434 | Clear | ||
| 435 | </Button> | ||
| 436 | ) : null} | ||
| 437 | </div> | ||
| 438 | <span className="text-[11px] text-muted-foreground"> | ||
| 439 | Optional marker shown in the picker. | ||
| 440 | </span> | ||
| 447 | </div> | 441 | </div> |
| 448 | ) : wizardStep === 2 ? ( | 442 | |
| 449 | <div className="grid gap-2"> | 443 | {driverSettingsFields.length > 0 ? ( |
| 450 | <p className="text-sm text-muted-foreground"> | 444 | <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}> |
| 451 | This driver has no required configuration. You can add the instance now. | 445 | <ProviderSettingsForm |
| 452 | </p> | 446 | definition={driverOption} |
| 453 | </div> | 447 | value={configDraft} |
| 454 | ) : null} | 448 | idPrefix={`add-provider-${driver}`} |
| 449 | variant="dialog" | ||
| 450 | onChange={setConfigDraft} | ||
| 451 | /> | ||
| 452 | </div> | ||
| 453 | ) : wizardStep === 2 ? ( | ||
| 454 | <div className="grid gap-2"> | ||
| 455 | <p className="text-sm text-muted-foreground"> | ||
| 456 | This driver has no required configuration. You can add the instance now. | ||
| 457 | </p> | ||
| 458 | </div> | ||
| 459 | ) : null} | ||
| 460 | </AnimatedHeight> | ||
| 455 | </div> | 461 | </div> |
| 456 | 462 | ||
| 457 | <DialogFooter className="border-t bg-background"> | 463 | <DialogFooter className="border-t bg-background"> |
apps/web/src/components/settings/ConnectionsSettings.tsx+2-66| ... | @@ -7,16 +7,7 @@ import { | ... | @@ -7,16 +7,7 @@ import { |
| 7 | TerminalIcon, | 7 | TerminalIcon, |
| 8 | TriangleAlertIcon, | 8 | TriangleAlertIcon, |
| 9 | } from "lucide-react"; | 9 | } from "lucide-react"; |
| 10 | import { | 10 | import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react"; |
| 11 | type ReactNode, | ||
| 12 | memo, | ||
| 13 | useCallback, | ||
| 14 | useEffect, | ||
| 15 | useLayoutEffect, | ||
| 16 | useMemo, | ||
| 17 | useRef, | ||
| 18 | useState, | ||
| 19 | } from "react"; | ||
| 20 | import { | 11 | import { |
| 21 | type AuthClientSession, | 12 | type AuthClientSession, |
| 22 | type AuthPairingLink, | 13 | type AuthPairingLink, |
| ... | @@ -68,6 +59,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; | ... | @@ -68,6 +59,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 68 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 59 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 69 | import { Button } from "../ui/button"; | 60 | import { Button } from "../ui/button"; |
| 70 | import { Group, GroupSeparator } from "../ui/group"; | 61 | import { Group, GroupSeparator } from "../ui/group"; |
| 62 | import { AnimatedHeight } from "../AnimatedHeight"; | ||
| 71 | import { | 63 | import { |
| 72 | Menu, | 64 | Menu, |
| 73 | MenuGroup, | 65 | MenuGroup, |
| ... | @@ -176,62 +168,6 @@ function ConnectionStatusDot({ | ... | @@ -176,62 +168,6 @@ function ConnectionStatusDot({ |
| 176 | ); | 168 | ); |
| 177 | } | 169 | } |
| 178 | 170 | ||
| 179 | function AnimatedHeight({ children }: { readonly children: ReactNode }) { | ||
| 180 | const contentRef = useRef<HTMLDivElement>(null); | ||
| 181 | const [height, setHeight] = useState<number | null>(null); | ||
| 182 | |||
| 183 | useLayoutEffect(() => { | ||
| 184 | const element = contentRef.current; | ||
| 185 | if (!element) return; | ||
| 186 | let firstFrameId: number | null = null; | ||
| 187 | let secondFrameId: number | null = null; | ||
| 188 | |||
| 189 | const updateHeight = () => { | ||
| 190 | const nextHeight = Math.ceil(element.scrollHeight || element.getBoundingClientRect().height); | ||
| 191 | setHeight((currentHeight) => (currentHeight === nextHeight ? currentHeight : nextHeight)); | ||
| 192 | }; | ||
| 193 | const cancelPendingFrames = () => { | ||
| 194 | if (firstFrameId !== null) { | ||
| 195 | window.cancelAnimationFrame(firstFrameId); | ||
| 196 | firstFrameId = null; | ||
| 197 | } | ||
| 198 | if (secondFrameId !== null) { | ||
| 199 | window.cancelAnimationFrame(secondFrameId); | ||
| 200 | secondFrameId = null; | ||
| 201 | } | ||
| 202 | }; | ||
| 203 | const updateHeightAfterPaint = () => { | ||
| 204 | cancelPendingFrames(); | ||
| 205 | updateHeight(); | ||
| 206 | firstFrameId = window.requestAnimationFrame(() => { | ||
| 207 | firstFrameId = null; | ||
| 208 | updateHeight(); | ||
| 209 | secondFrameId = window.requestAnimationFrame(() => { | ||
| 210 | secondFrameId = null; | ||
| 211 | updateHeight(); | ||
| 212 | }); | ||
| 213 | }); | ||
| 214 | }; | ||
| 215 | |||
| 216 | updateHeightAfterPaint(); | ||
| 217 | const resizeObserver = new ResizeObserver(updateHeightAfterPaint); | ||
| 218 | resizeObserver.observe(element); | ||
| 219 | return () => { | ||
| 220 | resizeObserver.disconnect(); | ||
| 221 | cancelPendingFrames(); | ||
| 222 | }; | ||
| 223 | }, []); | ||
| 224 | |||
| 225 | return ( | ||
| 226 | <div | ||
| 227 | className="overflow-hidden transition-[height] duration-200 ease-out motion-reduce:transition-none" | ||
| 228 | style={height === null ? undefined : { height }} | ||
| 229 | > | ||
| 230 | <div ref={contentRef}>{children}</div> | ||
| 231 | </div> | ||
| 232 | ); | ||
| 233 | } | ||
| 234 | |||
| 235 | function getSavedBackendStatusTooltip( | 171 | function getSavedBackendStatusTooltip( |
| 236 | runtime: SavedEnvironmentRuntimeState | null, | 172 | runtime: SavedEnvironmentRuntimeState | null, |
| 237 | record: SavedEnvironmentRecord, | 173 | record: SavedEnvironmentRecord, |
apps/web/src/components/settings/SettingsPanels.browser.tsx+9-5| ... | @@ -1081,9 +1081,9 @@ describe("SourceControlSettingsPanel discovery states", () => { | ... | @@ -1081,9 +1081,9 @@ describe("SourceControlSettingsPanel discovery states", () => { |
| 1081 | await expect.element(page.getByText("Version Control")).toBeInTheDocument(); | 1081 | await expect.element(page.getByText("Version Control")).toBeInTheDocument(); |
| 1082 | await expect.element(page.getByText("Source Control Providers")).toBeInTheDocument(); | 1082 | await expect.element(page.getByText("Source Control Providers")).toBeInTheDocument(); |
| 1083 | await expect | 1083 | await expect |
| 1084 | .element(page.getByRole("button", { name: "Scan source control tools" })) | 1084 | .element(page.getByRole("button", { name: "Rescan server environment" })) |
| 1085 | .toBeDisabled(); | 1085 | .toBeDisabled(); |
| 1086 | await expect.element(page.getByText("No source control tools found")).not.toBeInTheDocument(); | 1086 | await expect.element(page.getByText("Nothing detected yet")).not.toBeInTheDocument(); |
| 1087 | }); | 1087 | }); |
| 1088 | 1088 | ||
| 1089 | it("uses the shared empty state when discovery completes without tools", async () => { | 1089 | it("uses the shared empty state when discovery completes without tools", async () => { |
| ... | @@ -1098,9 +1098,13 @@ describe("SourceControlSettingsPanel discovery states", () => { | ... | @@ -1098,9 +1098,13 @@ describe("SourceControlSettingsPanel discovery states", () => { |
| 1098 | </AppAtomRegistryProvider>, | 1098 | </AppAtomRegistryProvider>, |
| 1099 | ); | 1099 | ); |
| 1100 | 1100 | ||
| 1101 | await expect.element(page.getByText("No source control tools found")).toBeInTheDocument(); | 1101 | await expect.element(page.getByText("Nothing detected yet")).toBeInTheDocument(); |
| 1102 | await expect | 1102 | await expect |
| 1103 | .element(page.getByText("Install a supported Git or pull request CLI, then scan again.")) | 1103 | .element( |
| 1104 | page.getByText( | ||
| 1105 | "Install Git on the server, add optional hosting integrations or credentials your workspace needs, then rescan.", | ||
| 1106 | ), | ||
| 1107 | ) | ||
| 1104 | .toBeInTheDocument(); | 1108 | .toBeInTheDocument(); |
| 1105 | await expect.element(page.getByRole("button", { name: "Scan" })).toBeInTheDocument(); | 1109 | await expect.element(page.getByRole("button", { name: "Scan" })).toBeInTheDocument(); |
| 1106 | }); | 1110 | }); |
| ... | @@ -1129,7 +1133,7 @@ describe("SourceControlSettingsPanel discovery states", () => { | ... | @@ -1129,7 +1133,7 @@ describe("SourceControlSettingsPanel discovery states", () => { |
| 1129 | ); | 1133 | ); |
| 1130 | 1134 | ||
| 1131 | await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument(); | 1135 | await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument(); |
| 1132 | await expect.element(page.getByText("No source control tools found")).not.toBeInTheDocument(); | 1136 | await expect.element(page.getByText("Nothing detected yet")).not.toBeInTheDocument(); |
| 1133 | }); | 1137 | }); |
| 1134 | 1138 | ||
| 1135 | it("does not rescan on remount while the discovery atom is fresh", async () => { | 1139 | it("does not rescan on remount while the discovery atom is fresh", async () => { |
apps/web/src/components/settings/SourceControlSettings.tsx+30-17| ... | @@ -69,17 +69,21 @@ function isProviderDiscoveryItem( | ... | @@ -69,17 +69,21 @@ function isProviderDiscoveryItem( |
| 69 | return "auth" in item; | 69 | return "auth" in item; |
| 70 | } | 70 | } |
| 71 | 71 | ||
| 72 | function isVcsNotReady(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): boolean { | ||
| 73 | return !isProviderDiscoveryItem(item) && !item.implemented; | ||
| 74 | } | ||
| 75 | |||
| 72 | function authPresentation(auth: SourceControlProviderAuth): { | 76 | function authPresentation(auth: SourceControlProviderAuth): { |
| 73 | readonly label: string; | 77 | readonly label: string; |
| 74 | readonly badge: "warning" | null; | 78 | readonly badge: "warning" | null; |
| 75 | } { | 79 | } { |
| 76 | if (auth.status === "authenticated") { | 80 | if (auth.status === "authenticated") { |
| 77 | return { label: "Signed in", badge: null }; | 81 | return { label: "Authenticated", badge: null }; |
| 78 | } | 82 | } |
| 79 | if (auth.status === "unauthenticated") { | 83 | if (auth.status === "unauthenticated") { |
| 80 | return { label: "Sign in", badge: "warning" }; | 84 | return { label: "Not authenticated", badge: "warning" }; |
| 81 | } | 85 | } |
| 82 | return { label: "Sign in", badge: null }; | 86 | return { label: "Status unknown", badge: null }; |
| 83 | } | 87 | } |
| 84 | 88 | ||
| 85 | function RedactedAccount(props: { readonly account: string | null }) { | 89 | function RedactedAccount(props: { readonly account: string | null }) { |
| ... | @@ -94,7 +98,7 @@ function RedactedAccount(props: { readonly account: string | null }) { | ... | @@ -94,7 +98,7 @@ function RedactedAccount(props: { readonly account: string | null }) { |
| 94 | } | 98 | } |
| 95 | 99 | ||
| 96 | function itemStatusDot(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): string { | 100 | function itemStatusDot(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): string { |
| 97 | if (!item.implemented) return "bg-muted-foreground/35"; | 101 | if (isVcsNotReady(item)) return "bg-muted-foreground/35"; |
| 98 | if (item.status !== "available") return "bg-warning"; | 102 | if (item.status !== "available") return "bg-warning"; |
| 99 | if (isProviderDiscoveryItem(item) && item.auth.status !== "authenticated") return "bg-warning"; | 103 | if (isProviderDiscoveryItem(item) && item.auth.status !== "authenticated") return "bg-warning"; |
| 100 | return "bg-success"; | 104 | return "bg-success"; |
| ... | @@ -137,12 +141,12 @@ function itemSummary({ | ... | @@ -137,12 +141,12 @@ function itemSummary({ |
| 137 | readonly auth: SourceControlProviderAuth | null; | 141 | readonly auth: SourceControlProviderAuth | null; |
| 138 | readonly authAccount: string | null; | 142 | readonly authAccount: string | null; |
| 139 | }) { | 143 | }) { |
| 140 | if (!item.implemented) { | 144 | if (isVcsNotReady(item)) { |
| 141 | return <span>Support for {item.label} is coming soon.</span>; | 145 | return <span>Support for {item.label} is coming soon.</span>; |
| 142 | } | 146 | } |
| 143 | 147 | ||
| 144 | if (item.status !== "available") { | 148 | if (item.status !== "available") { |
| 145 | return <span>Not found - {item.installHint}</span>; | 149 | return <span>Not available on this server — {item.installHint}</span>; |
| 146 | } | 150 | } |
| 147 | 151 | ||
| 148 | if (auth) { | 152 | if (auth) { |
| ... | @@ -165,11 +169,17 @@ function itemSummary({ | ... | @@ -165,11 +169,17 @@ function itemSummary({ |
| 165 | } | 169 | } |
| 166 | 170 | ||
| 167 | if (auth.status === "unauthenticated") { | 171 | if (auth.status === "unauthenticated") { |
| 168 | return <span>Sign in with the {item.executable} CLI to enable pull request actions.</span>; | 172 | return ( |
| 173 | <span> | ||
| 174 | {item.label} is not authenticated on this server. Sign in or configure credentials using | ||
| 175 | the <code className="rounded bg-muted px-1 py-px text-[11px]">{item.executable}</code>{" "} | ||
| 176 | tool on the server host to enable pull request features. | ||
| 177 | </span> | ||
| 178 | ); | ||
| 169 | } | 179 | } |
| 170 | return ( | 180 | return ( |
| 171 | <span> | 181 | <span> |
| 172 | Install and sign in with the {item.executable} CLI to enable pull request actions. | 182 | Could not verify {item.label}. {item.installHint} |
| 173 | </span> | 183 | </span> |
| 174 | ); | 184 | ); |
| 175 | } | 185 | } |
| ... | @@ -183,7 +193,8 @@ function DiscoveryItemRow({ | ... | @@ -183,7 +193,8 @@ function DiscoveryItemRow({ |
| 183 | readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; | 193 | readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; |
| 184 | }) { | 194 | }) { |
| 185 | const version = optionLabel(item.version); | 195 | const version = optionLabel(item.version); |
| 186 | const enabled = item.implemented && item.status === "available"; | 196 | const enabled = |
| 197 | item.status === "available" && (isProviderDiscoveryItem(item) || item.implemented); | ||
| 187 | const auth = isProviderDiscoveryItem(item) ? item.auth : null; | 198 | const auth = isProviderDiscoveryItem(item) ? item.auth : null; |
| 188 | const authStatus = auth ? authPresentation(auth) : null; | 199 | const authStatus = auth ? authPresentation(auth) : null; |
| 189 | const authAccount = auth ? optionLabel(auth.account) : null; | 200 | const authAccount = auth ? optionLabel(auth.account) : null; |
| ... | @@ -192,7 +203,7 @@ function DiscoveryItemRow({ | ... | @@ -192,7 +203,7 @@ function DiscoveryItemRow({ |
| 192 | <div | 203 | <div |
| 193 | className={cn( | 204 | className={cn( |
| 194 | "border-t border-border/60 first:border-t-0", | 205 | "border-t border-border/60 first:border-t-0", |
| 195 | !item.implemented && "opacity-80", | 206 | isVcsNotReady(item) && "opacity-80", |
| 196 | )} | 207 | )} |
| 197 | > | 208 | > |
| 198 | <div className="px-4 py-3.5 sm:px-5"> | 209 | <div className="px-4 py-3.5 sm:px-5"> |
| ... | @@ -204,7 +215,7 @@ function DiscoveryItemRow({ | ... | @@ -204,7 +215,7 @@ function DiscoveryItemRow({ |
| 204 | {item.label} | 215 | {item.label} |
| 205 | </h3> | 216 | </h3> |
| 206 | {version ? <code className="text-xs text-muted-foreground">{version}</code> : null} | 217 | {version ? <code className="text-xs text-muted-foreground">{version}</code> : null} |
| 207 | {!item.implemented ? ( | 218 | {isVcsNotReady(item) ? ( |
| 208 | <Badge variant="warning" size="sm"> | 219 | <Badge variant="warning" size="sm"> |
| 209 | Coming Soon | 220 | Coming Soon |
| 210 | </Badge> | 221 | </Badge> |
| ... | @@ -220,7 +231,7 @@ function DiscoveryItemRow({ | ... | @@ -220,7 +231,7 @@ function DiscoveryItemRow({ |
| 220 | </p> | 231 | </p> |
| 221 | </div> | 232 | </div> |
| 222 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | 233 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> |
| 223 | {item.implemented ? ( | 234 | {!isVcsNotReady(item) ? ( |
| 224 | <Switch checked={enabled} disabled aria-label={`${item.label} availability`} /> | 235 | <Switch checked={enabled} disabled aria-label={`${item.label} availability`} /> |
| 225 | ) : null} | 236 | ) : null} |
| 226 | </div> | 237 | </div> |
| ... | @@ -279,17 +290,19 @@ function EmptySourceControlDiscovery({ | ... | @@ -279,17 +290,19 @@ function EmptySourceControlDiscovery({ |
| 279 | const hasError = error !== null; | 290 | const hasError = error !== null; |
| 280 | 291 | ||
| 281 | return ( | 292 | return ( |
| 282 | <SettingsSection title="Detected tools"> | 293 | <SettingsSection title="Server environment"> |
| 283 | <Empty className="min-h-88"> | 294 | <Empty className="min-h-88"> |
| 284 | <EmptyMedia variant="icon"> | 295 | <EmptyMedia variant="icon"> |
| 285 | <GitPullRequestIcon /> | 296 | <GitPullRequestIcon /> |
| 286 | </EmptyMedia> | 297 | </EmptyMedia> |
| 287 | <EmptyHeader> | 298 | <EmptyHeader> |
| 288 | <EmptyTitle> | 299 | <EmptyTitle> |
| 289 | {hasError ? "Could not scan source control" : "No source control tools found"} | 300 | {hasError ? "Could not scan the server environment" : "Nothing detected yet"} |
| 290 | </EmptyTitle> | 301 | </EmptyTitle> |
| 291 | <EmptyDescription> | 302 | <EmptyDescription> |
| 292 | {hasError ? error : "Install a supported Git or pull request CLI, then scan again."} | 303 | {hasError |
| 304 | ? error | ||
| 305 | : "Install Git on the server, add optional hosting integrations or credentials your workspace needs, then rescan."} | ||
| 293 | </EmptyDescription> | 306 | </EmptyDescription> |
| 294 | </EmptyHeader> | 307 | </EmptyHeader> |
| 295 | <EmptyContent> | 308 | <EmptyContent> |
| ... | @@ -329,13 +342,13 @@ export function SourceControlSettingsPanel() { | ... | @@ -329,13 +342,13 @@ export function SourceControlSettingsPanel() { |
| 329 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | 342 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" |
| 330 | onClick={handleScan} | 343 | onClick={handleScan} |
| 331 | disabled={discovery.isPending} | 344 | disabled={discovery.isPending} |
| 332 | aria-label="Scan source control tools" | 345 | aria-label="Rescan server environment" |
| 333 | > | 346 | > |
| 334 | <RefreshCwIcon className={cn("size-3", discovery.isPending && "animate-spin")} /> | 347 | <RefreshCwIcon className={cn("size-3", discovery.isPending && "animate-spin")} /> |
| 335 | </Button> | 348 | </Button> |
| 336 | } | 349 | } |
| 337 | /> | 350 | /> |
| 338 | <TooltipPopup side="top">Scan source control tools</TooltipPopup> | 351 | <TooltipPopup side="top">Rescan Git and hosting integrations</TooltipPopup> |
| 339 | </Tooltip> | 352 | </Tooltip> |
| 340 | ); | 353 | ); |
| 341 | 354 |
apps/web/src/lib/sourceControlDiscoveryState.ts+71-24| ... | @@ -1,16 +1,17 @@ | ... | @@ -1,16 +1,17 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { | 2 | import { |
| 3 | EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, | 3 | type SourceControlDiscoveryTarget, |
| 4 | EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, | ||
| 5 | type SourceControlDiscoveryState, | 4 | type SourceControlDiscoveryState, |
| 6 | createSourceControlDiscoveryManager, | 5 | createSourceControlDiscoveryManager, |
| 7 | getSourceControlDiscoveryTargetKey, | 6 | getSourceControlDiscoveryTargetKey, |
| 8 | sourceControlDiscoveryStateAtom, | 7 | sourceControlDiscoveryStateAtom, |
| 9 | } from "@t3tools/client-runtime"; | 8 | } from "@t3tools/client-runtime"; |
| 10 | import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; | 9 | import { EnvironmentId, type SourceControlDiscoveryResult } from "@t3tools/contracts"; |
| 11 | import { Effect } from "effect"; | 10 | import { Effect } from "effect"; |
| 12 | import { Atom } from "effect/unstable/reactivity"; | 11 | import { Atom } from "effect/unstable/reactivity"; |
| 13 | 12 | ||
| 13 | import { readPrimaryEnvironmentDescriptor } from "../environments/primary"; | ||
| 14 | import { readEnvironmentConnection } from "../environments/runtime"; | ||
| 14 | import { readLocalApi } from "../localApi"; | 15 | import { readLocalApi } from "../localApi"; |
| 15 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 16 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 16 | 17 | ||
| ... | @@ -18,39 +19,85 @@ const SOURCE_CONTROL_DISCOVERY_TARGET = { key: "primary" } as const; | ... | @@ -18,39 +19,85 @@ const SOURCE_CONTROL_DISCOVERY_TARGET = { key: "primary" } as const; |
| 18 | const SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS = 30_000; | 19 | const SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS = 30_000; |
| 19 | const SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS = 5 * 60_000; | 20 | const SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS = 5 * 60_000; |
| 20 | 21 | ||
| 22 | interface SourceControlDiscoveryTargetInput { | ||
| 23 | readonly environmentId?: EnvironmentId | null; | ||
| 24 | } | ||
| 25 | |||
| 26 | function sourceControlDiscoveryTarget( | ||
| 27 | input?: SourceControlDiscoveryTargetInput, | ||
| 28 | ): SourceControlDiscoveryTarget { | ||
| 29 | const environmentId = input?.environmentId ?? null; | ||
| 30 | if (!environmentId) { | ||
| 31 | return SOURCE_CONTROL_DISCOVERY_TARGET; | ||
| 32 | } | ||
| 33 | return readPrimaryEnvironmentDescriptor()?.environmentId === environmentId | ||
| 34 | ? SOURCE_CONTROL_DISCOVERY_TARGET | ||
| 35 | : { key: environmentId }; | ||
| 36 | } | ||
| 37 | |||
| 21 | export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ | 38 | export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ |
| 22 | getRegistry: () => appAtomRegistry, | 39 | getRegistry: () => appAtomRegistry, |
| 23 | getClient: () => readLocalApi()?.server ?? null, | 40 | getClient: (key) => { |
| 41 | if (key === SOURCE_CONTROL_DISCOVERY_TARGET.key) { | ||
| 42 | const primaryEnvironmentId = readPrimaryEnvironmentDescriptor()?.environmentId ?? null; | ||
| 43 | const primaryConnection = primaryEnvironmentId | ||
| 44 | ? readEnvironmentConnection(primaryEnvironmentId) | ||
| 45 | : null; | ||
| 46 | if (primaryConnection) { | ||
| 47 | return primaryConnection.client.server; | ||
| 48 | } | ||
| 49 | try { | ||
| 50 | return readLocalApi()?.server ?? null; | ||
| 51 | } catch { | ||
| 52 | return null; | ||
| 53 | } | ||
| 54 | } | ||
| 55 | const environmentId = EnvironmentId.make(key); | ||
| 56 | const connection = readEnvironmentConnection(environmentId); | ||
| 57 | if (connection) { | ||
| 58 | return connection.client.server; | ||
| 59 | } | ||
| 60 | return null; | ||
| 61 | }, | ||
| 24 | }); | 62 | }); |
| 25 | 63 | ||
| 26 | const sourceControlDiscoveryAutoRefreshAtom = Atom.make(() => | 64 | const sourceControlDiscoveryAutoRefreshAtom = Atom.family((targetKey: string) => |
| 27 | Effect.promise(() => refreshSourceControlDiscovery()), | 65 | Atom.make(() => |
| 28 | ).pipe( | 66 | Effect.promise(() => sourceControlDiscoveryManager.refresh({ key: targetKey })), |
| 29 | Atom.swr({ | 67 | ).pipe( |
| 30 | staleTime: SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS, | 68 | Atom.swr({ |
| 31 | revalidateOnMount: true, | 69 | staleTime: SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS, |
| 32 | }), | 70 | revalidateOnMount: true, |
| 33 | Atom.setIdleTTL(SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS), | 71 | }), |
| 34 | Atom.withLabel("source-control-discovery:auto-refresh"), | 72 | Atom.setIdleTTL(SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS), |
| 73 | Atom.withLabel(`source-control-discovery:auto-refresh:${targetKey}`), | ||
| 74 | ), | ||
| 35 | ); | 75 | ); |
| 36 | 76 | ||
| 37 | export function refreshSourceControlDiscovery(): Promise<SourceControlDiscoveryResult | null> { | 77 | export function refreshSourceControlDiscovery( |
| 38 | return sourceControlDiscoveryManager.refresh(SOURCE_CONTROL_DISCOVERY_TARGET); | 78 | input?: SourceControlDiscoveryTargetInput, |
| 79 | ): Promise<SourceControlDiscoveryResult | null> { | ||
| 80 | return sourceControlDiscoveryManager.refresh(sourceControlDiscoveryTarget(input)); | ||
| 81 | } | ||
| 82 | |||
| 83 | export function getSourceControlDiscoverySnapshot( | ||
| 84 | input?: SourceControlDiscoveryTargetInput, | ||
| 85 | ): SourceControlDiscoveryState { | ||
| 86 | return sourceControlDiscoveryManager.getSnapshot(sourceControlDiscoveryTarget(input)); | ||
| 39 | } | 87 | } |
| 40 | 88 | ||
| 41 | export function resetSourceControlDiscoveryStateForTests(): void { | 89 | export function resetSourceControlDiscoveryStateForTests(): void { |
| 42 | sourceControlDiscoveryManager.reset(); | 90 | sourceControlDiscoveryManager.reset(); |
| 43 | } | 91 | } |
| 44 | 92 | ||
| 45 | export function useSourceControlDiscovery(): SourceControlDiscoveryState { | 93 | export function useSourceControlDiscovery( |
| 46 | const targetKey = getSourceControlDiscoveryTargetKey(SOURCE_CONTROL_DISCOVERY_TARGET); | 94 | input?: SourceControlDiscoveryTargetInput, |
| 95 | ): SourceControlDiscoveryState { | ||
| 96 | const targetKey = | ||
| 97 | getSourceControlDiscoveryTargetKey(sourceControlDiscoveryTarget(input)) ?? | ||
| 98 | SOURCE_CONTROL_DISCOVERY_TARGET.key; | ||
| 47 | 99 | ||
| 48 | useAtomValue(sourceControlDiscoveryAutoRefreshAtom); | 100 | useAtomValue(sourceControlDiscoveryAutoRefreshAtom(targetKey)); |
| 49 | 101 | ||
| 50 | const state = useAtomValue( | 102 | return useAtomValue(sourceControlDiscoveryStateAtom(targetKey)); |
| 51 | targetKey !== null | ||
| 52 | ? sourceControlDiscoveryStateAtom(targetKey) | ||
| 53 | : EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, | ||
| 54 | ); | ||
| 55 | return targetKey === null ? EMPTY_SOURCE_CONTROL_DISCOVERY_STATE : state; | ||
| 56 | } | 103 | } |
apps/web/src/pullRequestReference.test.ts+4| ... | @@ -57,6 +57,10 @@ describe("parsePullRequestReference", () => { | ... | @@ -57,6 +57,10 @@ describe("parsePullRequestReference", () => { |
| 57 | expect(parsePullRequestReference("az repos pr checkout --id 42")).toBe("42"); | 57 | expect(parsePullRequestReference("az repos pr checkout --id 42")).toBe("42"); |
| 58 | }); | 58 | }); |
| 59 | 59 | ||
| 60 | it("accepts az repos pr checkout commands with equals-style ids", () => { | ||
| 61 | expect(parsePullRequestReference("az repos pr checkout --id=42")).toBe("42"); | ||
| 62 | }); | ||
| 63 | |||
| 60 | it("accepts az repos pr checkout commands with extra flags", () => { | 64 | it("accepts az repos pr checkout commands with extra flags", () => { |
| 61 | expect(parsePullRequestReference("az repos pr checkout --id 42 --remote-name origin")).toBe( | 65 | expect(parsePullRequestReference("az repos pr checkout --id 42 --remote-name origin")).toBe( |
| 62 | "42", | 66 | "42", |
apps/web/src/pullRequestReference.ts+7-3| ... | @@ -11,9 +11,13 @@ const AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN = /^az\s+repos\s+pr\s+checkout\s+(.+) | ... | @@ -11,9 +11,13 @@ const AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN = /^az\s+repos\s+pr\s+checkout\s+(.+) |
| 11 | 11 | ||
| 12 | function parseAzureDevOpsCheckoutReference(args: string): string | null { | 12 | function parseAzureDevOpsCheckoutReference(args: string): string | null { |
| 13 | const parts = args.trim().split(/\s+/).filter(Boolean); | 13 | const parts = args.trim().split(/\s+/).filter(Boolean); |
| 14 | const idFlagIndex = parts.findIndex((part) => part === "--id" || part === "-i"); | 14 | for (const [index, part] of parts.entries()) { |
| 15 | if (idFlagIndex >= 0) { | 15 | if (part === "--id" || part === "-i") { |
| 16 | return parts[idFlagIndex + 1] ?? null; | 16 | return parts[index + 1] ?? null; |
| 17 | } | ||
| 18 | if (part.startsWith("--id=")) { | ||
| 19 | return part.slice("--id=".length) || null; | ||
| 20 | } | ||
| 17 | } | 21 | } |
| 18 | return parts.find((part) => !part.startsWith("-")) ?? null; | 22 | return parts.find((part) => !part.startsWith("-")) ?? null; |
| 19 | } | 23 | } |
docs/source-control-providers.md created+127| ... | @@ -0,0 +1,127 @@ | ||
| 1 | # Source Control Integrations | ||
| 2 | |||
| 3 | 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. | ||
| 4 | |||
| 5 | ## Supported Providers | ||
| 6 | |||
| 7 | T3 Code works with the platforms your team already uses: | ||
| 8 | |||
| 9 | - **GitHub** – Pull requests, repository creation, and clone integration | ||
| 10 | - **GitLab** – Merge requests, repository publishing, and hosted clones | ||
| 11 | - **Bitbucket** – Pull request workflows (via API token authentication) | ||
| 12 | - **Azure DevOps** – Pull request support for Microsoft-hosted repositories | ||
| 13 | |||
| 14 | ## What You Can Do | ||
| 15 | |||
| 16 | ### Start Projects from Anywhere | ||
| 17 | |||
| 18 | **Clone repositories directly** | ||
| 19 | |||
| 20 | - Open the Command Palette (`Cmd/Ctrl + K`) → **Add Project** | ||
| 21 | - Choose **GitHub repository**, **GitLab repository**, **Bitbucket repository**, **Azure DevOps repository**, or paste any **Git URL** | ||
| 22 | - Enter the repository path (`owner/repo`, `group/project`, `workspace/repository`, or `project/repository`) or a full Git URL, pick a destination, and start coding | ||
| 23 | |||
| 24 | **Publish local projects to the cloud** | ||
| 25 | |||
| 26 | - Have a local Git repository without a remote? | ||
| 27 | - 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 | ||
| 28 | - Perfect for turning a weekend prototype into a real project | ||
| 29 | |||
| 30 | ### Manage Code Reviews Without Context Switching | ||
| 31 | |||
| 32 | **Create pull requests while you work** | ||
| 33 | |||
| 34 | - Push a branch and create a pull request from the Git panel | ||
| 35 | - T3 Code can suggest titles and descriptions based on your commits | ||
| 36 | - Supports GitHub Pull Requests, GitLab Merge Requests, and Bitbucket Pull Requests | ||
| 37 | |||
| 38 | **Stay on top of open reviews** | ||
| 39 | |||
| 40 | - See if your current branch already has an open PR/MR | ||
| 41 | - Open the review directly in your browser with one click | ||
| 42 | - Check out a teammate's branch to review code locally | ||
| 43 | |||
| 44 | ### Know Your Setup at a Glance | ||
| 45 | |||
| 46 | The **Source Control settings** page shows you exactly what's connected: | ||
| 47 | |||
| 48 | - ✅ Which providers are authenticated and ready | ||
| 49 | - ⚠️ What's missing and how to fix it | ||
| 50 | - 👤 Which account is signed in (when available) | ||
| 51 | |||
| 52 | Run a quick **Rescan** after setting up a new machine or changing credentials. | ||
| 53 | |||
| 54 | ## Getting Started | ||
| 55 | |||
| 56 | ### For GitHub (Recommended for most users) | ||
| 57 | |||
| 58 | 1. Install the GitHub CLI on the machine running T3 Code: | ||
| 59 | ```bash | ||
| 60 | brew install gh | ||
| 61 | ``` | ||
| 62 | 2. Sign in: | ||
| 63 | ```bash | ||
| 64 | gh auth login | ||
| 65 | ``` | ||
| 66 | 3. Open **Settings → Source Control** in T3 Code and verify GitHub shows as authenticated | ||
| 67 | |||
| 68 | That's it—you can now clone, publish, and create pull requests. | ||
| 69 | |||
| 70 | ### For GitLab | ||
| 71 | |||
| 72 | 1. Install the GitLab CLI: | ||
| 73 | ```bash | ||
| 74 | brew install glab | ||
| 75 | ``` | ||
| 76 | 2. Authenticate: | ||
| 77 | ```bash | ||
| 78 | glab auth login | ||
| 79 | ``` | ||
| 80 | 3. Check **Settings → Source Control** to confirm the connection | ||
| 81 | |||
| 82 | ### For Bitbucket | ||
| 83 | |||
| 84 | Bitbucket uses API tokens instead of a CLI tool: | ||
| 85 | |||
| 86 | 1. Create an API token in your Atlassian account with read/write access to pull requests and repositories | ||
| 87 | 2. Add these environment variables to the environment running T3 Code: | ||
| 88 | ```bash | ||
| 89 | export T3CODE_BITBUCKET_EMAIL="you@example.com" | ||
| 90 | export T3CODE_BITBUCKET_API_TOKEN="your-token" | ||
| 91 | ``` | ||
| 92 | 3. Restart T3 Code and verify the connection in **Source Control settings** | ||
| 93 | |||
| 94 | ### For Azure DevOps | ||
| 95 | |||
| 96 | 1. Install Azure CLI: | ||
| 97 | ```bash | ||
| 98 | brew install azure-cli | ||
| 99 | ``` | ||
| 100 | 2. Add the DevOps extension: | ||
| 101 | ```bash | ||
| 102 | az extension add --name azure-devops | ||
| 103 | ``` | ||
| 104 | 3. Sign in: | ||
| 105 | ```bash | ||
| 106 | az login | ||
| 107 | ``` | ||
| 108 | |||
| 109 | --- | ||
| 110 | |||
| 111 | ## Requirements & Troubleshooting | ||
| 112 | |||
| 113 | **Git is required** – T3 Code uses Git for all local operations. Ensure `git` is installed on your server. | ||
| 114 | |||
| 115 | **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. | ||
| 116 | |||
| 117 | **Common issues:** | ||
| 118 | |||
| 119 | - **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 | ||
| 120 | - **Bitbucket not connecting** – Double-check your environment variables are set in the correct shell profile and the server was restarted | ||
| 121 | - **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) | ||
| 122 | |||
| 123 | **Need more help?** Check your provider's CLI documentation: | ||
| 124 | |||
| 125 | - [GitHub CLI](https://cli.github.com/) | ||
| 126 | - [GitLab CLI](https://gitlab.com/gitlab-org/cli) | ||
| 127 | - [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/) | ||
packages/contracts/src/sourceControl.ts+4-10| ... | @@ -121,31 +121,25 @@ export const SourceControlProviderAuth = Schema.Struct({ | ... | @@ -121,31 +121,25 @@ export const SourceControlProviderAuth = Schema.Struct({ |
| 121 | }); | 121 | }); |
| 122 | export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type; | 122 | export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type; |
| 123 | 123 | ||
| 124 | const SourceControlDiscoveryItemFields = { | 124 | const SourceControlDiscoverySharedFields = { |
| 125 | label: TrimmedNonEmptyString, | 125 | label: TrimmedNonEmptyString, |
| 126 | executable: Schema.optional(TrimmedNonEmptyString), | 126 | executable: Schema.optional(TrimmedNonEmptyString), |
| 127 | implemented: Schema.Boolean, | ||
| 128 | status: SourceControlDiscoveryStatus, | 127 | status: SourceControlDiscoveryStatus, |
| 129 | version: Schema.Option(TrimmedNonEmptyString), | 128 | version: Schema.Option(TrimmedNonEmptyString), |
| 130 | installHint: TrimmedNonEmptyString, | 129 | installHint: TrimmedNonEmptyString, |
| 131 | detail: Schema.Option(TrimmedNonEmptyString), | 130 | detail: Schema.Option(TrimmedNonEmptyString), |
| 132 | } as const; | 131 | } as const; |
| 133 | 132 | ||
| 134 | export const SourceControlDiscoveryItem = Schema.Struct({ | ||
| 135 | kind: Schema.String, | ||
| 136 | ...SourceControlDiscoveryItemFields, | ||
| 137 | }); | ||
| 138 | export type SourceControlDiscoveryItem = typeof SourceControlDiscoveryItem.Type; | ||
| 139 | |||
| 140 | export const VcsDiscoveryItem = Schema.Struct({ | 133 | export const VcsDiscoveryItem = Schema.Struct({ |
| 141 | kind: VcsDriverKind, | 134 | kind: VcsDriverKind, |
| 142 | ...SourceControlDiscoveryItemFields, | 135 | implemented: Schema.Boolean, |
| 136 | ...SourceControlDiscoverySharedFields, | ||
| 143 | }); | 137 | }); |
| 144 | export type VcsDiscoveryItem = typeof VcsDiscoveryItem.Type; | 138 | export type VcsDiscoveryItem = typeof VcsDiscoveryItem.Type; |
| 145 | 139 | ||
| 146 | export const SourceControlProviderDiscoveryItem = Schema.Struct({ | 140 | export const SourceControlProviderDiscoveryItem = Schema.Struct({ |
| 147 | kind: SourceControlProviderKind, | 141 | kind: SourceControlProviderKind, |
| 148 | ...SourceControlDiscoveryItemFields, | 142 | ...SourceControlDiscoverySharedFields, |
| 149 | auth: SourceControlProviderAuth, | 143 | auth: SourceControlProviderAuth, |
| 150 | }); | 144 | }); |
| 151 | export type SourceControlProviderDiscoveryItem = typeof SourceControlProviderDiscoveryItem.Type; | 145 | export type SourceControlProviderDiscoveryItem = typeof SourceControlProviderDiscoveryItem.Type; |
packages/shared/src/sourceControl.ts+1-3| ... | @@ -7,7 +7,7 @@ export interface ChangeRequestPresentation { | ... | @@ -7,7 +7,7 @@ export interface ChangeRequestPresentation { |
| 7 | readonly longName: string; | 7 | readonly longName: string; |
| 8 | readonly pluralLongName: string; | 8 | readonly pluralLongName: string; |
| 9 | readonly providerLongName: string; | 9 | readonly providerLongName: string; |
| 10 | readonly checkoutCommandExample: string; | 10 | readonly checkoutCommandExample?: string; |
| 11 | readonly urlExample: string; | 11 | readonly urlExample: string; |
| 12 | } | 12 | } |
| 13 | 13 | ||
| ... | @@ -61,7 +61,6 @@ const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { | ... | @@ -61,7 +61,6 @@ const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { |
| 61 | longName: "pull request", | 61 | longName: "pull request", |
| 62 | pluralLongName: "pull requests", | 62 | pluralLongName: "pull requests", |
| 63 | providerLongName: "Bitbucket pull request", | 63 | providerLongName: "Bitbucket pull request", |
| 64 | checkoutCommandExample: "bb pr checkout 123", | ||
| 65 | urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42", | 64 | urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42", |
| 66 | }; | 65 | }; |
| 67 | 66 | ||
| ... | @@ -72,7 +71,6 @@ const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { | ... | @@ -72,7 +71,6 @@ const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { |
| 72 | longName: "change request", | 71 | longName: "change request", |
| 73 | pluralLongName: "change requests", | 72 | pluralLongName: "change requests", |
| 74 | providerLongName: "change request", | 73 | providerLongName: "change request", |
| 75 | checkoutCommandExample: "123", | ||
| 76 | urlExample: "#42", | 74 | urlExample: "#42", |
| 77 | }; | 75 | }; |
| 78 | 76 |