History

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

Julius Marminge committed 6 months agov0.0.22-nightly.20260504.196

Commit 91a03e01 parent d501ebe59 files · +5834 −2327Browse files

Showing the changes to apps/server/src/sourceControl (26 of 59 files). Show all

Unified · Split

26 files
  1. apps/server/src/sourceControl/AzureDevOpsCli.test.ts +288 −0
  2. apps/server/src/sourceControl/AzureDevOpsCli.ts +429 −0
  3. apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +91 −0
  4. apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +144 −0
  5. apps/server/src/sourceControl/BitbucketApi.test.ts +513 −0
  6. apps/server/src/sourceControl/BitbucketApi.ts +703 −0
  7. apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +126 −0
  8. apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +126 −0
  9. apps/server/src/sourceControl/GitHubCli.test.ts +5 −6
  10. apps/server/src/sourceControl/GitHubCli.ts +8 −12
  11. apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +8 −6
  12. apps/server/src/sourceControl/GitHubSourceControlProvider.ts +115 −67
  13. apps/server/src/sourceControl/GitLabCli.test.ts +5 −6
  14. apps/server/src/sourceControl/GitLabCli.ts +16 −18
  15. apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +7 −5
  16. apps/server/src/sourceControl/GitLabSourceControlProvider.ts +53 −19
  17. apps/server/src/sourceControl/SourceControlDiscovery.test.ts +122 −82
  18. apps/server/src/sourceControl/SourceControlDiscovery.ts +10 −272
  19. apps/server/src/sourceControl/SourceControlProvider.ts +27 −0
  20. apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +237 −0
  21. apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +48 −9
  22. apps/server/src/sourceControl/SourceControlProviderRegistry.ts +104 −22
  23. apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +17 −21
  24. apps/server/src/sourceControl/SourceControlRepositoryService.ts +7 −7
  25. apps/server/src/sourceControl/azureDevOpsPullRequests.ts +106 −0
  26. apps/server/src/sourceControl/bitbucketPullRequests.ts +104 −0
