authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-04 00:59:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-04 00:59:58-07:00
log91a03e074751e9dc732d0dddcd7b3a291caba34f
treefd02d124bb4147f225341e3dc37632adae288603
parentd501ebe8771679b7b8173475cd0c00589d704817
signature Signed by PGP key B5690EEEBB952194

feat(source-control): add Bitbucket & Azure Devops providers (#2473)

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 @@
1import { assert, it } from "@effect/vitest";1import { assert, describe, it, vi } from "@effect/vitest";
2import { Effect, Layer } from "effect";2import { Effect, Layer } from "effect";
3import { describe, vi } from "vitest";
43
5import { GitManager } from "./GitManager.ts";4import * as GitManager from "./GitManager.ts";
6import { GitWorkflowService, layer as GitWorkflowServiceLayer } from "./GitWorkflowService.ts";5import * as GitWorkflowService from "./GitWorkflowService.ts";
7import { GitVcsDriver } from "../vcs/GitVcsDriver.ts";6import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
8import { VcsDriverRegistry, type VcsDriverRegistryShape } from "../vcs/VcsDriverRegistry.ts";7import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
98
10function makeLayer(input: { readonly detect: VcsDriverRegistryShape["detect"] }) {9function 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}
2120
22describe("GitWorkflowService", () => {21describe("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" });
2726
28 assert.deepStrictEqual(status, {27 assert.deepStrictEqual(status, {
...@@ -48,7 +47,7 @@ describe("GitWorkflowService", () => {...@@ -48,7 +47,7 @@ describe("GitWorkflowService", () => {
4847
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" });
5352
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();
8483
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 );
10099
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", () => {
112111
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" });
117116
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";
105import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";105import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
106import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";106import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
107import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";107import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
108import type { VcsDriverShape } from "./vcs/VcsDriver.ts";108import * as VcsDriver from "./vcs/VcsDriver.ts";
109import {109import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
110 VcsStatusBroadcaster,110import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
111 type VcsStatusBroadcasterShape,111import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
112 layer as VcsStatusBroadcasterLayer,112import * as GitWorkflowService from "./git/GitWorkflowService.ts";
113} from "./vcs/VcsStatusBroadcaster.ts";113import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
114import {
115 VcsDriverRegistry,
116 type VcsDriverRegistryShape,
117 type VcsDriverHandle,
118} from "./vcs/VcsDriverRegistry.ts";
119import { layer as VcsProvisioningServiceLayer } from "./vcs/VcsProvisioningService.ts";
120import { layer as GitWorkflowServiceLayer } from "./git/GitWorkflowService.ts";
121import {
122 SourceControlRepositoryService,
123 type SourceControlRepositoryServiceShape,
124} from "./sourceControl/SourceControlRepositoryService.ts";
125import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";114import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
126import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";115import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
127116
...@@ -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?.vcsStatusBroadcaster496 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));
512501
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
25import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts";25import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts";
26import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts";26import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts";
27import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts";27import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts";
28import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
29import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
28import * as GitHubCli from "./sourceControl/GitHubCli.ts";30import * as GitHubCli from "./sourceControl/GitHubCli.ts";
29import * as GitLabCli from "./sourceControl/GitLabCli.ts";31import * as GitLabCli from "./sourceControl/GitLabCli.ts";
30import * as TextGeneration from "./textGeneration/TextGeneration.ts";32import * 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);
165167
166const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe(168const 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);
170175
...@@ -235,6 +240,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(...@@ -235,6 +240,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(
235const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(240const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
236 // Core Services241 // 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Option } from "effect";
4import { ChildProcessSpawner } from "effect/unstable/process";
5import type { VcsError } from "@t3tools/contracts";
6
7import * as VcsProcess from "../vcs/VcsProcess.ts";
8import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
9
10const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
11 exitCode: ChildProcessSpawner.ExitCode(0),
12 stdout,
13 stderr: "",
14 stdoutTruncated: false,
15 stderrTruncated: false,
16});
17
18const mockRun = vi.fn<VcsProcess.VcsProcessShape["run"]>();
19
20const supportLayer = Layer.mergeAll(
21 Layer.mock(VcsProcess.VcsProcess)({
22 run: mockRun,
23 }),
24 NodeServices.layer,
25);
26const layer = Layer.mergeAll(AzureDevOpsCli.layer.pipe(Layer.provide(supportLayer)), supportLayer);
27
28afterEach(() => {
29 mockRun.mockReset();
30});
31
32describe("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 @@
1import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect";
2import {
3 TrimmedNonEmptyString,
4 type SourceControlRepositoryVisibility,
5 type VcsError,
6} from "@t3tools/contracts";
7
8import * as VcsProcess from "../vcs/VcsProcess.ts";
9import * as AzureDevOpsPullRequests from "./azureDevOpsPullRequests.ts";
10import * as SourceControlProvider from "./SourceControlProvider.ts";
11
12const DEFAULT_TIMEOUT_MS = 30_000;
13
14export 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
27export interface AzureDevOpsRepositoryCloneUrls {
28 readonly nameWithOwner: string;
29 readonly url: string;
30 readonly sshUrl: string;
31}
32
33export 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
91export class AzureDevOpsCli extends Context.Service<AzureDevOpsCli, AzureDevOpsCliShape>()(
92 "t3/source-control/AzureDevOpsCli",
93) {}
94
95function 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
106function 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
154function 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
160function 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
173const 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
186function normalizeDefaultBranch(value: string | null | undefined): string | null {
187 const trimmed = value?.trim().replace(/^refs\/heads\//, "") ?? "";
188 return trimmed.length > 0 ? trimmed : null;
189}
190
191function 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
202function 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
216function 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
234export 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
429export const layer = Layer.effect(AzureDevOpsCli, make());
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts created+91
...@@ -0,0 +1,91 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Layer, Option } from "effect";
3
4import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
5import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts";
6
7function makeProvider(azure: Partial<AzureDevOpsCli.AzureDevOpsCliShape>) {
8 return AzureDevOpsSourceControlProvider.make().pipe(
9 Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)),
10 );
11}
12
13it.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
47it.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
76it.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 @@
1import { Effect, Layer } from "effect";
2import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
3
4import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
5import * as SourceControlProvider from "./SourceControlProvider.ts";
6import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
7
8function 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
20function 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
48export 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
60function 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
82export 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
144export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make());
apps/server/src/sourceControl/BitbucketApi.test.ts created+513
...@@ -0,0 +1,513 @@
1import { assert, it, vi } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { ConfigProvider, DateTime, Effect, FileSystem, Layer, Option } from "effect";
4import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
5
6import * as BitbucketApi from "./BitbucketApi.ts";
7import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
8import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
9import type * as VcsDriver from "../vcs/VcsDriver.ts";
10
11const 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
37const 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
49function 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
150it.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
184it.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
226it.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
256it.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
288it.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
310it.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
342it.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
384it.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
402it.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
454it.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 @@
1import { Config, Context, Effect, FileSystem, Layer, Option, Schema } from "effect";
2import {
3 TrimmedNonEmptyString,
4 type SourceControlProviderAuth,
5 type SourceControlRepositoryCloneUrls,
6 type SourceControlRepositoryVisibility,
7} from "@t3tools/contracts";
8import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
9import { sanitizeBranchFragment } from "@t3tools/shared/git";
10import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
11
12import * as BitbucketPullRequests from "./bitbucketPullRequests.ts";
13import * as SourceControlProvider from "./SourceControlProvider.ts";
14import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
15import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
16
17const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0";
18
19const 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
28export 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
42const 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
68const BitbucketUserSchema = Schema.Struct({
69 username: Schema.optional(TrimmedNonEmptyString),
70 display_name: Schema.optional(TrimmedNonEmptyString),
71 account_id: Schema.optional(TrimmedNonEmptyString),
72});
73
74export interface BitbucketRepositoryLocator {
75 readonly workspace: string;
76 readonly repoSlug: string;
77}
78
79export 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
132export class BitbucketApi extends Context.Service<BitbucketApi, BitbucketApiShape>()(
133 "t3/source-control/BitbucketApi",
134) {}
135
136function 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
141function 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
149function 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
157function 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
170function bitbucketQueryString(filters: ReadonlyArray<string>): string {
171 return filters.join(" AND ");
172}
173
174function bitbucketStateFilter(states: ReadonlyArray<string>): string {
175 return states.length === 1
176 ? `state = "${states[0]}"`
177 : `(${states.map((state) => `state = "${state}"`).join(" OR ")})`;
178}
179
180function 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
189function 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
204function 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
218function 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
233function shouldPreferSshRemote(originRemoteUrl: string | null): boolean {
234 const trimmed = originRemoteUrl?.trim() ?? "";
235 return trimmed.startsWith("git@") || trimmed.startsWith("ssh://");
236}
237
238function 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
247function 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
259function 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
268function repositoryOwnerName(repositoryName: string): string {
269 return repositoryName.split("/")[0]?.trim() || "bitbucket";
270}
271
272function 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
303function 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
311function isBitbucketApiError(cause: unknown): cause is BitbucketApiError {
312 return Schema.is(BitbucketApiError)(cause);
313}
314
315function 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
336export 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
703export const layer = Layer.effect(BitbucketApi, make());
apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts created+126
...@@ -0,0 +1,126 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Layer, Option } from "effect";
3
4import * as BitbucketApi from "./BitbucketApi.ts";
5import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
6
7function makeProvider(bitbucket: Partial<BitbucketApi.BitbucketApiShape>) {
8 return BitbucketSourceControlProvider.make().pipe(
9 Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)),
10 );
11}
12
13it.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
52it.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
78it.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
111it.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 @@
1import { Effect, Layer, Option } from "effect";
2import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
3
4import * as BitbucketApi from "./BitbucketApi.ts";
5import * as BitbucketPullRequests from "./bitbucketPullRequests.ts";
6import * as SourceControlProvider from "./SourceControlProvider.ts";
7import type * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
8
9function 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
21function 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
45export 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
113export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make());
114
115export 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 @@
1import { assert, it } from "@effect/vitest";1import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest";
2import { Effect, Layer } from "effect";2import { Effect, Layer } from "effect";
3import { ChildProcessSpawner } from "effect/unstable/process";3import { ChildProcessSpawner } from "effect/unstable/process";
4import { VcsProcessExitError, type VcsError } from "@t3tools/contracts";4import { VcsProcessExitError, type VcsError } from "@t3tools/contracts";
5import { afterEach, describe, expect, vi } from "vitest";
65
7import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts";6import * as VcsProcess from "../vcs/VcsProcess.ts";
8import * as GitHubCli from "./GitHubCli.ts";7import * as GitHubCli from "./GitHubCli.ts";
98
10const processOutput = (stdout: string): VcsProcessOutput => ({9const 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});
1716
18const mockRun = vi.fn<(input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>>();17const mockRun = vi.fn<VcsProcess.VcsProcessShape["run"]>();
1918
20const layer = GitHubCli.layer.pipe(19const 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";
88
9import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";9import * as VcsProcess from "../vcs/VcsProcess.ts";
10import {10import * as GitHubPullRequests from "./gitHubPullRequests.ts";
11 decodeGitHubPullRequestJson,
12 decodeGitHubPullRequestListJson,
13 formatGitHubJsonDecodeError,
14} from "./gitHubPullRequests.ts";
1511
16const DEFAULT_TIMEOUT_MS = 30_000;12const DEFAULT_TIMEOUT_MS = 30_000;
1713
...@@ -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>;
5248
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}
227223
228export const make = Effect.fn("makeGitHubCli")(function* () {224export const make = Effect.fn("makeGitHubCli")(function* () {
229 const process = yield* VcsProcess;225 const process = yield* VcsProcess.VcsProcess;
230226
231 const execute: GitHubCliShape["execute"] = (input) =>227 const execute: GitHubCliShape["execute"] = (input) =>
232 process228 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 === 0258 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";
2import { DateTime, Effect, Layer, Option } from "effect";2import { DateTime, Effect, Layer, Option } from "effect";
3import { ChildProcessSpawner } from "effect/unstable/process";3import { ChildProcessSpawner } from "effect/unstable/process";
44
5import { GitHubCli, type GitHubCliShape } from "./GitHubCli.ts";5import * as VcsProcess from "../vcs/VcsProcess.ts";
6import type { VcsProcessOutput } from "../vcs/VcsProcess.ts";6import * as GitHubCli from "./GitHubCli.ts";
7import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";7import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
88
9const processResult = (stdout: string): VcsProcessOutput => ({9const 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});
1616
17function makeProvider(github: Partial<GitHubCliShape>) {17function 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}
2022
21it.effect("maps GitHub PR summaries into provider-neutral change requests", () =>23it.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", (
127129
128it.effect("creates GitHub PRs through provider-neutral input names", () =>130it.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";
77
8import { GitHubCli, type GitHubCliError, type GitHubPullRequestSummary } from "./GitHubCli.ts";8import * as GitHubCli from "./GitHubCli.ts";
9import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts";9import * as GitHubPullRequests from "./gitHubPullRequests.ts";
10import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts";10import * as SourceControlProvider from "./SourceControlProvider.ts";
11import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
1112
12function providerError(operation: string, cause: GitHubCliError): SourceControlProviderError {13function 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}
2024
21function toChangeRequest(summary: GitHubPullRequestSummary): ChangeRequest {25function 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}
4246
47function 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
78export 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
43export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () {90export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () {
44 const github = yield* GitHubCli;91 const github = yield* GitHubCli.GitHubCli;
4592
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 github109 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 };
110158
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});
147195
148export const layer = Layer.effect(SourceControlProvider, make());196export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make());
apps/server/src/sourceControl/GitLabCli.test.ts+5-6
...@@ -1,25 +1,24 @@...@@ -1,25 +1,24 @@
1import { assert, it } from "@effect/vitest";1import { assert, it, afterEach, expect, vi } from "@effect/vitest";
2import { Effect, Layer } from "effect";2import { Effect, Layer } from "effect";
3import { ChildProcessSpawner } from "effect/unstable/process";3import { ChildProcessSpawner } from "effect/unstable/process";
4import { afterEach, expect, vi } from "vitest";
54
6import { VcsProcessExitError } from "@t3tools/contracts";5import { VcsProcessExitError } from "@t3tools/contracts";
76
8import { VcsProcess, type VcsProcessOutput, type VcsProcessShape } from "../vcs/VcsProcess.ts";7import * as VcsProcess from "../vcs/VcsProcess.ts";
9import * as GitLabCli from "./GitLabCli.ts";8import * as GitLabCli from "./GitLabCli.ts";
109
11const mockedRun = vi.fn<VcsProcessShape["run"]>();10const mockedRun = vi.fn<VcsProcess.VcsProcessShape["run"]>();
12const layer = it.layer(11const 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);
2120
22function processOutput(stdout: string): VcsProcessOutput {21function 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
22
3import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts";3import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts";
44
5import {5import * as VcsProcess from "../vcs/VcsProcess.ts";
6 decodeGitLabMergeRequestJson,6import * as GitLabMergeRequests from "./gitLabMergeRequests.ts";
7 decodeGitLabMergeRequestListJson,7import type * as SourceControlProvider from "./SourceControlProvider.ts";
8 formatGitLabJsonDecodeError,
9} from "./gitLabMergeRequests.ts";
10import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
11import type { SourceControlRefSelector } from "./SourceControlProvider.ts";
128
13const DEFAULT_TIMEOUT_MS = 30_000;9const DEFAULT_TIMEOUT_MS = 30_000;
1410
...@@ -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>;
5046
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 {
220216
221function sourceRefName(input: {217function 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}
227223
228function sourceProjectIdentifier(source: SourceControlRefSelector | undefined): string | null {224function 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}
231229
...@@ -252,7 +250,7 @@ function parseRepositoryPath(repository: string): {...@@ -252,7 +250,7 @@ function parseRepositoryPath(repository: string): {
252}250}
253251
254export const make = Effect.fn("makeGitLabCli")(function* () {252export const make = Effect.fn("makeGitLabCli")(function* () {
255 const process = yield* VcsProcess;253 const process = yield* VcsProcess.VcsProcess;
256254
257 const execute: GitLabCliShape["execute"] = (input) =>255 const execute: GitLabCliShape["execute"] = (input) =>
258 process256 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 === 0285 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 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import { Effect, Layer, Option } from "effect";2import { Effect, Layer, Option } from "effect";
33
4import { GitLabCli, type GitLabCliShape } from "./GitLabCli.ts";4import * as GitLabCli from "./GitLabCli.ts";
5import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";5import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
66
7function makeProvider(gitlab: Partial<GitLabCliShape>) {7function 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}
1012
11it.effect("maps GitLab MR summaries into provider-neutral change requests", () =>13it.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", () =
4850
49it.effect("lists GitLab MRs through provider-neutral input names", () =>51it.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", () =>
7476
75it.effect("creates GitLab MRs through provider-neutral input names", () =>77it.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 @@
1import { Effect, Layer, Option } from "effect";1import { Effect, Layer, Option } from "effect";
2import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";2import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
33
4import { GitLabCli, type GitLabCliError, type GitLabMergeRequestSummary } from "./GitLabCli.ts";4import * as GitLabCli from "./GitLabCli.ts";
5import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts";5import * as SourceControlProvider from "./SourceControlProvider.ts";
6import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
67
7function providerError(operation: string, cause: GitLabCliError): SourceControlProviderError {8function 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}
1519
16function toChangeRequest(summary: GitLabMergeRequestSummary): ChangeRequest {20function 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}
3741
38function sourceFromInput(input: {42function 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 }
4564
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}
5173
74export 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
52export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () {86export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () {
53 const gitlab = yield* GitLabCli;87 const gitlab = yield* GitLabCli.GitLabCli;
5488
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 gitlab93 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 gitlab113 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});
109143
110export const layer = Layer.effect(SourceControlProvider, make());144export 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";
5import { VcsProcessSpawnError } from "@t3tools/contracts";5import { VcsProcessSpawnError } from "@t3tools/contracts";
66
7import { ServerConfig } from "../config.ts";7import { ServerConfig } from "../config.ts";
8import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
8import * as VcsProcess from "../vcs/VcsProcess.ts";9import * as VcsProcess from "../vcs/VcsProcess.ts";
9import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts";10import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
11import * as BitbucketApi from "./BitbucketApi.ts";
12import * as GitHubCli from "./GitHubCli.ts";
13import * as GitLabCli from "./GitLabCli.ts";
14import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";
15import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
16
17const 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 );
1036
11const processOutput = (37const 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});
2450
25it.effect("reports implemented tools separately from locally available CLIs", () => {51it.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
42Logged in to github.com account juliusmarminge (keyring)63Logged in to github.com account juliusmarminge (keyring)
43- Active account: true64- Active account: true
44- Git operations protocol: ssh65- 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 );
63103
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;
67107
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});
123158
124it.effect("probes provider authentication without exposing token details", () => {159it.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
138Logged in to github.com account octocat (keyring)168Logged 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.com176 processOutput(`gitlab.com
147Logged in to gitlab.com as gitlab-user177Logged 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.org189 operation: input.operation,
160Logged in as bitbucket-user190 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 );
177217
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;
181221
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 @@
1import {1import {
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";
107
11import { ServerConfig } from "../config.ts";8import { ServerConfig } from "../config.ts";
12import * as VcsProcess from "../vcs/VcsProcess.ts";9import * as VcsProcess from "../vcs/VcsProcess.ts";
10import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
11import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
1312
14interface DiscoveryProbe {13interface 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};
2726
28type ProviderProbe = DiscoveryProbe & {
29 readonly kind: SourceControlProviderKind;
30 readonly authArgs?: ReadonlyArray<string>;
31 readonly parseAuth?: (input: AuthProbeInput) => SourceControlProviderAuth;
32};
33
34interface AuthProbeInput {
35 readonly stdout: string;
36 readonly stderr: string;
37 readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"];
38}
39
40interface DiscoveryProbeResult<Kind extends string> {27interface 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];
6956
70const 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
111function 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
119function 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
126function 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
131function 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
136function 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
141function 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
155function unknownAuth(detail?: string): SourceControlProviderAuth {
156 return providerAuth({ status: "unknown", detail });
157}
158
159function combinedAuthOutput(input: AuthProbeInput): string {
160 return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n");
161}
162
163function 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
171function firstSafeAuthLine(text: string): string | undefined {
172 return sanitizedAuthLines(text)[0];
173}
174
175function 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
181function 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
190function 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
217function 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
245function 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
267export interface SourceControlDiscoveryShape {57export 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;
28173
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 };
340133
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}
2323
24export 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
33export function normalizeSourceBranch(headSelector: string): string {
34 return parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim();
35}
36
37export function sourceBranch(input: {
38 readonly headSelector: string;
39 readonly source?: SourceControlRefSelector;
40}): string {
41 return input.source?.refName ?? normalizeSourceBranch(input.headSelector);
42}
43
44export function sourceControlRefFromInput(input: {
45 readonly headSelector: string;
46 readonly source?: SourceControlRefSelector;
47}): SourceControlRefSelector | undefined {
48 return input.source ?? parseSourceControlOwnerRef(input.headSelector);
49}
50
24export interface SourceControlProviderShape {51export 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 @@
1import type {
2 SourceControlProviderAuth,
3 SourceControlProviderDiscoveryItem,
4 SourceControlProviderKind,
5} from "@t3tools/contracts";
6import { Effect, Option } from "effect";
7
8import type * as VcsProcess from "../vcs/VcsProcess.ts";
9
10export interface SourceControlAuthProbeInput {
11 readonly stdout: string;
12 readonly stderr: string;
13 readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"];
14}
15
16interface SourceControlDiscoverySpecBase {
17 readonly kind: SourceControlProviderKind;
18 readonly label: string;
19 readonly installHint: string;
20}
21
22export 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
30export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & {
31 readonly type: "api";
32 readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;
33};
34
35export type SourceControlProviderDiscoverySpec =
36 | SourceControlCliDiscoverySpec
37 | SourceControlApiDiscoverySpec;
38
39interface 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
49export 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
57export 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
64function 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
69function 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
74function 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
79export 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
93export function unknownAuth(detail?: string): SourceControlProviderAuth {
94 return providerAuth({ status: "unknown", detail });
95}
96
97export function combinedAuthOutput(input: SourceControlAuthProbeInput): string {
98 return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n");
99}
100
101function 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
109export function firstSafeAuthLine(text: string): string | undefined {
110 return sanitizedAuthLines(text)[0];
111}
112
113export 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
119export 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
128function 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
172export 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 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
2import { DateTime, Effect, Layer, Option } from "effect";3import { DateTime, Effect, Layer, Option } from "effect";
34
4import { GitHubCli } from "./GitHubCli.ts";5import { ServerConfig } from "../config.ts";
5import { GitLabCli } from "./GitLabCli.ts";6import type * as VcsDriver from "../vcs/VcsDriver.ts";
7import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
8import * as VcsProcess from "../vcs/VcsProcess.ts";
9import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
10import * as BitbucketApi from "./BitbucketApi.ts";
11import * as GitHubCli from "./GitHubCli.ts";
12import * as GitLabCli from "./GitLabCli.ts";
6import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";13import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
7import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";
8import type { VcsDriverShape } from "../vcs/VcsDriver.ts";
914
10const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");15const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
1116
...@@ -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>;
3338
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 });
5257
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);
95110
111it.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
123it.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
96it.effect("falls back to a non-origin remote when origin is not configured", () =>135it.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 @@
1import { Cache, Context, Duration, Effect, Exit, Layer } from "effect";1import { Cache, Context, Duration, Effect, Exit, Layer } from "effect";
2import { SourceControlProviderError } from "@t3tools/contracts";2import {
3 SourceControlProviderError,
4 type SourceControlProviderDiscoveryItem,
5} from "@t3tools/contracts";
3import type { SourceControlProviderKind } from "@t3tools/contracts";6import type { SourceControlProviderKind } from "@t3tools/contracts";
4import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";7import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
58
6import {9import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts";
7 SourceControlProvider,10import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
8 type SourceControlProviderContext,
9 type SourceControlProviderShape,
10} from "./SourceControlProvider.ts";
11import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";11import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
12import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";12import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
13import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";13import * as SourceControlProvider from "./SourceControlProvider.ts";
14import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
15import { ServerConfig } from "../config.ts";
16import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
17import * as VcsProcess from "../vcs/VcsProcess.ts";
1418
15const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048;19const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048;
16const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5);20const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5);
1721
18export interface SourceControlProviderRegistration {22export 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}
2227
23export interface SourceControlProviderHandle {28export interface SourceControlProviderHandle {
24 readonly provider: SourceControlProviderShape;29 readonly provider: SourceControlProvider.SourceControlProviderShape;
25 readonly context: SourceControlProviderContext | null;30 readonly context: SourceControlProvider.SourceControlProviderContext | null;
26}31}
2732
28export interface SourceControlProviderRegistryShape {33export 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}
3945
40export class SourceControlProviderRegistry extends Context.Service<46export 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 SourceControlProviderRegistryShape48 SourceControlProviderRegistryShape
43>()("t3/source-control/SourceControlProviderRegistry") {}49>()("t3/source-control/SourceControlProviderRegistry") {}
4450
45function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProviderShape {51function 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 );
5462
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 = remotes90 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);
94102
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}
102110
111function 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
103export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")(155export 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);
109165
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
125181
126 const providerContextCache = yield* Cache.makeWith<182 const providerContextCache = yield* Cache.makeWith<
127 string,183 string,
128 SourceControlProviderContext | null,184 SourceControlProvider.SourceControlProviderContext | null,
129 SourceControlProviderError185 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
154export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () {221export 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";
6import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts";6import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts";
77
8import { ServerConfig } from "../config.ts";8import { ServerConfig } from "../config.ts";
9import {9import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
10 GitVcsDriver,10import type * as SourceControlProvider from "./SourceControlProvider.ts";
11 type ExecuteGitResult,11import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
12 type GitVcsDriverShape,12import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts";
13} from "../vcs/GitVcsDriver.ts";
14import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts";
15import type { SourceControlProviderShape } from "./SourceControlProvider.ts";
16import { SourceControlRepositoryService, layer } from "./SourceControlRepositoryService.ts";
1713
18const CLONE_URLS = {14const 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};
2319
24function makeProvider(20function 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}
4541
46function processOutput(): ExecuteGitResult {42function 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}
5551
56function makeLayer(input: {52function 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 });
9490
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> }> = [];
116112
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 });
168164
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 }> = [];
223219
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", () => {
258it.effect("publish succeeds with status remote_added when the local repo has no commits", () => {254it.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 @@
1import OS from "node:os";1import * as NodeOS from "node:os";
2import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect";2import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect";
33
4import {4import {
...@@ -15,8 +15,8 @@ import {...@@ -15,8 +15,8 @@ import {
15} from "@t3tools/contracts";15} from "@t3tools/contracts";
1616
17import { ServerConfig } from "../config.ts";17import { ServerConfig } from "../config.ts";
18import { GitVcsDriver } from "../vcs/GitVcsDriver.ts";18import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
19import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts";19import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
2020
21export interface SourceControlRepositoryServiceShape {21export interface SourceControlRepositoryServiceShape {
22 readonly lookupRepository: (22 readonly lookupRepository: (
...@@ -102,10 +102,10 @@ function selectRemoteUrl(...@@ -102,10 +102,10 @@ function selectRemoteUrl(
102102
103function expandHomePath(input: string, path: Path.Path): string {103function 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 {
113export const make = Effect.fn("makeSourceControlRepositoryService")(function* () {113export 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;
119119
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 @@
1import { Cause, DateTime, Exit, Option, Result, Schema } from "effect";
2import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
3import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
4
5export 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
15const 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
35function trimOptionalString(value: string | null | undefined): string | null {
36 const trimmed = value?.trim() ?? "";
37 return trimmed.length > 0 ? trimmed : null;
38}
39
40function normalizeRefName(refName: string): string {
41 return refName.trim().replace(/^refs\/heads\//, "");
42}
43
44function 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
55function 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
71const decodeAzureDevOpsPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
72const decodeAzureDevOpsPullRequest = decodeJsonResult(AzureDevOpsPullRequestSchema);
73const decodeAzureDevOpsPullRequestEntry = Schema.decodeUnknownExit(AzureDevOpsPullRequestSchema);
74
75export const formatAzureDevOpsJsonDecodeError = formatSchemaError;
76
77export 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
98export 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 @@
1import { DateTime, Option, Schema } from "effect";
2import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
3
4export 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
17export 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
28export const BitbucketPullRequestBranchSchema = Schema.Struct({
29 repository: Schema.optional(Schema.NullOr(BitbucketRepositoryRefSchema)),
30 branch: Schema.Struct({
31 name: TrimmedNonEmptyString,
32 }),
33});
34
35export 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
49export const BitbucketPullRequestListSchema = Schema.Struct({
50 values: Schema.Array(BitbucketPullRequestSchema),
51 next: Schema.optional(TrimmedNonEmptyString),
52});
53
54function trimOptionalString(value: string | null | undefined): string | null {
55 const trimmed = value?.trim() ?? "";
56 return trimmed.length > 0 ? trimmed : null;
57}
58
59function 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
66function 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
79export 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";
21import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts";21import * as GitVcsDriverCore from "./GitVcsDriverCore.ts";
22import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts";22import * as VcsDriver from "./VcsDriver.ts";
23import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts";23import * as VcsProcess from "./VcsProcess.ts";
2424
25export interface ExecuteGitInput {25export interface ExecuteGitInput {
26 readonly operation: string;26 readonly operation: string;
...@@ -304,7 +304,7 @@ function parseGitRemoteVerboseOutput(...@@ -304,7 +304,7 @@ function parseGitRemoteVerboseOutput(
304}304}
305305
306const gitCommand = (306const 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 });
336336
337export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () {337export 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 };
347347
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"));
360360
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 });
372372
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 }
378379
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)));
389390
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 );
398398
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 );
440440
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 }
457448
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 });
472458
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 });
478473
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 );
484480
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 }
487487
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 }
512490
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 }
517515
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 }
521520
522 return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath));521 if (ignoredPaths.size === 0) {
523 },522 return relativePaths;
524 );523 }
525524
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);
531533
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});
543545
544export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () {546export 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});
548550
549export const make = Effect.fn("makeGitVcsDriverService")(function* () {551export 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});
553555
554export const vcsLayer = Layer.effect(VcsDriver, makeVcsDriver());556export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver());
555export const layer = Layer.effect(GitVcsDriver, make());557export const layer = Layer.effect(GitVcsDriver, make());
apps/server/src/vcs/GitVcsDriverCore.test.ts+1-2
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";2import { assert, it, describe } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Path, PlatformError, Scope } from "effect";3import { Effect, FileSystem, Layer, Path, PlatformError, Scope } from "effect";
4import { describe } from "vitest";
54
6import { GitCommandError } from "@t3tools/contracts";5import { GitCommandError } from "@t3tools/contracts";
7import { ServerConfig } from "../config.ts";6import { 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";
22import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";22import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";
23import { compactTraceAttributes } from "../observability/Attributes.ts";23import { compactTraceAttributes } from "../observability/Attributes.ts";
24import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";24import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";
25import type {25import * as GitVcsDriver from "./GitVcsDriver.ts";
26 ExecuteGitProgress,
27 GitCommitOptions,
28 GitVcsDriverShape,
29 GitStatusDetails,
30 ExecuteGitInput,
31 ExecuteGitResult,
32} from "./GitVcsDriver.ts";
33import {26import {
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);
51const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;44const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
52const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;45const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
53const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;46const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;
54const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitStatusDetails>({47const 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}
8780
88function parseBranchAb(value: string): { ahead: number; behind: number } {81function 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}
333326
334function toGitCommandError(327function 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 {
377const Trace2Record = Schema.Record(Schema.String, Schema.Unknown);370const Trace2Record = Schema.Record(Schema.String, Schema.Unknown);
378371
379const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* (372const 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});
537530
538const collectOutput = Effect.fn("collectOutput")(function* <E>(531const 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});
611604
612export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* (options?: {605export 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;
618611
619 let executeRaw: GitVcsDriverShape["execute"];612 let executeRaw: GitVcsDriver.GitVcsDriverShape["execute"];
620613
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 });
703696
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 }
724717
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 });
10251018
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 });
13241317
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 });
13301323
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 );
13401333
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 );
13571350
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 });
13991392
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 });
14321425
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 }
14451456
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 }
14621466
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 }
14721480
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 }
15231492
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 }
15401517
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 );
15501534
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()));
15871581
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 );
15961589
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 );
16321626
1633 return {1627 return {
1634 commitSummary,1628 commitSummary,
1635 diffSummary,1629 diffSummary,
1636 diffPatch,1630 diffPatch,
1637 };1631 };
1638 },1632 });
1639 );
16401633
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 );
16461639
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,
16891652 },
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 );
17131664
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 );
17371683
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 );
17651707
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 }
17781731
1779 const defaultBranch =1732 const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =
1780 defaultRef.exitCode === 01733 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 );
17831759
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 });
18241772
1825 const remoteBranches =1773 const defaultBranch =
1826 remoteBranchResult.exitCode === 01774 defaultRef.exitCode === 0
1827 ? remoteBranchResult.stdout1775 ? 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 });
18681796
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 });
18771818
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.newRefName1825 .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: {
18871828 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 });
18911862
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 );
19001872
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 });
19201895
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 localBranchAlreadyExists1902 [
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 );
19421914
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 });
19541936
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 ]);
19621955
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 });
19861979
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 );
20072000
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" },
20322025 );
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;
20502026
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;
20642044
2065 const checkoutArgs = localInputExists2045 const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName);
2066 ? ["checkout", input.refName]2046 const localTrackedBranchTargetExists =
2067 : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists2047 remoteExists && localTrackedBranchCandidate
2068 ? ["checkout", input.refName]2048 ? yield* executeGit(
2069 : remoteExists && !localTrackingBranch2049 "GitVcsDriver.switchRef.localTrackedBranchTargetExists",
2070 ? ["checkout", "--track", input.refName]2050 input.cwd,
2071 : remoteExists && localTrackingBranch2051 ["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;
20742058
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 });
20792073
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));
20842078
2085 return { refName };2079 return { refName };
2086 });2080 },
2081 );
20872082
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 }
20962092
2097 return { refName: input.refName };2093 return { refName: input.refName };
2098 });2094 },
2095 );
20992096
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);
21052102
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 );
21202117
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";
11import type { VcsProcessInput, VcsProcessOutput } from "./VcsProcess.ts";11import * as VcsProcess from "./VcsProcess.ts";
1212
13export interface VcsDriverShape {13export 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 @@
1import { assert, it } from "@effect/vitest";1import { assert, it, describe } from "@effect/vitest";
2import { Effect, Layer } from "effect";2import { Effect, Layer } from "effect";
3import { ChildProcessSpawner } from "effect/unstable/process";3import { ChildProcessSpawner } from "effect/unstable/process";
4import { describe } from "vitest";
54
6import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "./VcsProcess.ts";5import * as VcsProcess from "./VcsProcess.ts";
7import { VcsProjectConfig } from "./VcsProjectConfig.ts";6import * as VcsProjectConfig from "./VcsProjectConfig.ts";
8import { VcsDriverRegistry, make as makeVcsDriverRegistry } from "./VcsDriverRegistry.ts";7import * as VcsDriverRegistry from "./VcsDriverRegistry.ts";
98
10const processOutput = (stdout: string): VcsProcessOutput => ({9const 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 => ({
1716
18describe("VcsDriverRegistry", () => {17describe("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 );
3231
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");
3635
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 });
4039
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 );
6968
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" });
7473
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
4import { VcsUnsupportedOperationError } from "@t3tools/contracts";4import { VcsUnsupportedOperationError } from "@t3tools/contracts";
5import * as GitVcsDriver from "./GitVcsDriver.ts";5import * as GitVcsDriver from "./GitVcsDriver.ts";
6import * as VcsProjectConfig from "./VcsProjectConfig.ts";6import * as VcsProjectConfig from "./VcsProjectConfig.ts";
7import type { VcsDriverShape } from "./VcsDriver.ts";7import * as VcsDriver from "./VcsDriver.ts";
88
9const DETECTION_CACHE_CAPACITY = 2_048;9const DETECTION_CACHE_CAPACITY = 2_048;
10const DETECTION_CACHE_TTL = Duration.seconds(2);10const DETECTION_CACHE_TTL = Duration.seconds(2);
...@@ -17,11 +17,11 @@ export interface VcsDriverResolveInput {...@@ -17,11 +17,11 @@ export interface VcsDriverResolveInput {
17export interface VcsDriverHandle {17export 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}
2222
23export interface VcsDriverRegistryShape {23export 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): {
66export const make = Effect.fn("makeVcsDriverRegistry")(function* () {66export 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 };
7272
...@@ -82,7 +82,7 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () {...@@ -82,7 +82,7 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () {
8282
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 @@
1import { assert, it } from "@effect/vitest";1import { assert, it, describe } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { Effect, FileSystem, Layer, Path } from "effect";3import { Effect, FileSystem, Layer, Path } from "effect";
4import { describe } from "vitest";
54
6import { VcsProjectConfig, layer as VcsProjectConfigLayer } from "./VcsProjectConfig.ts";5import * as VcsProjectConfig from "./VcsProjectConfig.ts";
76
8const TestLayer = VcsProjectConfigLayer.pipe(7const 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 );
4443
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 });
4746
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 });
6261
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";
2import { DateTime, Effect, Layer, Option } from "effect";2import { DateTime, Effect, Layer, Option } from "effect";
3import { ChildProcessSpawner } from "effect/unstable/process";3import { ChildProcessSpawner } from "effect/unstable/process";
44
5import type { VcsDriverShape } from "./VcsDriver.ts";5import * as VcsDriver from "./VcsDriver.ts";
6import { VcsDriverRegistry } from "./VcsDriverRegistry.ts";6import * as VcsDriverRegistry from "./VcsDriverRegistry.ts";
7import { VcsProvisioningService, layer } from "./VcsProvisioningService.ts";7import * as VcsProvisioningService from "./VcsProvisioningService.ts";
88
9const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");9const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
1010
11function makeDriver(calls: string[]): VcsDriverShape {11function 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 {
58it.effect("routes repository initialization through an explicit VCS driver kind", () => {58it.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 );
6868
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" });
7272
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"
77it.effect("defaults repository initialization to Git until callers choose a VCS kind", () => {77it.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 );
8787
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" });
9191
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";
9import { VcsDriverRegistry } from "./VcsDriverRegistry.ts";9import * as VcsDriverRegistry from "./VcsDriverRegistry.ts";
1010
11export interface VcsProvisioningServiceShape {11export 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}
3737
38export const make = Effect.fn("makeVcsProvisioningService")(function* () {38export const make = Effect.fn("makeVcsProvisioningService")(function* () {
39 const registry = yield* VcsDriverRegistry;39 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
4040
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 @@
1import { assert, it } from "@effect/vitest";1import { assert, it, describe } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { Deferred, Effect, Exit, FileSystem, Layer, Option, Path, Scope, Stream } from "effect";3import { Deferred, Effect, Exit, FileSystem, Layer, Option, Path, Scope, Stream } from "effect";
4import type {4import 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";
10import { describe } from "vitest";
1110
12import {11import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts";
13 VcsStatusBroadcaster,12import * as GitWorkflowService from "../git/GitWorkflowService.ts";
14 layer as VcsStatusBroadcasterLayer,
15} from "./VcsStatusBroadcaster.ts";
16import { GitWorkflowService, type GitWorkflowServiceShape } from "../git/GitWorkflowService.ts";
1713
18const baseLocalStatus: VcsStatusLocalResult = {14const 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 };
8884
89 return Effect.gen(function* () {85 return Effect.gen(function* () {
90 const broadcaster = yield* VcsStatusBroadcaster;86 const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
9187
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 };
113109
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" });
117113
118 state.currentLocalStatus = {114 state.currentLocalStatus = {
...@@ -153,7 +149,7 @@ describe("VcsStatusBroadcaster", () => {...@@ -153,7 +149,7 @@ describe("VcsStatusBroadcaster", () => {
153 };149 };
154150
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" });
158154
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 );
217213
...@@ -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);
230226
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 });
234230
...@@ -249,7 +245,7 @@ describe("VcsStatusBroadcaster", () => {...@@ -249,7 +245,7 @@ describe("VcsStatusBroadcaster", () => {
249 };245 };
250246
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 );
327323
...@@ -331,7 +327,7 @@ describe("VcsStatusBroadcaster", () => {...@@ -331,7 +327,7 @@ describe("VcsStatusBroadcaster", () => {
331 remoteInterruptedDeferred = remoteInterrupted;327 remoteInterruptedDeferred = remoteInterrupted;
332 remoteStartedDeferred = remoteStarted;328 remoteStartedDeferred = remoteStarted;
333329
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 @@
1import { realpathSync } from "node:fs";1import { realpathSync } from "node:fs";
22
3import {3import {
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";
23import { Context } from "effect";
24import { mergeGitStatusParts } from "@t3tools/shared/git";24import { mergeGitStatusParts } from "@t3tools/shared/git";
2525
26import { GitWorkflowService } from "../git/GitWorkflowService.ts";26import * as GitWorkflowService from "../git/GitWorkflowService.ts";
2727
28const VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30);28const VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30);
2929
...@@ -80,7 +80,7 @@ function normalizeCwd(cwd: string): string {...@@ -80,7 +80,7 @@ function normalizeCwd(cwd: string): string {
80export const layer = Layer.effect(80export 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 @@
1import { assert, it } from "@effect/vitest";1import { assert, it, describe } from "@effect/vitest";
2import { DateTime, Option } from "effect";2import {
3import { Effect, FileSystem, Layer, Path, type PlatformError, type Scope } from "effect";3 Effect,
4import { describe } from "vitest";4 FileSystem,
5 Layer,
6 Path,
7 type PlatformError,
8 type Scope,
9 DateTime,
10 Option,
11} from "effect";
512
6import type { VcsDriverKind } from "@t3tools/contracts";13import type { VcsDriverKind } from "@t3tools/contracts";
7import { VcsDriver } from "../VcsDriver.ts";14import * as VcsDriver from "../VcsDriver.ts";
815
9export interface VcsDriverFixture<R, E> {16export 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> {
24export interface VcsDriverContractSuiteInput<R, E> {31export 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}
3041
...@@ -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;
4657
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;
5667
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;
8192
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;
105116
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;
126137
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;
144155
145 yield* input.fixture.createRepo(cwd);156 yield* input.fixture.createRepo(cwd);
146157
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";
56import { ServerAuth } from "./auth/Services/ServerAuth.ts";56import { ServerAuth } from "./auth/Services/ServerAuth.ts";
57import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts";57import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts";
58import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts";58import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts";
59import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
60import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
61import * as GitHubCli from "./sourceControl/GitHubCli.ts";
62import * as GitLabCli from "./sourceControl/GitLabCli.ts";
63import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
64import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
65import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
66import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
59import * as VcsProcess from "./vcs/VcsProcess.ts";67import * as VcsProcess from "./vcs/VcsProcess.ts";
60import {68import {
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
3import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
4
5export 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 {
22import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";22import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
23import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model";23import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model";
24import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";24import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
25import { Option } from "effect";
25import { HttpResponse, http, ws } from "msw";26import { HttpResponse, http, ws } from "msw";
26import { setupWorker } from "msw/browser";27import { setupWorker } from "msw/browser";
27import { page } from "vitest/browser";28import { 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)", () => {
46504718
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();
46534722
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)", () => {
47124781
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();
47154785
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();
47724842
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");
47764847
...@@ -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();
48514922
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/");
48554927
...@@ -4976,12 +5048,12 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -4976,12 +5048,12 @@ describe("ChatView timeline estimator parity (full app)", () => {
49765048
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 expect5052 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();
49855057
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)", () => {
50755147
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");
50805153
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";
13import { useQuery, useQueryClient } from "@tanstack/react-query";14import { useQuery, useQueryClient } from "@tanstack/react-query";
14import { useNavigate, useParams } from "@tanstack/react-router";15import { useNavigate, useParams } from "@tanstack/react-router";
16import { Option } from "effect";
15import {17import {
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 {
45import { useHandleNewThread } from "../hooks/useHandleNewThread";48import { useHandleNewThread } from "../hooks/useHandleNewThread";
46import { useSettings } from "../hooks/useSettings";49import { useSettings } from "../hooks/useSettings";
47import { readLocalApi } from "../localApi";50import { readLocalApi } from "../localApi";
51import {
52 getSourceControlDiscoverySnapshot,
53 refreshSourceControlDiscovery,
54} from "../lib/sourceControlDiscoveryState";
48import {55import {
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";
93import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";100import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
94import { CommandPaletteResults } from "./CommandPaletteResults";101import { CommandPaletteResults } from "./CommandPaletteResults";
95import { GitHubIcon, GitLabIcon } from "./Icons";102import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons";
96import { ProjectFavicon } from "./ProjectFavicon";103import { ProjectFavicon } from "./ProjectFavicon";
97import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";104import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
98import { useServerKeybindings } from "../rpc/serverState";105import { useServerKeybindings } from "../rpc/serverState";
...@@ -108,6 +115,7 @@ import {...@@ -108,6 +115,7 @@ import {
108import { Button } from "./ui/button";115import { Button } from "./ui/button";
109import { Kbd, KbdGroup } from "./ui/kbd";116import { Kbd, KbdGroup } from "./ui/kbd";
110import { stackedThreadToast, toastManager } from "./ui/toast";117import { stackedThreadToast, toastManager } from "./ui/toast";
118import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
111import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";119import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";
112import type { ChatComposerHandle } from "./chat/ChatComposer";120import type { ChatComposerHandle } from "./chat/ChatComposer";
113121
...@@ -143,7 +151,10 @@ interface AddProjectEnvironmentOption {...@@ -143,7 +151,10 @@ interface AddProjectEnvironmentOption {
143 readonly isPrimary: boolean;151 readonly isPrimary: boolean;
144}152}
145153
146type AddProjectRemoteProviderKind = Extract<SourceControlProviderKind, "github" | "gitlab">;154type AddProjectRemoteProviderKind = Extract<
155 SourceControlProviderKind,
156 "github" | "gitlab" | "bitbucket" | "azure-devops"
157>;
147type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";158type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
148159
149type AddProjectCloneFlow =160type AddProjectCloneFlow =
...@@ -161,7 +172,19 @@ type AddProjectCloneFlow =...@@ -161,7 +172,19 @@ type AddProjectCloneFlow =
161 readonly remoteUrl: string;172 readonly remoteUrl: string;
162 };173 };
163174
164const REMOTE_PROJECT_SOURCES: ReadonlyArray<AddProjectRemoteSource> = ["github", "gitlab", "url"];175const REMOTE_PROJECT_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [
176 "url",
177 "github",
178 "gitlab",
179 "bitbucket",
180 "azure-devops",
181];
182const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [
183 "github",
184 "gitlab",
185 "bitbucket",
186 "azure-devops",
187];
165188
166function remoteProjectSourceLabel(source: AddProjectRemoteSource): string {189function 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}
176203
204function 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
177function remoteProjectSourceProvider(219function 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
249function sourceProviderKind(source: AddProjectRemoteSource): AddProjectRemoteProviderKind | null {
250 return source === "url" ? null : source;
251}
252
253function 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
266type AddProjectRemoteSourceReadiness = Record<
267 AddProjectRemoteSource,
268 { readonly ready: boolean; readonly hint: string | null }
269>;
270
271function 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}
202321
203function errorMessage(error: unknown): string {322function errorMessage(error: unknown): string {
...@@ -658,6 +777,145 @@ function OpenCommandPaletteDialog() {...@@ -658,6 +777,145 @@ function OpenCommandPaletteDialog() {
658 [],777 [],
659 );778 );
660779
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 );
686944
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 }
857965
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 ]);
865973
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 }
9151023
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 });
9391052
940 actionItems.push({1053 actionItems.push({
...@@ -1355,6 +1468,10 @@ function OpenCommandPaletteDialog() {...@@ -1355,6 +1468,10 @@ function OpenCommandPaletteDialog() {
1355 }1468 }
13561469
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 <CommandPaletteResultRow47 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}
6165
66function 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
62function CommandPaletteResultRow(props: {93function 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";
12import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";13import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
14import { useNavigate } from "@tanstack/react-router";
13import { Option } from "effect";15import { Option } from "effect";
14import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";16import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
17import { flushSync } from "react-dom";
15import {18import {
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";
25import { Radio as RadioPrimitive } from "@base-ui/react/radio";28import { Radio as RadioPrimitive } from "@base-ui/react/radio";
26import { GitHubIcon, GitLabIcon } from "~/components/Icons";29import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "~/components/Icons";
27import { RadioGroup } from "~/components/ui/radio-group";30import { RadioGroup } from "~/components/ui/radio-group";
28import { Spinner } from "~/components/ui/spinner";31import { Spinner } from "~/components/ui/spinner";
29import { cn } from "~/lib/utils";32import { 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";
46import { AnimatedHeight } from "./AnimatedHeight";
43import { Button } from "~/components/ui/button";47import { Button } from "~/components/ui/button";
44import { Checkbox } from "~/components/ui/checkbox";48import { Checkbox } from "~/components/ui/checkbox";
45import {49import {
...@@ -56,16 +60,9 @@ import { Input } from "~/components/ui/input";...@@ -56,16 +60,9 @@ import { Input } from "~/components/ui/input";
56import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";60import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
57import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";61import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
58import { ScrollArea } from "~/components/ui/scroll-area";62import { ScrollArea } from "~/components/ui/scroll-area";
59import {
60 Select,
61 SelectGroup,
62 SelectItem,
63 SelectPopup,
64 SelectTrigger,
65 SelectValue,
66} from "~/components/ui/select";
67import { Textarea } from "~/components/ui/textarea";63import { Textarea } from "~/components/ui/textarea";
68import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast";64import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast";
65import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
69import { openInPreferredEditor } from "~/editorPreferences";66import { openInPreferredEditor } from "~/editorPreferences";
70import {67import {
71 gitInitMutationOptions,68 gitInitMutationOptions,
...@@ -100,7 +97,10 @@ interface PendingDefaultBranchAction {...@@ -100,7 +97,10 @@ interface PendingDefaultBranchAction {
100 filePaths?: string[];97 filePaths?: string[];
101}98}
10299
103type PublishProviderKind = Extract<SourceControlProviderKind, "github" | "gitlab">;100type PublishProviderKind = Extract<
101 SourceControlProviderKind,
102 "github" | "gitlab" | "bitbucket" | "azure-devops"
103>;
104104
105type GitActionToastId = ReturnType<typeof toastManager.add>;105type GitActionToastId = ReturnType<typeof toastManager.add>;
106106
...@@ -129,6 +129,88 @@ interface RunGitActionWithToastInput {...@@ -129,6 +129,88 @@ interface RunGitActionWithToastInput {
129129
130const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250;130const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250;
131131
132const 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
174function publishProviderOption(provider: PublishProviderKind) {
175 return (
176 PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ??
177 PUBLISH_PROVIDER_OPTIONS[0]
178 );
179}
180
181function isPublishProviderKind(
182 provider: SourceControlProviderKind,
183): provider is PublishProviderKind {
184 return PUBLISH_PROVIDER_OPTIONS.some((option) => option.value === provider);
185}
186
187function 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
132function formatElapsedDescription(startedAtMs: number | null): string | undefined {214function 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}
258340
341interface PublishRepositoryDialogProps {
342 readonly open: boolean;
343 readonly onOpenChange: (open: boolean) => void;
344 readonly environmentId: ScopedThreadRef["environmentId"] | null;
345 readonly gitCwd: string;
346}
347
348function 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
259export default function GitActionsControl({948export 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>;
319982
...@@ -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 );
4351091
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]);
5121168
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 );
9661615
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 };
10071617
1008 if (!gitCwd) return null;1618 if (!gitCwd) return null;
10091619
...@@ -1322,419 +1932,12 @@ export default function GitActionsControl({...@@ -1322,419 +1932,12 @@ export default function GitActionsControl({
1322 </DialogPopup>1932 </DialogPopup>
1323 </Dialog>1933 </Dialog>
13241934
1325 <Dialog1935 <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>
17381941
1739 <Dialog1942 <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";
28import { toastManager } from "../ui/toast";28import { toastManager } from "../ui/toast";
29import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta";29import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta";
30import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm";30import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm";
31import { AnimatedHeight } from "../AnimatedHeight";
3132
32const PROVIDER_ACCENT_SWATCHES = [33const 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 === wizardStep255 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 < wizardStep257 : 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 Driver296 <span
296 </span>297 id="add-instance-driver-label"
297 <RadioGroup298 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}
390304 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 <input308 {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 <button312 <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 selected317 "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 <Button337 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 Clear344 )}
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>
437361
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 <ProviderSettingsForm364 <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>
456462
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";
10import {10import { 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";
20import {11import {
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";
68import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";59import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
69import { Button } from "../ui/button";60import { Button } from "../ui/button";
70import { Group, GroupSeparator } from "../ui/group";61import { Group, GroupSeparator } from "../ui/group";
62import { AnimatedHeight } from "../AnimatedHeight";
71import {63import {
72 Menu,64 Menu,
73 MenuGroup,65 MenuGroup,
...@@ -176,62 +168,6 @@ function ConnectionStatusDot({...@@ -176,62 +168,6 @@ function ConnectionStatusDot({
176 );168 );
177}169}
178170
179function 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
235function getSavedBackendStatusTooltip(171function 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 expect1083 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 });
10881088
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 );
11001100
1101 await expect.element(page.getByText("No source control tools found")).toBeInTheDocument();1101 await expect.element(page.getByText("Nothing detected yet")).toBeInTheDocument();
1102 await expect1102 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 );
11301134
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 });
11341138
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}
7171
72function isVcsNotReady(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): boolean {
73 return !isProviderDiscoveryItem(item) && !item.implemented;
74}
75
72function authPresentation(auth: SourceControlProviderAuth): {76function 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}
8488
85function RedactedAccount(props: { readonly account: string | null }) {89function 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}
9599
96function itemStatusDot(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): string {100function 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 }
143147
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 }
147151
148 if (auth) {152 if (auth) {
...@@ -165,11 +169,17 @@ function itemSummary({...@@ -165,11 +169,17 @@ function itemSummary({
165 }169 }
166170
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 <div203 <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 Soon220 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;
280291
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 );
341354
apps/web/src/lib/sourceControlDiscoveryState.ts+71-24
...@@ -1,16 +1,17 @@...@@ -1,16 +1,17 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import {2import {
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";
10import type { SourceControlDiscoveryResult } from "@t3tools/contracts";9import { EnvironmentId, type SourceControlDiscoveryResult } from "@t3tools/contracts";
11import { Effect } from "effect";10import { Effect } from "effect";
12import { Atom } from "effect/unstable/reactivity";11import { Atom } from "effect/unstable/reactivity";
1312
13import { readPrimaryEnvironmentDescriptor } from "../environments/primary";
14import { readEnvironmentConnection } from "../environments/runtime";
14import { readLocalApi } from "../localApi";15import { readLocalApi } from "../localApi";
15import { appAtomRegistry } from "../rpc/atomRegistry";16import { appAtomRegistry } from "../rpc/atomRegistry";
1617
...@@ -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;
18const SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS = 30_000;19const SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS = 30_000;
19const SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS = 5 * 60_000;20const SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS = 5 * 60_000;
2021
22interface SourceControlDiscoveryTargetInput {
23 readonly environmentId?: EnvironmentId | null;
24}
25
26function 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
21export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({38export 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});
2563
26const sourceControlDiscoveryAutoRefreshAtom = Atom.make(() =>64const 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);
3676
37export function refreshSourceControlDiscovery(): Promise<SourceControlDiscoveryResult | null> {77export 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
83export function getSourceControlDiscoverySnapshot(
84 input?: SourceControlDiscoveryTargetInput,
85): SourceControlDiscoveryState {
86 return sourceControlDiscoveryManager.getSnapshot(sourceControlDiscoveryTarget(input));
39}87}
4088
41export function resetSourceControlDiscoveryStateForTests(): void {89export function resetSourceControlDiscoveryStateForTests(): void {
42 sourceControlDiscoveryManager.reset();90 sourceControlDiscoveryManager.reset();
43}91}
4492
45export function useSourceControlDiscovery(): SourceControlDiscoveryState {93export 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;
4799
48 useAtomValue(sourceControlDiscoveryAutoRefreshAtom);100 useAtomValue(sourceControlDiscoveryAutoRefreshAtom(targetKey));
49101
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 });
5959
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+(.+)
1111
12function parseAzureDevOpsCheckoutReference(args: string): string | null {12function 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
3T3 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
7T3 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
46The **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
52Run 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
581. Install the GitHub CLI on the machine running T3 Code:
59 ```bash
60 brew install gh
61 ```
622. Sign in:
63 ```bash
64 gh auth login
65 ```
663. Open **Settings → Source Control** in T3 Code and verify GitHub shows as authenticated
67
68That's it—you can now clone, publish, and create pull requests.
69
70### For GitLab
71
721. Install the GitLab CLI:
73 ```bash
74 brew install glab
75 ```
762. Authenticate:
77 ```bash
78 glab auth login
79 ```
803. Check **Settings → Source Control** to confirm the connection
81
82### For Bitbucket
83
84Bitbucket uses API tokens instead of a CLI tool:
85
861. Create an API token in your Atlassian account with read/write access to pull requests and repositories
872. 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 ```
923. Restart T3 Code and verify the connection in **Source Control settings**
93
94### For Azure DevOps
95
961. Install Azure CLI:
97 ```bash
98 brew install azure-cli
99 ```
1002. Add the DevOps extension:
101 ```bash
102 az extension add --name azure-devops
103 ```
1043. 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});
122export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type;122export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type;
123123
124const SourceControlDiscoveryItemFields = {124const 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;
133132
134export const SourceControlDiscoveryItem = Schema.Struct({
135 kind: Schema.String,
136 ...SourceControlDiscoveryItemFields,
137});
138export type SourceControlDiscoveryItem = typeof SourceControlDiscoveryItem.Type;
139
140export const VcsDiscoveryItem = Schema.Struct({133export const VcsDiscoveryItem = Schema.Struct({
141 kind: VcsDriverKind,134 kind: VcsDriverKind,
142 ...SourceControlDiscoveryItemFields,135 implemented: Schema.Boolean,
136 ...SourceControlDiscoverySharedFields,
143});137});
144export type VcsDiscoveryItem = typeof VcsDiscoveryItem.Type;138export type VcsDiscoveryItem = typeof VcsDiscoveryItem.Type;
145139
146export const SourceControlProviderDiscoveryItem = Schema.Struct({140export const SourceControlProviderDiscoveryItem = Schema.Struct({
147 kind: SourceControlProviderKind,141 kind: SourceControlProviderKind,
148 ...SourceControlDiscoveryItemFields,142 ...SourceControlDiscoverySharedFields,
149 auth: SourceControlProviderAuth,143 auth: SourceControlProviderAuth,
150});144});
151export type SourceControlProviderDiscoveryItem = typeof SourceControlProviderDiscoveryItem.Type;145export 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}
1313
...@@ -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};
6766
...@@ -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};
7876