apps/server/src/sourceControl/AzureDevOpsCli.test.ts+288−0
@@ -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+429−0
@@ -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+91−0
@@ -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+144−0
@@ -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+513−0
@@ -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+703−0
@@ -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+126−0
@@ -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+126−0
@@ -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 @@
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";
6 5
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";
9 8
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 => ({ · 1 unmodified line
15 stderrTruncated: false,14 stderrTruncated: false,
16});15});
17 16
18const mockRun = vi.fn<(input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>>();17const mockRun = vi.fn<VcsProcess.VcsProcessShape["run"]>();
19 18
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 { · 5 unmodified lines
6 type VcsError,6 type VcsError,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8 8
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";
15 11
16const DEFAULT_TIMEOUT_MS = 30_000;12const DEFAULT_TIMEOUT_MS = 30_000;
17 13
@@ -48,7 +44,7 @@ export interface GitHubCliShape { · 30 unmodified lines
48 readonly cwd: string;44 readonly cwd: string;
49 readonly args: ReadonlyArray<string>;45 readonly args: ReadonlyArray<string>;
50 readonly timeoutMs?: number;46 readonly timeoutMs?: number;
51 }) => Effect.Effect<VcsProcessOutput, GitHubCliError>;47 }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCliError>;
52 48
53 readonly listOpenPullRequests: (input: {49 readonly listOpenPullRequests: (input: {
54 readonly cwd: string;50 readonly cwd: string;
@@ -226,7 +222,7 @@ function decodeGitHubJson<S extends Schema.Top>( · 171 unmodified lines
226}222}
227 223
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;
230 226
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* () { · 28 unmodified lines
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* () { · 19 unmodified lines
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"; · 1 unmodified line
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";
4 4
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";
8 8
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 => ({ · 1 unmodified line
14 stderrTruncated: false,14 stderrTruncated: false,
15});15});
16 16
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}
20 22
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", ( · 105 unmodified lines
127 129
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 { · 4 unmodified lines
5 type ChangeRequestState,5 type ChangeRequestState,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7 7
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";
11 12
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 · 2 unmodified lines
18 });22 });
19}23}
20 24
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 { · 15 unmodified lines
40 };44 };
41}45}
42 46
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;
45 92
46 const listChangeRequests: SourceControlProviderShape["listChangeRequests"] = (input) => {93 const listChangeRequests: SourceControlProvider.SourceControlProviderShape["listChangeRequests"] =
47 if (input.state === "open") {94 (input) => {
95 if (input.state === "open") {
96 return github
97 .listOpenPullRequests({
98 cwd: input.cwd,
99 headSelector: input.headSelector,
100 ...(input.limit !== undefined ? { limit: input.limit } : {}),
101 })
102 .pipe(
103 Effect.map((items) => items.map(toChangeRequest)),
104 Effect.mapError((error) => providerError("listChangeRequests", error)),
105 );
106 }
107
108 const stateArg: ChangeRequestState | "all" = input.state;
48 return 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 };
110 158
111 return SourceControlProvider.of({159 return SourceControlProvider.SourceControlProvider.of({
112 kind: "github",160 kind: "github",
113 listChangeRequests,161 listChangeRequests,
114 getChangeRequest: (input) =>162 getChangeRequest: (input) =>
@@ -145,4 +193,4 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { · 30 unmodified lines
145 });193 });
146});194});
147 195
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 @@
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";
5 4
6import { VcsProcessExitError } from "@t3tools/contracts";5import { VcsProcessExitError } from "@t3tools/contracts";
7 6
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";
10 9
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);
21 20
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 · 1 unmodified line
2 2
3import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts";3import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts";
4 4
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";
12 8
13const DEFAULT_TIMEOUT_MS = 30_000;9const DEFAULT_TIMEOUT_MS = 30_000;
14 10
@@ -46,12 +42,12 @@ export interface GitLabCliShape { · 31 unmodified lines
46 readonly cwd: string;42 readonly cwd: string;
47 readonly args: ReadonlyArray<string>;43 readonly args: ReadonlyArray<string>;
48 readonly timeoutMs?: number;44 readonly timeoutMs?: number;
49 }) => Effect.Effect<VcsProcessOutput, GitLabCliError>;45 }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitLabCliError>;
50 46
51 readonly listMergeRequests: (input: {47 readonly listMergeRequests: (input: {
52 readonly cwd: string;48 readonly cwd: string;
53 readonly headSelector: string;49 readonly headSelector: string;
54 readonly source?: SourceControlRefSelector;50 readonly source?: SourceControlProvider.SourceControlRefSelector;
55 readonly state: "open" | "closed" | "merged" | "all";51 readonly state: "open" | "closed" | "merged" | "all";
56 readonly limit?: number;52 readonly limit?: number;
57 }) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>;53 }) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>;
@@ -76,8 +72,8 @@ export interface GitLabCliShape { · 18 unmodified lines
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 { · 136 unmodified lines
220 216
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}
227 223
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}
231 229
@@ -252,7 +250,7 @@ function parseRepositoryPath(repository: string): { · 20 unmodified lines
252}250}
253 251
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;
256 254
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* () { · 27 unmodified lines
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* () { · 11 unmodified lines
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 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import { Effect, Layer, Option } from "effect";2import { Effect, Layer, Option } from "effect";
3 3
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";
6 6
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}
10 12
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", () = · 36 unmodified lines
48 50
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", () => · 19 unmodified lines
74 76
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 @@
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";
3 3
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";
6 7
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 · 2 unmodified lines
13 });17 });
14}18}
15 19
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 { · 15 unmodified lines
35 };39 };
36}40}
37 41
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 }
45 64
46 const match = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim());65 return SourceControlProviderDiscovery.providerAuth({
47 const owner = match?.[1]?.trim();66 status: "unknown",
48 const refName = match?.[2]?.trim();67 host,
49 return owner && refName ? { owner, refName } : undefined;68 detail:
69 SourceControlProviderDiscovery.firstSafeAuthLine(output) ??
70 "GitLab CLI auth status could not be parsed.",
71 });
50}72}
51 73
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;
54 88
55 return SourceControlProvider.of({89 return SourceControlProvider.SourceControlProvider.of({
56 kind: "gitlab",90 kind: "gitlab",
57 listChangeRequests: (input) => {91 listChangeRequests: (input) => {
58 const source = sourceFromInput(input);92 const source = SourceControlProvider.sourceControlRefFromInput(input);
59 return 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* () { · 13 unmodified lines
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* () { · 25 unmodified lines
107 });141 });
108});142});
109 143
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"; · 4 unmodified lines
5import { VcsProcessSpawnError } from "@t3tools/contracts";5import { VcsProcessSpawnError } from "@t3tools/contracts";
6 6
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 );
10 36
11const processOutput = (37const processOutput = (
12 stdout: string,38 stdout: string,
@@ -22,39 +48,53 @@ const processOutput = ( · 9 unmodified lines
22 stderrTruncated: false,48 stderrTruncated: false,
23});49});
24 50
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) · 1 unmodified line
62 );102 );
63 103
64 return Effect.gen(function* () {104 return Effect.gen(function* () {
65 const discovery = yield* SourceControlDiscovery;105 const discovery = yield* SourceControlDiscovery.SourceControlDiscovery;
66 const result = yield* discovery.discover;106 const result = yield* discovery.discover;
67 107
68 assert.deepStrictEqual(108 assert.deepStrictEqual(
@@ -79,7 +119,6 @@ Logged in to github.com account juliusmarminge (keyring) · 10 unmodified lines
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) · 1 unmodified line
87 [126 [
88 {127 {
89 kind: "github",128 kind: "github",
90 implemented: true,
91 status: "available",129 status: "available",
92 auth: "authenticated",130 auth: "authenticated",
93 account: Option.some("juliusmarminge"),131 account: Option.some("juliusmarminge"),
94 },132 },
95 {133 {
96 kind: "gitlab",134 kind: "gitlab",
97 implemented: true,
98 status: "missing",135 status: "missing",
99 auth: "unknown",136 auth: "unknown",
100 account: Option.none(),137 account: Option.none(),
101 },138 },
102 {139 {
103 kind: "azure-devops",140 kind: "azure-devops",
104 implemented: false,
105 status: "missing",141 status: "missing",
106 auth: "unknown",142 auth: "unknown",
107 account: Option.none(),143 account: Option.none(),
108 },144 },
109 {145 {
110 kind: "bitbucket",146 kind: "bitbucket",
111 implemented: false,147 status: "available",
112 status: "missing",148 auth: "unauthenticated",
113 auth: "unknown",
114 account: Option.none(),149 account: Option.none(),
115 },150 },
116 ],151 ],
117 );152 );
118 const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket");153 const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket");
119 assert.ok(bitbucket);154 assert.ok(bitbucket);
120 assert.strictEqual("executable" in bitbucket, false);155 assert.strictEqual(bitbucket.executable, undefined);
121 }).pipe(Effect.provide(testLayer));156 }).pipe(Effect.provide(testLayer));
122});157});
123 158
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 · 1 unmodified line
176 );216 );
177 217
178 return Effect.gen(function* () {218 return Effect.gen(function* () {
179 const discovery = yield* SourceControlDiscovery;219 const discovery = yield* SourceControlDiscovery.SourceControlDiscovery;
180 const result = yield* discovery.discover;220 const result = yield* discovery.discover;
181 221
182 assert.deepStrictEqual(222 assert.deepStrictEqual(
@@ -207,9 +247,9 @@ Logged in as bitbucket-user · 24 unmodified lines
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 @@
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"; · 1 unmodified line
10 7
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";
13 12
14interface DiscoveryProbe {13interface DiscoveryProbe {
15 readonly label: string;14 readonly label: string;
@@ -25,18 +24,6 @@ type VcsProbe = DiscoveryProbe & { · 9 unmodified lines
25 readonly versionArgs: ReadonlyArray<string>;24 readonly versionArgs: ReadonlyArray<string>;
26};25};
27 26
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> = [ · 24 unmodified lines
67 },54 },
68];55];
69 56
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( · 8 unmodified lines
278 Effect.gen(function* () {68 Effect.gen(function* () {
279 const config = yield* ServerConfig;69 const config = yield* ServerConfig;
280 const process = yield* VcsProcess.VcsProcess;70 const process = yield* VcsProcess.VcsProcess;
71 const sourceControlProviders =
72 yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
281 73
282 const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>(74 const probe = <Kind extends VcsDriverKind>(
283 input: DiscoveryProbe & { readonly kind: Kind },75 input: DiscoveryProbe & { readonly kind: Kind },
284 ): Effect.Effect<DiscoveryProbeResult<Kind>> => {76 ): Effect.Effect<DiscoveryProbeResult<Kind>> => {
285 const executable = input.executable;77 const executable = input.executable;
@@ -316,8 +108,9 @@ export const layer = Layer.effect( · 30 unmodified lines
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( · 8 unmodified lines
332 status: "missing" as const,125 status: "missing" as const,
333 version: Option.none<string>(),126 version: Option.none<string>(),
334 installHint: input.installHint,127 installHint: input.installHint,
335 detail: detailFromCause(cause),128 detail: SourceControlProviderDiscovery.detailFromCause(cause),
336 } satisfies DiscoveryProbeResult<Kind>),129 } satisfies DiscoveryProbeResult<Kind>),
337 ),130 ),
338 );131 );
339 };132 };
340 133
341 const probeProvider = (input: ProviderProbe) =>
342 probe(input).pipe(
343 Effect.flatMap((item) => {
344 const executable = input.executable;
345 const authArgs = input.authArgs;
346 const parseAuth = input.parseAuth;
347
348 if (!executable || !authArgs || !parseAuth) {
349 return Effect.succeed({
350 ...item,
351 auth: unknownAuth(input.installHint),
352 } satisfies SourceControlProviderDiscoveryItem);
353 }
354
355 if (item.status !== "available") {
356 return Effect.succeed({
357 ...item,
358 auth: unknownAuth("CLI is not installed."),
359 } satisfies SourceControlProviderDiscoveryItem);
360 }
361
362 return process
363 .run({
364 operation: "source-control.discovery.auth",
365 command: executable,
366 args: authArgs,
367 cwd: config.cwd,
368 allowNonZeroExit: true,
369 timeoutMs: 5_000,
370 maxOutputBytes: 8_000,
371 truncateOutputAtMaxBytes: true,
372 })
373 .pipe(
374 Effect.map(
375 (result) =>
376 ({
377 ...item,
378 auth: parseAuth(result),
379 }) satisfies SourceControlProviderDiscoveryItem,
380 ),
381 Effect.catch((cause) =>
382 Effect.succeed({
383 ...item,
384 auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))),
385 } satisfies SourceControlProviderDiscoveryItem),
386 ),
387 );
388 }),
389 );
390
391 return SourceControlDiscovery.of({134 return SourceControlDiscovery.of({
392 discover: Effect.all({135 discover: Effect.all({
393 versionControlSystems: Effect.all(136 versionControlSystems: Effect.all(
394 VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray<Effect.Effect<VcsDiscoveryItem>>,137 VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray<Effect.Effect<VcsDiscoveryItem>>,
395 { concurrency: "unbounded" },138 { concurrency: "unbounded" },
396 ),139 ),
397 sourceControlProviders: Effect.all(140 sourceControlProviders: sourceControlProviders.discover,
398 SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probeProvider(entry)) as ReadonlyArray<
399 Effect.Effect<SourceControlProviderDiscoveryItem>
400 >,
401 { concurrency: "unbounded" },
402 ),
403 }),141 }),
404 });142 });
405 }),143 }),
apps/server/src/sourceControl/SourceControlProvider.ts+27−0
@@ -21,6 +21,33 @@ export interface SourceControlRefSelector { · 20 unmodified lines
21 readonly repository?: string;21 readonly repository?: string;
22}22}
23 23
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+237−0
@@ -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 @@
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";
3 4
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";
9 14
10const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");15const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
11 16
@@ -29,10 +34,10 @@ function makeRegistry(input: { · 17 unmodified lines
29 expiresAt: Option.none(),34 expiresAt: Option.none(),
30 },35 },
31 }),36 }),
32 } satisfies Partial<VcsDriverShape>;37 } satisfies Partial<VcsDriver.VcsDriverShape>;
33 38
34 const registryLayer = Layer.mock(VcsDriverRegistry)({39 const registryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
35 get: () => Effect.succeed(driver as unknown as VcsDriverShape),40 get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriverShape),
36 resolve: () =>41 resolve: () =>
37 Effect.succeed({42 Effect.succeed({
38 kind: "git",43 kind: "git",
@@ -46,13 +51,23 @@ function makeRegistry(input: { · 7 unmodified lines
46 expiresAt: Option.none(),51 expiresAt: Option.none(),
47 },52 },
48 },53 },
49 driver: driver as unknown as VcsDriverShape,54 driver: driver as unknown as VcsDriver.VcsDriverShape,
50 }),55 }),
51 });56 });
52 57
53 return SourceControlProviderRegistry.make().pipe(58 return SourceControlProviderRegistry.make().pipe(
54 Effect.provide(59 Effect.provide(
55 Layer.mergeAll(registryLayer, Layer.mock(GitHubCli)({}), Layer.mock(GitLabCli)({})),60 Layer.mergeAll(
61 registryLayer,
62 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
63 Layer.mock(BitbucketApi.BitbucketApi)({}),
64 Layer.mock(GitHubCli.GitHubCli)({}),
65 Layer.mock(GitLabCli.GitLabCli)({}),
66 Layer.mock(VcsProcess.VcsProcess)({}),
67 ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe(
68 Layer.provide(NodeServices.layer),
69 ),
70 ),
56 ),71 ),
57 );72 );
58}73}
@@ -93,6 +108,30 @@ it.effect("routes GitLab remotes to the GitLab provider", () => · 34 unmodified lines
93 }),108 }),
94);109);
95 110
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 @@
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";
5 8
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";
14 18
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);
17 21
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}
22 27
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}
27 32
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}
39 45
40export class SourceControlProviderRegistry extends Context.Service<46export class SourceControlProviderRegistry extends Context.Service<
@@ -42,7 +48,9 @@ export class SourceControlProviderRegistry extends Context.Service< · 1 unmodified line
42 SourceControlProviderRegistryShape48 SourceControlProviderRegistryShape
43>()("t3/source-control/SourceControlProviderRegistry") {}49>()("t3/source-control/SourceControlProviderRegistry") {}
44 50
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 · 3 unmodified lines
52 }),60 }),
53 );61 );
54 62
55 return SourceControlProvider.of({63 return SourceControlProvider.SourceControlProvider.of({
56 kind,64 kind,
57 listChangeRequests: () => unsupported("listChangeRequests"),65 listChangeRequests: () => unsupported("listChangeRequests"),
58 getChangeRequest: () => unsupported("getChangeRequest"),66 getChangeRequest: () => unsupported("getChangeRequest"),
@@ -78,7 +86,7 @@ function selectProviderContext( · 19 unmodified lines
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( · 5 unmodified lines
90 }98 }
91 : null;99 : null;
92 })100 })
93 .filter((value): value is SourceControlProviderContext => value !== null);101 .filter((value): value is SourceControlProvider.SourceControlProviderContext => value !== null);
94 102
95 return (103 return (
96 candidates.find((candidate) => candidate.remoteName === "origin") ??104 candidates.find((candidate) => candidate.remoteName === "origin") ??
@@ -100,12 +108,60 @@ function selectProviderContext( · 3 unmodified lines
100 );108 );
101}109}
102 110
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);
109 165
110 const get: SourceControlProviderRegistryShape["get"] = (kind) =>166 const get: SourceControlProviderRegistryShape["get"] = (kind) =>
111 Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind));167 Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind));
@@ -125,7 +181,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit · 13 unmodified lines
125 181
126 const providerContextCache = yield* Cache.makeWith<182 const providerContextCache = yield* Cache.makeWith<
127 string,183 string,
128 SourceControlProviderContext | null,184 SourceControlProvider.SourceControlProviderContext | null,
129 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 · 4 unmodified lines
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 · 3 unmodified lines
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 · 1 unmodified line
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"; · 5 unmodified lines
6import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts";6import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts";
7 7
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";
17 13
18const CLONE_URLS = {14const CLONE_URLS = {
19 nameWithOwner: "octocat/t3code",15 nameWithOwner: "octocat/t3code",
@@ -22,8 +18,8 @@ const CLONE_URLS = { · 2 unmodified lines
22};18};
23 19
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( · 13 unmodified lines
43 };39 };
44}40}
45 41
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 { · 4 unmodified lines
54}50}
55 51
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", · 22 unmodified lines
93 });89 });
94 90
95 return Effect.gen(function* () {91 return Effect.gen(function* () {
96 const service = yield* SourceControlRepositoryService;92 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
97 const result = yield* service.lookupRepository({93 const result = yield* service.lookupRepository({
98 provider: "github",94 provider: "github",
99 repository: "octocat/t3code",95 repository: "octocat/t3code",
@@ -115,7 +111,7 @@ it.effect("clones a looked-up repository into the requested destination", () => · 15 unmodified lines
115 const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = [];111 const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = [];
116 112
117 yield* Effect.gen(function* () {113 yield* Effect.gen(function* () {
118 const service = yield* SourceControlRepositoryService;114 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
119 const result = yield* service.cloneRepository({115 const result = yield* service.cloneRepository({
120 provider: "github",116 provider: "github",
121 repository: "octocat/t3code",117 repository: "octocat/t3code",
@@ -167,7 +163,7 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up · 45 unmodified lines
167 });163 });
168 164
169 return Effect.gen(function* () {165 return Effect.gen(function* () {
170 const service = yield* SourceControlRepositoryService;166 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
171 const result = yield* service.publishRepository({167 const result = yield* service.publishRepository({
172 cwd: "/workspace",168 cwd: "/workspace",
173 provider: "github",169 provider: "github",
@@ -222,7 +218,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => { · 48 unmodified lines
222 const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = [];218 const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = [];
223 219
224 return Effect.gen(function* () {220 return Effect.gen(function* () {
225 const service = yield* SourceControlRepositoryService;221 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
226 const result = yield* service.publishRepository({222 const result = yield* service.publishRepository({
227 cwd: "/workspace",223 cwd: "/workspace",
228 provider: "github",224 provider: "github",
@@ -258,7 +254,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => { · 29 unmodified lines
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 @@
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";
3 3
4import {4import {
@@ -15,8 +15,8 @@ import { · 10 unmodified lines
15} from "@t3tools/contracts";15} from "@t3tools/contracts";
16 16
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";
20 20
21export interface SourceControlRepositoryServiceShape {21export interface SourceControlRepositoryServiceShape {
22 readonly lookupRepository: (22 readonly lookupRepository: (
@@ -102,10 +102,10 @@ function selectRemoteUrl( · 79 unmodified lines
102 102
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 { · 1 unmodified line
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;
119 119
120 const ensureConcreteProvider = (input: {120 const ensureConcreteProvider = (input: {
121 readonly operation: string;121 readonly operation: string;
apps/server/src/sourceControl/azureDevOpsPullRequests.ts+106−0
@@ -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+104−0
@@ -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}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t