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 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest";
3+import { Effect, FileSystem, Layer, Option } from "effect";
4+import { ChildProcessSpawner } from "effect/unstable/process";
5+import type { VcsError } from "@t3tools/contracts";
6+
7+import * as VcsProcess from "../vcs/VcsProcess.ts";
8+import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
9+
10+const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
11+ exitCode: ChildProcessSpawner.ExitCode(0),
12+ stdout,
13+ stderr: "",
14+ stdoutTruncated: false,
15+ stderrTruncated: false,
16+});
17+
18+const mockRun = vi.fn<VcsProcess.VcsProcessShape["run"]>();
19+
20+const supportLayer = Layer.mergeAll(
21+ Layer.mock(VcsProcess.VcsProcess)({
22+ run: mockRun,
23+ }),
24+ NodeServices.layer,
25+);
26+const layer = Layer.mergeAll(AzureDevOpsCli.layer.pipe(Layer.provide(supportLayer)), supportLayer);
27+
28+afterEach(() => {
29+ mockRun.mockReset();
30+});
31+
32+describe("AzureDevOpsCli.layer", () => {
33+ it.effect("parses pull request view output", () =>
34+ Effect.gen(function* () {
35+ mockRun.mockReturnValueOnce(
36+ Effect.succeed(
37+ processOutput(
38+ JSON.stringify({
39+ pullRequestId: 42,
40+ title: "Add Azure provider",
41+ sourceRefName: "refs/heads/feature/source-control",
42+ targetRefName: "refs/heads/main",
43+ status: "active",
44+ creationDate: "2026-01-02T00:00:00.000Z",
45+ closedDate: null,
46+ _links: {
47+ web: {
48+ href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42",
49+ },
50+ },
51+ }),
52+ ),
53+ ),
54+ );
55+
56+ const az = yield* AzureDevOpsCli.AzureDevOpsCli;
57+ const result = yield* az.getPullRequest({
58+ cwd: "/repo",
59+ reference: "#42",
60+ });
61+
62+ assert.strictEqual(result.number, 42);
63+ assert.strictEqual(result.title, "Add Azure provider");
64+ assert.strictEqual(result.baseRefName, "main");
65+ assert.strictEqual(result.headRefName, "feature/source-control");
66+ assert.strictEqual(result.state, "open");
67+ assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag);
68+ assert.deepStrictEqual(mockRun.mock.calls.at(-1)?.[0], {
69+ operation: "AzureDevOpsCli.execute",
70+ command: "az",
71+ args: [
72+ "repos",
73+ "pr",
74+ "show",
75+ "--detect",
76+ "true",
77+ "--id",
78+ "42",
79+ "--only-show-errors",
80+ "--output",
81+ "json",
82+ ],
83+ cwd: "/repo",
84+ timeoutMs: 30_000,
85+ });
86+ }).pipe(Effect.provide(layer)),
87+ );
88+
89+ it.effect("lists pull requests with Azure status and source branch arguments", () =>
90+ Effect.gen(function* () {
91+ mockRun.mockReturnValueOnce(
92+ Effect.succeed(
93+ processOutput(
94+ JSON.stringify([
95+ {
96+ pullRequestId: 7,
97+ title: "Merged work",
98+ sourceRefName: "refs/heads/feature/merged",
99+ targetRefName: "refs/heads/main",
100+ status: "completed",
101+ closedDate: "2026-01-03T00:00:00.000Z",
102+ _links: {
103+ web: {
104+ href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/7",
105+ },
106+ },
107+ },
108+ ]),
109+ ),
110+ ),
111+ );
112+
113+ const az = yield* AzureDevOpsCli.AzureDevOpsCli;
114+ const result = yield* az.listPullRequests({
115+ cwd: "/repo",
116+ headSelector: "origin:feature/merged",
117+ state: "merged",
118+ limit: 10,
119+ });
120+
121+ assert.strictEqual(result[0]?.state, "merged");
122+ expect(mockRun).toHaveBeenCalledWith({
123+ operation: "AzureDevOpsCli.execute",
124+ command: "az",
125+ args: [
126+ "repos",
127+ "pr",
128+ "list",
129+ "--detect",
130+ "true",
131+ "--source-branch",
132+ "feature/merged",
133+ "--status",
134+ "completed",
135+ "--top",
136+ "10",
137+ "--only-show-errors",
138+ "--output",
139+ "json",
140+ ],
141+ cwd: "/repo",
142+ timeoutMs: 30_000,
143+ });
144+ }).pipe(Effect.provide(layer)),
145+ );
146+
147+ it.effect("reads repository clone URLs", () =>
148+ Effect.gen(function* () {
149+ mockRun.mockReturnValueOnce(
150+ Effect.succeed(
151+ processOutput(
152+ JSON.stringify({
153+ name: "repo",
154+ webUrl: "https://dev.azure.com/acme/project/_git/repo",
155+ remoteUrl: "https://dev.azure.com/acme/project/_git/repo",
156+ sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
157+ project: {
158+ name: "project",
159+ },
160+ }),
161+ ),
162+ ),
163+ );
164+
165+ const az = yield* AzureDevOpsCli.AzureDevOpsCli;
166+ const result = yield* az.getRepositoryCloneUrls({
167+ cwd: "/repo",
168+ repository: "repo",
169+ });
170+
171+ assert.deepStrictEqual(result, {
172+ nameWithOwner: "project/repo",
173+ url: "https://dev.azure.com/acme/project/_git/repo",
174+ sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
175+ });
176+ }).pipe(Effect.provide(layer)),
177+ );
178+
179+ it.effect("creates repositories through Azure Repos", () =>
180+ Effect.gen(function* () {
181+ mockRun.mockReturnValueOnce(
182+ Effect.succeed(
183+ processOutput(
184+ JSON.stringify({
185+ name: "repo",
186+ webUrl: "https://dev.azure.com/acme/project/_git/repo",
187+ remoteUrl: "https://dev.azure.com/acme/project/_git/repo",
188+ sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
189+ project: {
190+ name: "project",
191+ },
192+ }),
193+ ),
194+ ),
195+ );
196+
197+ const az = yield* AzureDevOpsCli.AzureDevOpsCli;
198+ const result = yield* az.createRepository({
199+ cwd: "/repo",
200+ repository: "project/repo",
201+ visibility: "private",
202+ });
203+
204+ assert.deepStrictEqual(result, {
205+ nameWithOwner: "project/repo",
206+ url: "https://dev.azure.com/acme/project/_git/repo",
207+ sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
208+ });
209+ expect(mockRun).toHaveBeenCalledWith({
210+ operation: "AzureDevOpsCli.execute",
211+ command: "az",
212+ args: [
213+ "repos",
214+ "create",
215+ "--detect",
216+ "true",
217+ "--name",
218+ "repo",
219+ "--project",
220+ "project",
221+ "--only-show-errors",
222+ "--output",
223+ "json",
224+ ],
225+ cwd: "/repo",
226+ timeoutMs: 30_000,
227+ });
228+ }).pipe(Effect.provide(layer)),
229+ );
230+
231+ it.effect("creates pull requests using the body file as the Azure description", () =>
232+ Effect.gen(function* () {
233+ const fileSystem = yield* FileSystem.FileSystem;
234+ const bodyFile = `/tmp/t3code-azure-devops-cli-${Date.now()}.md`;
235+ yield* fileSystem.writeFileString(bodyFile, "Generated body");
236+ mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}")));
237+
238+ const az = yield* AzureDevOpsCli.AzureDevOpsCli;
239+ yield* az.createPullRequest({
240+ cwd: "/repo",
241+ baseBranch: "main",
242+ headSelector: "feature/provider",
243+ title: "Provider PR",
244+ bodyFile,
245+ });
246+
247+ expect(mockRun).toHaveBeenCalledWith(
248+ expect.objectContaining({
249+ command: "az",
250+ cwd: "/repo",
251+ args: expect.arrayContaining(["--description", `@${bodyFile}`]),
252+ }),
253+ );
254+ expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--output");
255+ }).pipe(Effect.provide(layer)),
256+ );
257+
258+ it.effect("does not force JSON output on checkout side-effect commands", () =>
259+ Effect.gen(function* () {
260+ mockRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
261+
262+ const az = yield* AzureDevOpsCli.AzureDevOpsCli;
263+ yield* az.checkoutPullRequest({
264+ cwd: "/repo",
265+ reference: "42",
266+ });
267+
268+ expect(mockRun).toHaveBeenCalledWith({
269+ operation: "AzureDevOpsCli.execute",
270+ command: "az",
271+ args: [
272+ "repos",
273+ "pr",
274+ "checkout",
275+ "--only-show-errors",
276+ "--detect",
277+ "true",
278+ "--id",
279+ "42",
280+ "--remote-name",
281+ "origin",
282+ ],
283+ cwd: "/repo",
284+ timeoutMs: 30_000,
285+ });
286+ }).pipe(Effect.provide(layer)),
287+ );
288+});
apps/server/src/sourceControl/AzureDevOpsCli.ts+429−0
@@ -0,0 +1,429 @@
1+import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect";
2+import {
3+ TrimmedNonEmptyString,
4+ type SourceControlRepositoryVisibility,
5+ type VcsError,
6+} from "@t3tools/contracts";
7+
8+import * as VcsProcess from "../vcs/VcsProcess.ts";
9+import * as AzureDevOpsPullRequests from "./azureDevOpsPullRequests.ts";
10+import * as SourceControlProvider from "./SourceControlProvider.ts";
11+
12+const DEFAULT_TIMEOUT_MS = 30_000;
13+
14+export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliError>()(
15+ "AzureDevOpsCliError",
16+ {
17+ operation: Schema.String,
18+ detail: Schema.String,
19+ cause: Schema.optional(Schema.Defect),
20+ },
21+) {
22+ override get message(): string {
23+ return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
24+ }
25+}
26+
27+export interface AzureDevOpsRepositoryCloneUrls {
28+ readonly nameWithOwner: string;
29+ readonly url: string;
30+ readonly sshUrl: string;
31+}
32+
33+export interface AzureDevOpsCliShape {
34+ readonly execute: (input: {
35+ readonly cwd: string;
36+ readonly args: ReadonlyArray<string>;
37+ readonly timeoutMs?: number;
38+ }) => Effect.Effect<VcsProcess.VcsProcessOutput, AzureDevOpsCliError>;
39+
40+ readonly listPullRequests: (input: {
41+ readonly cwd: string;
42+ readonly headSelector: string;
43+ readonly source?: SourceControlProvider.SourceControlRefSelector;
44+ readonly state: "open" | "closed" | "merged" | "all";
45+ readonly limit?: number;
46+ }) => Effect.Effect<
47+ ReadonlyArray<AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord>,
48+ AzureDevOpsCliError
49+ >;
50+
51+ readonly getPullRequest: (input: {
52+ readonly cwd: string;
53+ readonly reference: string;
54+ }) => Effect.Effect<
55+ AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord,
56+ AzureDevOpsCliError
57+ >;
58+
59+ readonly getRepositoryCloneUrls: (input: {
60+ readonly cwd: string;
61+ readonly repository: string;
62+ }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;
63+
64+ readonly createRepository: (input: {
65+ readonly cwd: string;
66+ readonly repository: string;
67+ readonly visibility: SourceControlRepositoryVisibility;
68+ }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;
69+
70+ readonly createPullRequest: (input: {
71+ readonly cwd: string;
72+ readonly baseBranch: string;
73+ readonly headSelector: string;
74+ readonly source?: SourceControlProvider.SourceControlRefSelector;
75+ readonly target?: SourceControlProvider.SourceControlRefSelector;
76+ readonly title: string;
77+ readonly bodyFile: string;
78+ }) => Effect.Effect<void, AzureDevOpsCliError>;
79+
80+ readonly getDefaultBranch: (input: {
81+ readonly cwd: string;
82+ }) => Effect.Effect<string | null, AzureDevOpsCliError>;
83+
84+ readonly checkoutPullRequest: (input: {
85+ readonly cwd: string;
86+ readonly reference: string;
87+ readonly remoteName?: string;
88+ }) => Effect.Effect<void, AzureDevOpsCliError>;
89+}
90+
91+export class AzureDevOpsCli extends Context.Service<AzureDevOpsCli, AzureDevOpsCliShape>()(
92+ "t3/source-control/AzureDevOpsCli",
93+) {}
94+
95+function errorText(error: VcsError | unknown): string {
96+ if (typeof error === "object" && error !== null) {
97+ const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : "";
98+ const detail = "detail" in error && typeof error.detail === "string" ? error.detail : "";
99+ const message = "message" in error && typeof error.message === "string" ? error.message : "";
100+ return [tag, detail, message].filter(Boolean).join("\n");
101+ }
102+
103+ return String(error);
104+}
105+
106+function normalizeAzureDevOpsCliError(
107+ operation: "execute",
108+ error: VcsError | unknown,
109+): AzureDevOpsCliError {
110+ const text = errorText(error);
111+ const lower = text.toLowerCase();
112+
113+ if (lower.includes("command not found: az") || lower.includes("enoent")) {
114+ return new AzureDevOpsCliError({
115+ operation,
116+ detail:
117+ "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH.",
118+ cause: error,
119+ });
120+ }
121+
122+ if (
123+ lower.includes("az devops login") ||
124+ lower.includes("please run az login") ||
125+ lower.includes("not logged in") ||
126+ lower.includes("authentication failed") ||
127+ lower.includes("unauthorized")
128+ ) {
129+ return new AzureDevOpsCliError({
130+ operation,
131+ detail: "Azure DevOps CLI is not authenticated. Run `az devops login` and retry.",
132+ cause: error,
133+ });
134+ }
135+
136+ if (
137+ lower.includes("pull request") &&
138+ (lower.includes("not found") || lower.includes("does not exist"))
139+ ) {
140+ return new AzureDevOpsCliError({
141+ operation,
142+ detail: "Pull request not found. Check the PR number or URL and try again.",
143+ cause: error,
144+ });
145+ }
146+
147+ return new AzureDevOpsCliError({
148+ operation,
149+ detail: text,
150+ cause: error,
151+ });
152+}
153+
154+function normalizeChangeRequestId(reference: string): string {
155+ const trimmed = reference.trim().replace(/^#/, "");
156+ const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed);
157+ return urlMatch?.[1] ?? trimmed;
158+}
159+
160+function toAzureStatus(state: "open" | "closed" | "merged" | "all"): string {
161+ switch (state) {
162+ case "open":
163+ return "active";
164+ case "closed":
165+ return "abandoned";
166+ case "merged":
167+ return "completed";
168+ case "all":
169+ return "all";
170+ }
171+}
172+
173+const RawAzureDevOpsRepositorySchema = Schema.Struct({
174+ name: TrimmedNonEmptyString,
175+ webUrl: TrimmedNonEmptyString,
176+ remoteUrl: TrimmedNonEmptyString,
177+ sshUrl: TrimmedNonEmptyString,
178+ project: Schema.optional(
179+ Schema.Struct({
180+ name: TrimmedNonEmptyString,
181+ }),
182+ ),
183+ defaultBranch: Schema.optional(Schema.NullOr(Schema.String)),
184+});
185+
186+function normalizeDefaultBranch(value: string | null | undefined): string | null {
187+ const trimmed = value?.trim().replace(/^refs\/heads\//, "") ?? "";
188+ return trimmed.length > 0 ? trimmed : null;
189+}
190+
191+function normalizeRepositoryCloneUrls(
192+ raw: Schema.Schema.Type<typeof RawAzureDevOpsRepositorySchema>,
193+): AzureDevOpsRepositoryCloneUrls {
194+ const projectName = raw.project?.name.trim();
195+ return {
196+ nameWithOwner: projectName ? `${projectName}/${raw.name}` : raw.name,
197+ url: raw.remoteUrl,
198+ sshUrl: raw.sshUrl,
199+ };
200+}
201+
202+function parseRepositorySpecifier(repository: string): {
203+ readonly project: string | null;
204+ readonly name: string;
205+} {
206+ const parts = repository
207+ .split("/")
208+ .map((part) => part.trim())
209+ .filter((part) => part.length > 0);
210+ return {
211+ project: parts.length > 1 ? (parts.at(-2) ?? null) : null,
212+ name: parts.at(-1) ?? repository.trim(),
213+ };
214+}
215+
216+function decodeAzureDevOpsJson<S extends Schema.Top>(
217+ raw: string,
218+ schema: S,
219+ operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository",
220+ invalidDetail: string,
221+): Effect.Effect<S["Type"], AzureDevOpsCliError, S["DecodingServices"]> {
222+ return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
223+ Effect.mapError(
224+ (error) =>
225+ new AzureDevOpsCliError({
226+ operation,
227+ detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`,
228+ cause: error,
229+ }),
230+ ),
231+ );
232+}
233+
234+export const make = Effect.fn("makeAzureDevOpsCli")(function* () {
235+ const process = yield* VcsProcess.VcsProcess;
236+
237+ const execute: AzureDevOpsCliShape["execute"] = (input) =>
238+ process
239+ .run({
240+ operation: "AzureDevOpsCli.execute",
241+ command: "az",
242+ args: input.args,
243+ cwd: input.cwd,
244+ timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
245+ })
246+ .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error)));
247+
248+ const executeJson = (input: Parameters<AzureDevOpsCliShape["execute"]>[0]) =>
249+ execute({
250+ ...input,
251+ args: [...input.args, "--only-show-errors", "--output", "json"],
252+ });
253+
254+ return AzureDevOpsCli.of({
255+ execute,
256+ listPullRequests: (input) =>
257+ executeJson({
258+ cwd: input.cwd,
259+ args: [
260+ "repos",
261+ "pr",
262+ "list",
263+ "--detect",
264+ "true",
265+ "--source-branch",
266+ SourceControlProvider.sourceBranch(input),
267+ "--status",
268+ toAzureStatus(input.state),
269+ "--top",
270+ String(input.limit ?? 20),
271+ ],
272+ }).pipe(
273+ Effect.map((result) => result.stdout.trim()),
274+ Effect.flatMap((raw) =>
275+ raw.length === 0
276+ ? Effect.succeed([])
277+ : Effect.sync(() =>
278+ AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestListJson(raw),
279+ ).pipe(
280+ Effect.flatMap((decoded) => {
281+ if (!Result.isSuccess(decoded)) {
282+ return Effect.fail(
283+ new AzureDevOpsCliError({
284+ operation: "listPullRequests",
285+ detail: `Azure DevOps CLI returned invalid PR list JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`,
286+ cause: decoded.failure,
287+ }),
288+ );
289+ }
290+
291+ return Effect.succeed(decoded.success);
292+ }),
293+ ),
294+ ),
295+ ),
296+ getPullRequest: (input) =>
297+ executeJson({
298+ cwd: input.cwd,
299+ args: [
300+ "repos",
301+ "pr",
302+ "show",
303+ "--detect",
304+ "true",
305+ "--id",
306+ normalizeChangeRequestId(input.reference),
307+ ],
308+ }).pipe(
309+ Effect.map((result) => result.stdout.trim()),
310+ Effect.flatMap((raw) =>
311+ Effect.sync(() => AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestJson(raw)).pipe(
312+ Effect.flatMap((decoded) => {
313+ if (!Result.isSuccess(decoded)) {
314+ return Effect.fail(
315+ new AzureDevOpsCliError({
316+ operation: "getPullRequest",
317+ detail: `Azure DevOps CLI returned invalid pull request JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`,
318+ cause: decoded.failure,
319+ }),
320+ );
321+ }
322+
323+ return Effect.succeed(decoded.success);
324+ }),
325+ ),
326+ ),
327+ ),
328+ getRepositoryCloneUrls: (input) =>
329+ executeJson({
330+ cwd: input.cwd,
331+ args: ["repos", "show", "--detect", "true", "--repository", input.repository],
332+ }).pipe(
333+ Effect.map((result) => result.stdout.trim()),
334+ Effect.flatMap((raw) =>
335+ decodeAzureDevOpsJson(
336+ raw,
337+ RawAzureDevOpsRepositorySchema,
338+ "getRepositoryCloneUrls",
339+ "Azure DevOps CLI returned invalid repository JSON.",
340+ ),
341+ ),
342+ Effect.map(normalizeRepositoryCloneUrls),
343+ ),
344+ createRepository: (input) => {
345+ const repository = parseRepositorySpecifier(input.repository);
346+ // Azure Repos access is governed by project/organization permissions.
347+ // `az repos create` does not expose a per-repository visibility flag, so
348+ // the generic source-control visibility input is intentionally not
349+ // translated into CLI args for this provider.
350+ return executeJson({
351+ cwd: input.cwd,
352+ args: [
353+ "repos",
354+ "create",
355+ "--detect",
356+ "true",
357+ "--name",
358+ repository.name,
359+ ...(repository.project ? ["--project", repository.project] : []),
360+ ],
361+ }).pipe(
362+ Effect.map((result) => result.stdout.trim()),
363+ Effect.flatMap((raw) =>
364+ decodeAzureDevOpsJson(
365+ raw,
366+ RawAzureDevOpsRepositorySchema,
367+ "createRepository",
368+ "Azure DevOps CLI returned invalid repository JSON.",
369+ ),
370+ ),
371+ Effect.map(normalizeRepositoryCloneUrls),
372+ );
373+ },
374+ createPullRequest: (input) =>
375+ execute({
376+ cwd: input.cwd,
377+ args: [
378+ "repos",
379+ "pr",
380+ "create",
381+ "--only-show-errors",
382+ "--detect",
383+ "true",
384+ "--target-branch",
385+ input.target?.refName ?? input.baseBranch,
386+ "--source-branch",
387+ SourceControlProvider.sourceBranch(input),
388+ "--title",
389+ input.title,
390+ "--description",
391+ `@${input.bodyFile}`,
392+ ],
393+ }).pipe(Effect.asVoid),
394+ getDefaultBranch: (input) =>
395+ executeJson({
396+ cwd: input.cwd,
397+ args: ["repos", "show", "--detect", "true"],
398+ }).pipe(
399+ Effect.map((result) => result.stdout.trim()),
400+ Effect.flatMap((raw) =>
401+ decodeAzureDevOpsJson(
402+ raw,
403+ RawAzureDevOpsRepositorySchema,
404+ "getDefaultBranch",
405+ "Azure DevOps CLI returned invalid repository JSON.",
406+ ),
407+ ),
408+ Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)),
409+ ),
410+ checkoutPullRequest: (input) =>
411+ execute({
412+ cwd: input.cwd,
413+ args: [
414+ "repos",
415+ "pr",
416+ "checkout",
417+ "--only-show-errors",
418+ "--detect",
419+ "true",
420+ "--id",
421+ normalizeChangeRequestId(input.reference),
422+ "--remote-name",
423+ input.remoteName ?? "origin",
424+ ],
425+ }).pipe(Effect.asVoid),
426+ });
427+});
428+
429+export const layer = Layer.effect(AzureDevOpsCli, make());
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts+91−0
@@ -0,0 +1,91 @@
1+import { assert, it } from "@effect/vitest";
2+import { Effect, Layer, Option } from "effect";
3+
4+import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
5+import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts";
6+
7+function makeProvider(azure: Partial<AzureDevOpsCli.AzureDevOpsCliShape>) {
8+ return AzureDevOpsSourceControlProvider.make().pipe(
9+ Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)),
10+ );
11+}
12+
13+it.effect("maps Azure DevOps PR summaries into provider-neutral change requests", () =>
14+ Effect.gen(function* () {
15+ const provider = yield* makeProvider({
16+ getPullRequest: () =>
17+ Effect.succeed({
18+ number: 42,
19+ title: "Add Azure provider",
20+ url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42",
21+ baseRefName: "main",
22+ headRefName: "feature/source-control",
23+ state: "open",
24+ updatedAt: Option.none(),
25+ }),
26+ });
27+
28+ const changeRequest = yield* provider.getChangeRequest({
29+ cwd: "/repo",
30+ reference: "42",
31+ });
32+
33+ assert.deepStrictEqual(changeRequest, {
34+ provider: "azure-devops",
35+ number: 42,
36+ title: "Add Azure provider",
37+ url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42",
38+ baseRefName: "main",
39+ headRefName: "feature/source-control",
40+ state: "open",
41+ updatedAt: Option.none(),
42+ isCrossRepository: false,
43+ });
44+ }),
45+);
46+
47+it.effect("creates Azure DevOps PRs through provider-neutral input names", () =>
48+ Effect.gen(function* () {
49+ let createInput: Parameters<AzureDevOpsCli.AzureDevOpsCliShape["createPullRequest"]>[0] | null =
50+ null;
51+ const provider = yield* makeProvider({
52+ createPullRequest: (input) => {
53+ createInput = input;
54+ return Effect.void;
55+ },
56+ });
57+
58+ yield* provider.createChangeRequest({
59+ cwd: "/repo",
60+ baseRefName: "main",
61+ headSelector: "feature/provider",
62+ title: "Provider PR",
63+ bodyFile: "/tmp/body.md",
64+ });
65+
66+ assert.deepStrictEqual(createInput, {
67+ cwd: "/repo",
68+ baseBranch: "main",
69+ headSelector: "feature/provider",
70+ title: "Provider PR",
71+ bodyFile: "/tmp/body.md",
72+ });
73+ }),
74+);
75+
76+it.effect("uses Azure CLI repository detection for default branch lookup", () =>
77+ Effect.gen(function* () {
78+ let cwdInput: string | null = null;
79+ const provider = yield* makeProvider({
80+ getDefaultBranch: (input) => {
81+ cwdInput = input.cwd;
82+ return Effect.succeed("main");
83+ },
84+ });
85+
86+ const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" });
87+
88+ assert.strictEqual(defaultBranch, "main");
89+ assert.strictEqual(cwdInput, "/repo");
90+ }),
91+);
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts+144−0
@@ -0,0 +1,144 @@
1+import { Effect, Layer } from "effect";
2+import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
3+
4+import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
5+import * as SourceControlProvider from "./SourceControlProvider.ts";
6+import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
7+
8+function providerError(
9+ operation: string,
10+ cause: AzureDevOpsCli.AzureDevOpsCliError,
11+): SourceControlProviderError {
12+ return new SourceControlProviderError({
13+ provider: "azure-devops",
14+ operation,
15+ detail: cause.detail,
16+ cause,
17+ });
18+}
19+
20+function parseAzureAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) {
21+ const account = input.stdout.trim().split(/\r?\n/)[0]?.trim();
22+
23+ if (input.exitCode !== 0) {
24+ return SourceControlProviderDiscovery.providerAuth({
25+ status: "unauthenticated",
26+ detail:
27+ SourceControlProviderDiscovery.firstSafeAuthLine(
28+ SourceControlProviderDiscovery.combinedAuthOutput(input),
29+ ) ?? "Run `az login` to authenticate Azure CLI.",
30+ });
31+ }
32+
33+ if (account && account.length > 0) {
34+ return SourceControlProviderDiscovery.providerAuth({
35+ status: "authenticated",
36+ account,
37+ host: "dev.azure.com",
38+ });
39+ }
40+
41+ return SourceControlProviderDiscovery.providerAuth({
42+ status: "unknown",
43+ host: "dev.azure.com",
44+ detail: "Azure CLI account status could not be parsed.",
45+ });
46+}
47+
48+export const discovery = {
49+ type: "cli",
50+ kind: "azure-devops",
51+ label: "Azure DevOps",
52+ executable: "az",
53+ versionArgs: ["--version"],
54+ authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"],
55+ parseAuth: parseAzureAuth,
56+ installHint:
57+ "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.",
58+} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec;
59+
60+function toChangeRequest(summary: {
61+ readonly number: number;
62+ readonly title: string;
63+ readonly url: string;
64+ readonly baseRefName: string;
65+ readonly headRefName: string;
66+ readonly state: "open" | "closed" | "merged";
67+ readonly updatedAt: ChangeRequest["updatedAt"];
68+}): ChangeRequest {
69+ return {
70+ provider: "azure-devops",
71+ number: summary.number,
72+ title: summary.title,
73+ url: summary.url,
74+ baseRefName: summary.baseRefName,
75+ headRefName: summary.headRefName,
76+ state: summary.state,
77+ updatedAt: summary.updatedAt,
78+ isCrossRepository: false,
79+ };
80+}
81+
82+export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* () {
83+ const azure = yield* AzureDevOpsCli.AzureDevOpsCli;
84+
85+ return SourceControlProvider.SourceControlProvider.of({
86+ kind: "azure-devops",
87+ listChangeRequests: (input) => {
88+ const source = SourceControlProvider.sourceControlRefFromInput(input);
89+ return azure
90+ .listPullRequests({
91+ cwd: input.cwd,
92+ headSelector: input.headSelector,
93+ ...(source ? { source } : {}),
94+ state: input.state,
95+ ...(input.limit !== undefined ? { limit: input.limit } : {}),
96+ })
97+ .pipe(
98+ Effect.map((items) => items.map(toChangeRequest)),
99+ Effect.mapError((error) => providerError("listChangeRequests", error)),
100+ );
101+ },
102+ getChangeRequest: (input) =>
103+ azure.getPullRequest(input).pipe(
104+ Effect.map(toChangeRequest),
105+ Effect.mapError((error) => providerError("getChangeRequest", error)),
106+ ),
107+ createChangeRequest: (input) => {
108+ const source = SourceControlProvider.sourceControlRefFromInput(input);
109+ return azure
110+ .createPullRequest({
111+ cwd: input.cwd,
112+ baseBranch: input.baseRefName,
113+ headSelector: input.headSelector,
114+ ...(source ? { source } : {}),
115+ ...(input.target ? { target: input.target } : {}),
116+ title: input.title,
117+ bodyFile: input.bodyFile,
118+ })
119+ .pipe(Effect.mapError((error) => providerError("createChangeRequest", error)));
120+ },
121+ getRepositoryCloneUrls: (input) =>
122+ azure
123+ .getRepositoryCloneUrls(input)
124+ .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))),
125+ createRepository: (input) =>
126+ azure
127+ .createRepository(input)
128+ .pipe(Effect.mapError((error) => providerError("createRepository", error))),
129+ getDefaultBranch: (input) =>
130+ azure
131+ .getDefaultBranch({ cwd: input.cwd })
132+ .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))),
133+ checkoutChangeRequest: (input) =>
134+ azure
135+ .checkoutPullRequest({
136+ cwd: input.cwd,
137+ reference: input.reference,
138+ ...(input.context ? { remoteName: input.context.remoteName } : {}),
139+ })
140+ .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))),
141+ });
142+});
143+
144+export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make());
apps/server/src/sourceControl/BitbucketApi.test.ts+513−0
@@ -0,0 +1,513 @@
1+import { assert, it, vi } from "@effect/vitest";
2+import * as NodeServices from "@effect/platform-node/NodeServices";
3+import { ConfigProvider, DateTime, Effect, FileSystem, Layer, Option } from "effect";
4+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
5+
6+import * as BitbucketApi from "./BitbucketApi.ts";
7+import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
8+import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
9+import type * as VcsDriver from "../vcs/VcsDriver.ts";
10+
11+const bitbucketPullRequest = {
12+ id: 42,
13+ title: "Add Bitbucket provider",
14+ state: "OPEN",
15+ updated_on: "2026-01-02T00:00:00.000Z",
16+ links: {
17+ html: {
18+ href: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
19+ },
20+ },
21+ source: {
22+ branch: { name: "feature/source-control" },
23+ repository: {
24+ full_name: "octocat/t3code",
25+ workspace: { slug: "octocat" },
26+ },
27+ },
28+ destination: {
29+ branch: { name: "main" },
30+ repository: {
31+ full_name: "pingdotgg/t3code",
32+ workspace: { slug: "pingdotgg" },
33+ },
34+ },
35+};
36+
37+const repositoryJson = {
38+ full_name: "pingdotgg/t3code",
39+ links: {
40+ html: { href: "https://bitbucket.org/pingdotgg/t3code" },
41+ clone: [
42+ { name: "https", href: "https://bitbucket.org/pingdotgg/t3code.git" },
43+ { name: "ssh", href: "git@bitbucket.org:pingdotgg/t3code.git" },
44+ ],
45+ },
46+ mainbranch: { name: "main" },
47+};
48+
49+function makeLayer(input: {
50+ readonly response: (request: HttpClientRequest.HttpClientRequest) => Response;
51+ readonly git?: Partial<GitVcsDriver.GitVcsDriverShape>;
52+}) {
53+ const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) =>
54+ Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))),
55+ );
56+ const gitMock = {
57+ readConfigValue: vi.fn<GitVcsDriver.GitVcsDriverShape["readConfigValue"]>(() =>
58+ Effect.succeed<string | null>("git@bitbucket.org:pingdotgg/t3code.git"),
59+ ),
60+ resolvePrimaryRemoteName: vi.fn<GitVcsDriver.GitVcsDriverShape["resolvePrimaryRemoteName"]>(
61+ () => Effect.succeed("origin"),
62+ ),
63+ ensureRemote: vi.fn<GitVcsDriver.GitVcsDriverShape["ensureRemote"]>(() =>
64+ Effect.succeed("octocat"),
65+ ),
66+ fetchRemoteBranch: vi.fn<GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"]>(
67+ () => Effect.void,
68+ ),
69+ fetchRemoteTrackingBranch: vi.fn<GitVcsDriver.GitVcsDriverShape["fetchRemoteTrackingBranch"]>(
70+ () => Effect.void,
71+ ),
72+ setBranchUpstream: vi.fn<GitVcsDriver.GitVcsDriverShape["setBranchUpstream"]>(
73+ () => Effect.void,
74+ ),
75+ switchRef: vi.fn<GitVcsDriver.GitVcsDriverShape["switchRef"]>((request) =>
76+ Effect.succeed({ refName: request.refName }),
77+ ),
78+ listLocalBranchNames: vi.fn<GitVcsDriver.GitVcsDriverShape["listLocalBranchNames"]>(() =>
79+ Effect.succeed([]),
80+ ),
81+ };
82+ const git = {
83+ ...gitMock,
84+ ...input.git,
85+ } satisfies Partial<GitVcsDriver.GitVcsDriverShape>;
86+
87+ const driver = {
88+ listRemotes: () =>
89+ Effect.succeed({
90+ remotes: [
91+ {
92+ name: "origin",
93+ url: "git@bitbucket.org:pingdotgg/t3code.git",
94+ pushUrl: Option.none(),
95+ isPrimary: true,
96+ },
97+ ],
98+ freshness: {
99+ source: "live-local" as const,
100+ observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"),
101+ expiresAt: Option.none(),
102+ },
103+ }),
104+ } satisfies Partial<VcsDriver.VcsDriverShape>;
105+
106+ const layer = BitbucketApi.layer.pipe(
107+ Layer.provide(
108+ Layer.succeed(
109+ HttpClient.HttpClient,
110+ HttpClient.make((request) => execute(request)),
111+ ),
112+ ),
113+ Layer.provide(
114+ Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
115+ resolve: () =>
116+ Effect.succeed({
117+ kind: "git",
118+ repository: {
119+ kind: "git",
120+ rootPath: "/repo",
121+ metadataPath: null,
122+ freshness: {
123+ source: "live-local" as const,
124+ observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"),
125+ expiresAt: Option.none(),
126+ },
127+ },
128+ driver: driver as unknown as VcsDriver.VcsDriverShape,
129+ }),
130+ }),
131+ ),
132+ Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(git)),
133+ Layer.provide(
134+ ConfigProvider.layer(
135+ ConfigProvider.fromEnv({
136+ env: {
137+ T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0",
138+ T3CODE_BITBUCKET_EMAIL: "user@example.com",
139+ T3CODE_BITBUCKET_API_TOKEN: "token",
140+ },
141+ }),
142+ ),
143+ ),
144+ Layer.provideMerge(NodeServices.layer),
145+ );
146+
147+ return { execute, git: gitMock, layer };
148+}
149+
150+it.effect("parses pull request responses from the Bitbucket REST API", () => {
151+ const { execute, layer } = makeLayer({
152+ response: () =>
153+ Response.json({
154+ ...bitbucketPullRequest,
155+ }),
156+ });
157+
158+ return Effect.gen(function* () {
159+ const bitbucket = yield* BitbucketApi.BitbucketApi;
160+ const result = yield* bitbucket.getPullRequest({
161+ cwd: "/repo",
162+ reference: "#42",
163+ });
164+
165+ assert.deepStrictEqual(result, {
166+ number: 42,
167+ title: "Add Bitbucket provider",
168+ url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
169+ baseRefName: "main",
170+ headRefName: "feature/source-control",
171+ state: "open",
172+ updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")),
173+ isCrossRepository: true,
174+ headRepositoryNameWithOwner: "octocat/t3code",
175+ headRepositoryOwnerLogin: "octocat",
176+ });
177+ assert.strictEqual(
178+ execute.mock.calls[0]?.[0].url,
179+ "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests/42",
180+ );
181+ }).pipe(Effect.provide(layer));
182+});
183+
184+it.effect("lists pull requests with Bitbucket state and source branch query params", () => {
185+ const { execute, layer } = makeLayer({
186+ response: () =>
187+ Response.json({
188+ values: [
189+ {
190+ ...bitbucketPullRequest,
191+ id: 7,
192+ state: "MERGED",
193+ source: {
194+ branch: { name: "feature/merged" },
195+ repository: { full_name: "pingdotgg/t3code" },
196+ },
197+ },
198+ ],
199+ }),
200+ });
201+
202+ return Effect.gen(function* () {
203+ const bitbucket = yield* BitbucketApi.BitbucketApi;
204+ const result = yield* bitbucket.listPullRequests({
205+ cwd: "/repo",
206+ headSelector: "origin:feature/merged",
207+ state: "merged",
208+ limit: 10,
209+ });
210+
211+ assert.strictEqual(result[0]?.state, "merged");
212+ const request = execute.mock.calls[0]?.[0];
213+ assert.strictEqual(
214+ request?.url,
215+ "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests",
216+ );
217+ assert.deepStrictEqual(request?.urlParams.params, [
218+ ["pagelen", "10"],
219+ ["sort", "-updated_on"],
220+ ["q", 'source.branch.name = "feature/merged" AND state = "MERGED"'],
221+ ["state", "MERGED"],
222+ ]);
223+ }).pipe(Effect.provide(layer));
224+});
225+
226+it.effect("lists closed pull requests with both closed Bitbucket states", () => {
227+ const { execute, layer } = makeLayer({
228+ response: () =>
229+ Response.json({
230+ values: [],
231+ }),
232+ });
233+
234+ return Effect.gen(function* () {
235+ const bitbucket = yield* BitbucketApi.BitbucketApi;
236+ yield* bitbucket.listPullRequests({
237+ cwd: "/repo",
238+ headSelector: "feature/closed",
239+ state: "closed",
240+ limit: 10,
241+ });
242+
243+ assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [
244+ ["pagelen", "10"],
245+ ["sort", "-updated_on"],
246+ [
247+ "q",
248+ 'source.branch.name = "feature/closed" AND (state = "DECLINED" OR state = "SUPERSEDED")',
249+ ],
250+ ["state", "DECLINED"],
251+ ["state", "SUPERSEDED"],
252+ ]);
253+ }).pipe(Effect.provide(layer));
254+});
255+
256+it.effect("expands all-state pull request listing instead of relying on Bitbucket defaults", () => {
257+ const { execute, layer } = makeLayer({
258+ response: () =>
259+ Response.json({
260+ values: [],
261+ }),
262+ });
263+
264+ return Effect.gen(function* () {
265+ const bitbucket = yield* BitbucketApi.BitbucketApi;
266+ yield* bitbucket.listPullRequests({
267+ cwd: "/repo",
268+ headSelector: "feature/all",
269+ state: "all",
270+ limit: 10,
271+ });
272+
273+ assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [
274+ ["pagelen", "10"],
275+ ["sort", "-updated_on"],
276+ [
277+ "q",
278+ 'source.branch.name = "feature/all" AND (state = "OPEN" OR state = "MERGED" OR state = "DECLINED" OR state = "SUPERSEDED")',
279+ ],
280+ ["state", "OPEN"],
281+ ["state", "MERGED"],
282+ ["state", "DECLINED"],
283+ ["state", "SUPERSEDED"],
284+ ]);
285+ }).pipe(Effect.provide(layer));
286+});
287+
288+it.effect("reads repository clone URLs and default branch", () => {
289+ const { layer } = makeLayer({
290+ response: () => Response.json(repositoryJson),
291+ });
292+
293+ return Effect.gen(function* () {
294+ const bitbucket = yield* BitbucketApi.BitbucketApi;
295+ const cloneUrls = yield* bitbucket.getRepositoryCloneUrls({
296+ cwd: "/repo",
297+ repository: "pingdotgg/t3code",
298+ });
299+ const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" });
300+
301+ assert.deepStrictEqual(cloneUrls, {
302+ nameWithOwner: "pingdotgg/t3code",
303+ url: "https://bitbucket.org/pingdotgg/t3code.git",
304+ sshUrl: "git@bitbucket.org:pingdotgg/t3code.git",
305+ });
306+ assert.strictEqual(defaultBranch, "main");
307+ }).pipe(Effect.provide(layer));
308+});
309+
310+it.effect("creates repositories through the Bitbucket REST API", () => {
311+ const { execute, layer } = makeLayer({
312+ response: () => Response.json(repositoryJson),
313+ });
314+
315+ return Effect.gen(function* () {
316+ const bitbucket = yield* BitbucketApi.BitbucketApi;
317+ const cloneUrls = yield* bitbucket.createRepository({
318+ cwd: "/repo",
319+ repository: "pingdotgg/t3code",
320+ visibility: "private",
321+ });
322+
323+ assert.deepStrictEqual(cloneUrls, {
324+ nameWithOwner: "pingdotgg/t3code",
325+ url: "https://bitbucket.org/pingdotgg/t3code.git",
326+ sshUrl: "git@bitbucket.org:pingdotgg/t3code.git",
327+ });
328+
329+ const request = execute.mock.calls[0]?.[0];
330+ assert.strictEqual(request?.url, "https://api.test.local/2.0/repositories/pingdotgg/t3code");
331+ assert.strictEqual(request?.method, "POST");
332+ assert.ok(request);
333+ const rawBody = (request.body as { readonly body?: Uint8Array }).body;
334+ assert.ok(rawBody);
335+ assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), {
336+ scm: "git",
337+ is_private: true,
338+ });
339+ }).pipe(Effect.provide(layer));
340+});
341+
342+it.effect("creates pull requests using the official REST payload shape", () => {
343+ const { execute, layer } = makeLayer({
344+ response: () => Response.json(bitbucketPullRequest),
345+ });
346+
347+ return Effect.gen(function* () {
348+ const fileSystem = yield* FileSystem.FileSystem;
349+ const bodyFile = yield* fileSystem.makeTempFileScoped({ prefix: "bitbucket-pr-body-" });
350+ yield* fileSystem.writeFileString(bodyFile, "PR body");
351+
352+ const bitbucket = yield* BitbucketApi.BitbucketApi;
353+ yield* bitbucket.createPullRequest({
354+ cwd: "/repo",
355+ baseBranch: "main",
356+ headSelector: "owner:feature/provider",
357+ title: "Provider PR",
358+ bodyFile,
359+ });
360+
361+ const request = execute.mock.calls[0]?.[0];
362+ assert.strictEqual(
363+ request?.url,
364+ "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests",
365+ );
366+ assert.strictEqual(request?.method, "POST");
367+ assert.ok(request);
368+ const rawBody = (request.body as { readonly body?: Uint8Array }).body;
369+ assert.ok(rawBody);
370+ assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), {
371+ title: "Provider PR",
372+ description: "PR body",
373+ source: {
374+ branch: { name: "feature/provider" },
375+ repository: { full_name: "owner/t3code" },
376+ },
377+ destination: {
378+ branch: { name: "main" },
379+ },
380+ });
381+ }).pipe(Effect.provide(layer), Effect.scoped);
382+});
383+
384+it.effect("reports auth status through the Bitbucket REST /user endpoint", () => {
385+ const { layer } = makeLayer({
386+ response: () => Response.json({ username: "bitbucket-user" }),
387+ });
388+
389+ return Effect.gen(function* () {
390+ const bitbucket = yield* BitbucketApi.BitbucketApi;
391+ const auth = yield* bitbucket.probeAuth;
392+
393+ assert.deepStrictEqual(auth, {
394+ status: "authenticated",
395+ account: Option.some("bitbucket-user"),
396+ host: Option.some("bitbucket.org"),
397+ detail: Option.none(),
398+ });
399+ }).pipe(Effect.provide(layer));
400+});
401+
402+it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => {
403+ const { git, layer } = makeLayer({
404+ response: () =>
405+ Response.json({
406+ ...bitbucketPullRequest,
407+ source: {
408+ branch: { name: "feature/source-control" },
409+ repository: {
410+ full_name: "pingdotgg/t3code",
411+ workspace: { slug: "pingdotgg" },
412+ },
413+ },
414+ }),
415+ });
416+
417+ return Effect.gen(function* () {
418+ const bitbucket = yield* BitbucketApi.BitbucketApi;
419+ yield* bitbucket.checkoutPullRequest({
420+ cwd: "/repo",
421+ context: {
422+ provider: {
423+ kind: "bitbucket",
424+ name: "Bitbucket",
425+ baseUrl: "https://bitbucket.org",
426+ },
427+ remoteName: "origin",
428+ remoteUrl: "git@bitbucket.org:pingdotgg/t3code.git",
429+ },
430+ reference: "42",
431+ force: true,
432+ });
433+
434+ assert.strictEqual(git.ensureRemote.mock.calls.length, 0);
435+ assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], {
436+ cwd: "/repo",
437+ remoteName: "origin",
438+ remoteBranch: "feature/source-control",
439+ localBranch: "feature/source-control",
440+ });
441+ assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], {
442+ cwd: "/repo",
443+ branch: "feature/source-control",
444+ remoteName: "origin",
445+ remoteBranch: "feature/source-control",
446+ });
447+ assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], {
448+ cwd: "/repo",
449+ refName: "feature/source-control",
450+ });
451+ }).pipe(Effect.provide(layer));
452+});
453+
454+it.effect("checks out fork pull requests through an ensured fork remote", () => {
455+ const { git, layer } = makeLayer({
456+ response: (request) => {
457+ if (request.url.endsWith("/repositories/octocat/t3code")) {
458+ return Response.json({
459+ ...repositoryJson,
460+ full_name: "octocat/t3code",
461+ links: {
462+ html: { href: "https://bitbucket.org/octocat/t3code" },
463+ clone: [
464+ { name: "https", href: "https://bitbucket.org/octocat/t3code.git" },
465+ { name: "ssh", href: "git@bitbucket.org:octocat/t3code.git" },
466+ ],
467+ },
468+ });
469+ }
470+ return Response.json({
471+ ...bitbucketPullRequest,
472+ source: {
473+ branch: { name: "main" },
474+ repository: {
475+ full_name: "octocat/t3code",
476+ workspace: { slug: "octocat" },
477+ },
478+ },
479+ });
480+ },
481+ });
482+
483+ return Effect.gen(function* () {
484+ const bitbucket = yield* BitbucketApi.BitbucketApi;
485+ yield* bitbucket.checkoutPullRequest({
486+ cwd: "/repo",
487+ reference: "42",
488+ force: true,
489+ });
490+
491+ assert.deepStrictEqual(git.ensureRemote.mock.calls[0]?.[0], {
492+ cwd: "/repo",
493+ preferredName: "octocat",
494+ url: "git@bitbucket.org:octocat/t3code.git",
495+ });
496+ assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], {
497+ cwd: "/repo",
498+ remoteName: "octocat",
499+ remoteBranch: "main",
500+ localBranch: "t3code/pr-42/main",
501+ });
502+ assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], {
503+ cwd: "/repo",
504+ branch: "t3code/pr-42/main",
505+ remoteName: "octocat",
506+ remoteBranch: "main",
507+ });
508+ assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], {
509+ cwd: "/repo",
510+ refName: "t3code/pr-42/main",
511+ });
512+ }).pipe(Effect.provide(layer));
513+});
apps/server/src/sourceControl/BitbucketApi.ts+703−0
@@ -0,0 +1,703 @@
1+import { Config, Context, Effect, FileSystem, Layer, Option, Schema } from "effect";
2+import {
3+ TrimmedNonEmptyString,
4+ type SourceControlProviderAuth,
5+ type SourceControlRepositoryCloneUrls,
6+ type SourceControlRepositoryVisibility,
7+} from "@t3tools/contracts";
8+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
9+import { sanitizeBranchFragment } from "@t3tools/shared/git";
10+import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
11+
12+import * as BitbucketPullRequests from "./bitbucketPullRequests.ts";
13+import * as SourceControlProvider from "./SourceControlProvider.ts";
14+import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
15+import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
16+
17+const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0";
18+
19+const BitbucketApiEnvConfig = Config.all({
20+ baseUrl: Config.string("T3CODE_BITBUCKET_API_BASE_URL").pipe(
21+ Config.withDefault(DEFAULT_API_BASE_URL),
22+ ),
23+ accessToken: Config.string("T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option),
24+ email: Config.string("T3CODE_BITBUCKET_EMAIL").pipe(Config.option),
25+ apiToken: Config.string("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option),
26+});
27+
28+export class BitbucketApiError extends Schema.TaggedErrorClass<BitbucketApiError>()(
29+ "BitbucketApiError",
30+ {
31+ operation: Schema.String,
32+ detail: Schema.String,
33+ status: Schema.optional(Schema.Number),
34+ cause: Schema.optional(Schema.Defect),
35+ },
36+) {
37+ override get message(): string {
38+ return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
39+ }
40+}
41+
42+const RawBitbucketRepositorySchema = Schema.Struct({
43+ full_name: TrimmedNonEmptyString,
44+ links: Schema.Struct({
45+ html: Schema.optional(
46+ Schema.Struct({
47+ href: TrimmedNonEmptyString,
48+ }),
49+ ),
50+ clone: Schema.optional(
51+ Schema.Array(
52+ Schema.Struct({
53+ name: TrimmedNonEmptyString,
54+ href: TrimmedNonEmptyString,
55+ }),
56+ ),
57+ ),
58+ }),
59+ mainbranch: Schema.optional(
60+ Schema.NullOr(
61+ Schema.Struct({
62+ name: TrimmedNonEmptyString,
63+ }),
64+ ),
65+ ),
66+});
67+
68+const BitbucketUserSchema = Schema.Struct({
69+ username: Schema.optional(TrimmedNonEmptyString),
70+ display_name: Schema.optional(TrimmedNonEmptyString),
71+ account_id: Schema.optional(TrimmedNonEmptyString),
72+});
73+
74+export interface BitbucketRepositoryLocator {
75+ readonly workspace: string;
76+ readonly repoSlug: string;
77+}
78+
79+export interface BitbucketApiShape {
80+ readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;
81+ readonly listPullRequests: (input: {
82+ readonly cwd: string;
83+ readonly context?: SourceControlProvider.SourceControlProviderContext;
84+ readonly headSelector: string;
85+ readonly source?: SourceControlProvider.SourceControlRefSelector;
86+ readonly state: "open" | "closed" | "merged" | "all";
87+ readonly limit?: number;
88+ }) => Effect.Effect<
89+ ReadonlyArray<BitbucketPullRequests.NormalizedBitbucketPullRequestRecord>,
90+ BitbucketApiError
91+ >;
92+ readonly getPullRequest: (input: {
93+ readonly cwd: string;
94+ readonly context?: SourceControlProvider.SourceControlProviderContext;
95+ readonly reference: string;
96+ }) => Effect.Effect<
97+ BitbucketPullRequests.NormalizedBitbucketPullRequestRecord,
98+ BitbucketApiError
99+ >;
100+ readonly getRepositoryCloneUrls: (input: {
101+ readonly cwd: string;
102+ readonly context?: SourceControlProvider.SourceControlProviderContext;
103+ readonly repository: string;
104+ }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;
105+ readonly createRepository: (input: {
106+ readonly cwd: string;
107+ readonly repository: string;
108+ readonly visibility: SourceControlRepositoryVisibility;
109+ }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;
110+ readonly createPullRequest: (input: {
111+ readonly cwd: string;
112+ readonly context?: SourceControlProvider.SourceControlProviderContext;
113+ readonly baseBranch: string;
114+ readonly headSelector: string;
115+ readonly source?: SourceControlProvider.SourceControlRefSelector;
116+ readonly target?: SourceControlProvider.SourceControlRefSelector;
117+ readonly title: string;
118+ readonly bodyFile: string;
119+ }) => Effect.Effect<void, BitbucketApiError>;
120+ readonly getDefaultBranch: (input: {
121+ readonly cwd: string;
122+ readonly context?: SourceControlProvider.SourceControlProviderContext;
123+ }) => Effect.Effect<string | null, BitbucketApiError>;
124+ readonly checkoutPullRequest: (input: {
125+ readonly cwd: string;
126+ readonly context?: SourceControlProvider.SourceControlProviderContext;
127+ readonly reference: string;
128+ readonly force?: boolean;
129+ }) => Effect.Effect<void, BitbucketApiError>;
130+}
131+
132+export class BitbucketApi extends Context.Service<BitbucketApi, BitbucketApiShape>()(
133+ "t3/source-control/BitbucketApi",
134+) {}
135+
136+function nonEmpty(value: string | undefined): Option.Option<string> {
137+ const trimmed = value?.trim();
138+ return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
139+}
140+
141+function normalizeChangeRequestId(reference: string): string {
142+ const trimmed = reference.trim().replace(/^#/, "");
143+ const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec(
144+ trimmed,
145+ );
146+ return urlMatch?.[1] ?? trimmed;
147+}
148+
149+function sourceWorkspace(input: {
150+ readonly headSelector: string;
151+ readonly source?: SourceControlProvider.SourceControlRefSelector;
152+}): string | undefined {
153+ if (input.source?.owner) return input.source.owner;
154+ return SourceControlProvider.parseSourceControlOwnerRef(input.headSelector)?.owner;
155+}
156+
157+function toBitbucketStates(state: "open" | "closed" | "merged" | "all"): ReadonlyArray<string> {
158+ switch (state) {
159+ case "open":
160+ return ["OPEN"];
161+ case "closed":
162+ return ["DECLINED", "SUPERSEDED"];
163+ case "merged":
164+ return ["MERGED"];
165+ case "all":
166+ return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"];
167+ }
168+}
169+
170+function bitbucketQueryString(filters: ReadonlyArray<string>): string {
171+ return filters.join(" AND ");
172+}
173+
174+function bitbucketStateFilter(states: ReadonlyArray<string>): string {
175+ return states.length === 1
176+ ? `state = "${states[0]}"`
177+ : `(${states.map((state) => `state = "${state}"`).join(" OR ")})`;
178+}
179+
180+function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator | null {
181+ const normalized = value.trim().replace(/\.git$/u, "");
182+ const parts = normalized.split("/").filter((part) => part.length > 0);
183+ if (parts.length < 2) return null;
184+ const workspace = parts.at(-2);
185+ const repoSlug = parts.at(-1);
186+ return workspace && repoSlug ? { workspace, repoSlug } : null;
187+}
188+
189+function requireRepositoryLocator(
190+ operation: string,
191+ repository: string,
192+): Effect.Effect<BitbucketRepositoryLocator, BitbucketApiError> {
193+ const locator = parseBitbucketRepositorySlug(repository);
194+ return locator
195+ ? Effect.succeed(locator)
196+ : Effect.fail(
197+ new BitbucketApiError({
198+ operation,
199+ detail: "Bitbucket repositories must be specified as workspace/repository.",
200+ }),
201+ );
202+}
203+
204+function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null {
205+ const trimmed = remoteUrl.trim();
206+ if (trimmed.startsWith("git@")) {
207+ const pathStart = trimmed.indexOf(":");
208+ return pathStart < 0 ? null : parseBitbucketRepositorySlug(trimmed.slice(pathStart + 1));
209+ }
210+
211+ try {
212+ return parseBitbucketRepositorySlug(new URL(trimmed).pathname);
213+ } catch {
214+ return null;
215+ }
216+}
217+
218+function normalizeRepositoryCloneUrls(
219+ raw: Schema.Schema.Type<typeof RawBitbucketRepositorySchema>,
220+): SourceControlRepositoryCloneUrls {
221+ const httpClone =
222+ raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ??
223+ raw.links.html?.href;
224+ const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href;
225+
226+ return {
227+ nameWithOwner: raw.full_name,
228+ url: httpClone ?? raw.links.html?.href ?? raw.full_name,
229+ sshUrl: sshClone ?? httpClone ?? raw.full_name,
230+ };
231+}
232+
233+function shouldPreferSshRemote(originRemoteUrl: string | null): boolean {
234+ const trimmed = originRemoteUrl?.trim() ?? "";
235+ return trimmed.startsWith("git@") || trimmed.startsWith("ssh://");
236+}
237+
238+function selectCloneUrl(input: {
239+ readonly cloneUrls: SourceControlRepositoryCloneUrls;
240+ readonly originRemoteUrl: string | null;
241+}): string {
242+ return shouldPreferSshRemote(input.originRemoteUrl)
243+ ? input.cloneUrls.sshUrl
244+ : input.cloneUrls.url;
245+}
246+
247+function checkoutBranchName(input: {
248+ readonly pullRequestId: number;
249+ readonly headBranch: string;
250+ readonly isCrossRepository: boolean;
251+}): string {
252+ if (!input.isCrossRepository) {
253+ return input.headBranch;
254+ }
255+
256+ return `t3code/pr-${input.pullRequestId}/${sanitizeBranchFragment(input.headBranch)}`;
257+}
258+
259+function repositoryNameWithOwner(
260+ repository: Schema.Schema.Type<
261+ typeof BitbucketPullRequests.BitbucketPullRequestSchema
262+ >["source"]["repository"],
263+): string | null {
264+ const fullName = repository?.full_name?.trim() ?? "";
265+ return fullName.length > 0 ? fullName : null;
266+}
267+
268+function repositoryOwnerName(repositoryName: string): string {
269+ return repositoryName.split("/")[0]?.trim() || "bitbucket";
270+}
271+
272+function authFromConfig(
273+ config: Config.Success<typeof BitbucketApiEnvConfig>,
274+): SourceControlProviderAuth {
275+ if (Option.isSome(config.accessToken)) {
276+ return {
277+ status: "unknown",
278+ account: Option.none(),
279+ host: Option.some("bitbucket.org"),
280+ detail: Option.some("Bitbucket access token is configured."),
281+ };
282+ }
283+
284+ if (Option.isSome(config.email) && Option.isSome(config.apiToken)) {
285+ return {
286+ status: "unknown",
287+ account: config.email,
288+ host: Option.some("bitbucket.org"),
289+ detail: Option.some("Bitbucket API token is configured."),
290+ };
291+ }
292+
293+ return {
294+ status: "unauthenticated",
295+ account: Option.none(),
296+ host: Option.some("bitbucket.org"),
297+ detail: Option.some(
298+ "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.",
299+ ),
300+ };
301+}
302+
303+function requestError(operation: string, cause: unknown): BitbucketApiError {
304+ return new BitbucketApiError({
305+ operation,
306+ detail: cause instanceof Error ? cause.message : String(cause),
307+ cause,
308+ });
309+}
310+
311+function isBitbucketApiError(cause: unknown): cause is BitbucketApiError {
312+ return Schema.is(BitbucketApiError)(cause);
313+}
314+
315+function responseError(
316+ operation: string,
317+ response: HttpClientResponse.HttpClientResponse,
318+): Effect.Effect<never, BitbucketApiError> {
319+ return response.text.pipe(
320+ Effect.catch(() => Effect.succeed("")),
321+ Effect.flatMap((body) =>
322+ Effect.fail(
323+ new BitbucketApiError({
324+ operation,
325+ status: response.status,
326+ detail:
327+ body.trim().length > 0
328+ ? `Bitbucket returned HTTP ${response.status}: ${body.trim()}`
329+ : `Bitbucket returned HTTP ${response.status}.`,
330+ }),
331+ ),
332+ ),
333+ );
334+}
335+
336+export const make = Effect.fn("makeBitbucketApi")(function* () {
337+ const config = yield* BitbucketApiEnvConfig;
338+ const httpClient = yield* HttpClient.HttpClient;
339+ const fileSystem = yield* FileSystem.FileSystem;
340+ const git = yield* GitVcsDriver.GitVcsDriver;
341+ const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;
342+
343+ const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`;
344+
345+ const withAuth = (request: HttpClientRequest.HttpClientRequest) => {
346+ if (Option.isSome(config.accessToken)) {
347+ return request.pipe(HttpClientRequest.bearerToken(config.accessToken.value));
348+ }
349+ if (Option.isSome(config.email) && Option.isSome(config.apiToken)) {
350+ return request.pipe(HttpClientRequest.basicAuth(config.email.value, config.apiToken.value));
351+ }
352+ return request;
353+ };
354+
355+ const decodeResponse = <S extends Schema.Top>(
356+ operation: string,
357+ schema: S,
358+ response: HttpClientResponse.HttpClientResponse,
359+ ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> =>
360+ HttpClientResponse.matchStatus({
361+ "2xx": (success) =>
362+ HttpClientResponse.schemaBodyJson(schema)(success).pipe(
363+ Effect.mapError(
364+ (cause) =>
365+ new BitbucketApiError({
366+ operation,
367+ detail: "Bitbucket returned invalid JSON for the requested resource.",
368+ cause,
369+ }),
370+ ),
371+ ),
372+ orElse: (failed) => responseError(operation, failed),
373+ })(response);
374+
375+ const executeJson = <S extends Schema.Top>(
376+ operation: string,
377+ request: HttpClientRequest.HttpClientRequest,
378+ schema: S,
379+ ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> =>
380+ httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe(
381+ Effect.mapError((cause) => requestError(operation, cause)),
382+ Effect.flatMap((response) => decodeResponse(operation, schema, response)),
383+ );
384+
385+ const resolveRepository = Effect.fn("BitbucketApi.resolveRepository")(function* (input: {
386+ readonly cwd: string;
387+ readonly context?: SourceControlProvider.SourceControlProviderContext;
388+ readonly repository?: string;
389+ }) {
390+ const fromRepository =
391+ input.repository !== undefined ? parseBitbucketRepositorySlug(input.repository) : null;
392+ if (fromRepository) return fromRepository;
393+
394+ const fromContext =
395+ input.context?.provider.kind === "bitbucket"
396+ ? parseBitbucketRemoteUrl(input.context.remoteUrl)
397+ : null;
398+ if (fromContext) return fromContext;
399+
400+ const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe(
401+ Effect.mapError(
402+ (cause) =>
403+ new BitbucketApiError({
404+ operation: "resolveRepository",
405+ detail: `Failed to resolve VCS repository for ${input.cwd}.`,
406+ cause,
407+ }),
408+ ),
409+ );
410+ const remotes = yield* handle.driver.listRemotes(input.cwd).pipe(
411+ Effect.mapError(
412+ (cause) =>
413+ new BitbucketApiError({
414+ operation: "resolveRepository",
415+ detail: `Failed to list remotes for ${input.cwd}.`,
416+ cause,
417+ }),
418+ ),
419+ );
420+
421+ for (const remote of remotes.remotes) {
422+ if (detectSourceControlProviderFromRemoteUrl(remote.url)?.kind !== "bitbucket") continue;
423+ const parsed = parseBitbucketRemoteUrl(remote.url);
424+ if (parsed) return parsed;
425+ }
426+
427+ return yield* new BitbucketApiError({
428+ operation: "resolveRepository",
429+ detail: `No Bitbucket repository remote was detected for ${input.cwd}.`,
430+ });
431+ });
432+
433+ const getRepository = (input: {
434+ readonly cwd: string;
435+ readonly context?: SourceControlProvider.SourceControlProviderContext;
436+ readonly repository?: string;
437+ }) =>
438+ resolveRepository(input).pipe(
439+ Effect.flatMap((repository) =>
440+ executeJson(
441+ "getRepository",
442+ HttpClientRequest.get(
443+ apiUrl(
444+ `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`,
445+ ),
446+ ),
447+ RawBitbucketRepositorySchema,
448+ ),
449+ ),
450+ );
451+
452+ const getRawPullRequestFromRepository = (
453+ repository: BitbucketRepositoryLocator,
454+ reference: string,
455+ ) =>
456+ executeJson(
457+ "getPullRequest",
458+ HttpClientRequest.get(
459+ apiUrl(
460+ `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`,
461+ ),
462+ ),
463+ BitbucketPullRequests.BitbucketPullRequestSchema,
464+ );
465+
466+ const getRawPullRequest = (input: {
467+ readonly cwd: string;
468+ readonly context?: SourceControlProvider.SourceControlProviderContext;
469+ readonly reference: string;
470+ }) =>
471+ resolveRepository(input).pipe(
472+ Effect.flatMap((repository) => getRawPullRequestFromRepository(repository, input.reference)),
473+ );
474+
475+ const readConfigValueNullable = (cwd: string, key: string) =>
476+ git.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));
477+
478+ const resolveCheckoutRemote = Effect.fn("BitbucketApi.resolveCheckoutRemote")(function* (input: {
479+ readonly cwd: string;
480+ readonly context?: SourceControlProvider.SourceControlProviderContext;
481+ readonly destinationRepository: BitbucketRepositoryLocator;
482+ readonly sourceRepositoryName: string;
483+ readonly isCrossRepository: boolean;
484+ }) {
485+ if (
486+ input.context?.provider.kind === "bitbucket" &&
487+ !input.isCrossRepository &&
488+ parseBitbucketRemoteUrl(input.context.remoteUrl) !== null
489+ ) {
490+ return input.context.remoteName;
491+ }
492+
493+ if (!input.isCrossRepository) {
494+ const remoteName = yield* git
495+ .resolvePrimaryRemoteName(input.cwd)
496+ .pipe(Effect.catch(() => Effect.succeed(null)));
497+ if (remoteName) return remoteName;
498+ }
499+
500+ const cloneUrls = yield* getRepository({
501+ cwd: input.cwd,
502+ repository: input.sourceRepositoryName,
503+ ...(input.context ? { context: input.context } : {}),
504+ }).pipe(Effect.map(normalizeRepositoryCloneUrls));
505+ const originRemoteUrl = yield* readConfigValueNullable(input.cwd, "remote.origin.url");
506+ return yield* git.ensureRemote({
507+ cwd: input.cwd,
508+ preferredName: input.isCrossRepository
509+ ? repositoryOwnerName(input.sourceRepositoryName)
510+ : input.destinationRepository.workspace,
511+ url: selectCloneUrl({ cloneUrls, originRemoteUrl }),
512+ });
513+ });
514+
515+ return BitbucketApi.of({
516+ probeAuth: executeJson(
517+ "probeAuth",
518+ HttpClientRequest.get(apiUrl("/user")),
519+ BitbucketUserSchema,
520+ ).pipe(
521+ Effect.map((user) => ({
522+ status: "authenticated" as const,
523+ account: nonEmpty(user.username ?? user.display_name ?? user.account_id),
524+ host: Option.some("bitbucket.org"),
525+ detail: Option.none<string>(),
526+ })),
527+ Effect.catch(() => Effect.succeed(authFromConfig(config))),
528+ ),
529+ listPullRequests: (input) =>
530+ resolveRepository(input).pipe(
531+ Effect.flatMap((repository) => {
532+ const states = toBitbucketStates(input.state);
533+ const query: Record<string, string | ReadonlyArray<string>> = {
534+ pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))),
535+ sort: "-updated_on",
536+ q: bitbucketQueryString([
537+ `source.branch.name = "${SourceControlProvider.sourceBranch(input).replaceAll('"', '\\"')}"`,
538+ bitbucketStateFilter(states),
539+ ]),
540+ state: states,
541+ };
542+
543+ return executeJson(
544+ "listPullRequests",
545+ HttpClientRequest.get(
546+ apiUrl(
547+ `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`,
548+ ),
549+ { urlParams: query },
550+ ),
551+ BitbucketPullRequests.BitbucketPullRequestListSchema,
552+ );
553+ }),
554+ Effect.map((list) =>
555+ list.values.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord),
556+ ),
557+ ),
558+ getPullRequest: (input) =>
559+ getRawPullRequest(input).pipe(
560+ Effect.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord),
561+ ),
562+ getRepositoryCloneUrls: (input) =>
563+ getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)),
564+ createRepository: (input) =>
565+ requireRepositoryLocator("createRepository", input.repository).pipe(
566+ Effect.flatMap((repository) =>
567+ executeJson(
568+ "createRepository",
569+ HttpClientRequest.post(
570+ apiUrl(
571+ `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`,
572+ ),
573+ ).pipe(
574+ HttpClientRequest.bodyJsonUnsafe({
575+ scm: "git",
576+ is_private: input.visibility === "private",
577+ }),
578+ ),
579+ RawBitbucketRepositorySchema,
580+ ),
581+ ),
582+ Effect.map(normalizeRepositoryCloneUrls),
583+ ),
584+ createPullRequest: (input) =>
585+ Effect.gen(function* () {
586+ const repository = yield* resolveRepository(input);
587+ const description = yield* fileSystem.readFileString(input.bodyFile).pipe(
588+ Effect.mapError(
589+ (cause) =>
590+ new BitbucketApiError({
591+ operation: "createPullRequest",
592+ detail: `Failed to read pull request body file ${input.bodyFile}.`,
593+ cause,
594+ }),
595+ ),
596+ );
597+ const sourceOwner = sourceWorkspace(input);
598+ const body = {
599+ title: input.title,
600+ description,
601+ source: {
602+ branch: {
603+ name: SourceControlProvider.sourceBranch(input),
604+ },
605+ ...(sourceOwner
606+ ? {
607+ repository: {
608+ full_name: `${sourceOwner}/${input.source?.repository ?? repository.repoSlug}`,
609+ },
610+ }
611+ : {}),
612+ },
613+ destination: {
614+ branch: {
615+ name: input.target?.refName ?? input.baseBranch,
616+ },
617+ },
618+ };
619+
620+ yield* executeJson(
621+ "createPullRequest",
622+ HttpClientRequest.post(
623+ apiUrl(
624+ `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`,
625+ ),
626+ ).pipe(HttpClientRequest.bodyJsonUnsafe(body)),
627+ BitbucketPullRequests.BitbucketPullRequestSchema,
628+ );
629+ }),
630+ getDefaultBranch: (input) =>
631+ getRepository(input).pipe(Effect.map((repository) => repository.mainbranch?.name ?? null)),
632+ // Bitbucket Cloud pull requests are Git-backed and Bitbucket does not provide
633+ // an official checkout CLI. This provider-local path uses GitVcsDriver as a
634+ // narrow escape hatch to materialize Bitbucket PR refs. Do not generalize this
635+ // as the source-control provider model: if we support non-Git-compatible
636+ // hosting providers or native JJ/Sapling checkout flows, move this into a
637+ // VCS-specific change-request checkout capability.
638+ checkoutPullRequest: (input) =>
639+ Effect.gen(function* () {
640+ const destinationRepository = yield* resolveRepository(input);
641+ const pullRequest = yield* getRawPullRequestFromRepository(
642+ destinationRepository,
643+ input.reference,
644+ );
645+ const destinationRepositoryName =
646+ repositoryNameWithOwner(pullRequest.destination.repository) ??
647+ `${destinationRepository.workspace}/${destinationRepository.repoSlug}`;
648+ const sourceRepositoryName =
649+ repositoryNameWithOwner(pullRequest.source.repository) ?? destinationRepositoryName;
650+ const isCrossRepository = sourceRepositoryName !== destinationRepositoryName;
651+ const remoteName = yield* resolveCheckoutRemote({
652+ cwd: input.cwd,
653+ destinationRepository,
654+ sourceRepositoryName,
655+ isCrossRepository,
656+ ...(input.context ? { context: input.context } : {}),
657+ });
658+ const remoteBranch = pullRequest.source.branch.name;
659+ const localBranch = checkoutBranchName({
660+ pullRequestId: pullRequest.id,
661+ headBranch: remoteBranch,
662+ isCrossRepository,
663+ });
664+ const localBranchNames = yield* git.listLocalBranchNames(input.cwd);
665+ const localBranchExists = localBranchNames.includes(localBranch);
666+
667+ if (input.force === true || !localBranchExists) {
668+ yield* git.fetchRemoteBranch({
669+ cwd: input.cwd,
670+ remoteName,
671+ remoteBranch,
672+ localBranch,
673+ });
674+ } else {
675+ yield* git.fetchRemoteTrackingBranch({
676+ cwd: input.cwd,
677+ remoteName,
678+ remoteBranch,
679+ });
680+ }
681+
682+ yield* git.setBranchUpstream({
683+ cwd: input.cwd,
684+ branch: localBranch,
685+ remoteName,
686+ remoteBranch,
687+ });
688+ yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch }));
689+ }).pipe(
690+ Effect.mapError((cause) =>
691+ isBitbucketApiError(cause)
692+ ? cause
693+ : new BitbucketApiError({
694+ operation: "checkoutPullRequest",
695+ detail: cause instanceof Error ? cause.message : String(cause),
696+ cause,
697+ }),
698+ ),
699+ ),
700+ });
701+});
702+
703+export const layer = Layer.effect(BitbucketApi, make());
apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts+126−0
@@ -0,0 +1,126 @@
1+import { assert, it } from "@effect/vitest";
2+import { Effect, Layer, Option } from "effect";
3+
4+import * as BitbucketApi from "./BitbucketApi.ts";
5+import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
6+
7+function makeProvider(bitbucket: Partial<BitbucketApi.BitbucketApiShape>) {
8+ return BitbucketSourceControlProvider.make().pipe(
9+ Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)),
10+ );
11+}
12+
13+it.effect("maps Bitbucket PR summaries into provider-neutral change requests", () =>
14+ Effect.gen(function* () {
15+ const provider = yield* makeProvider({
16+ getPullRequest: () =>
17+ Effect.succeed({
18+ number: 42,
19+ title: "Add Bitbucket provider",
20+ url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
21+ baseRefName: "main",
22+ headRefName: "feature/source-control",
23+ state: "open",
24+ updatedAt: Option.none(),
25+ isCrossRepository: true,
26+ headRepositoryNameWithOwner: "fork/t3code",
27+ headRepositoryOwnerLogin: "fork",
28+ }),
29+ });
30+
31+ const changeRequest = yield* provider.getChangeRequest({
32+ cwd: "/repo",
33+ reference: "42",
34+ });
35+
36+ assert.deepStrictEqual(changeRequest, {
37+ provider: "bitbucket",
38+ number: 42,
39+ title: "Add Bitbucket provider",
40+ url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
41+ baseRefName: "main",
42+ headRefName: "feature/source-control",
43+ state: "open",
44+ updatedAt: Option.none(),
45+ isCrossRepository: true,
46+ headRepositoryNameWithOwner: "fork/t3code",
47+ headRepositoryOwnerLogin: "fork",
48+ });
49+ }),
50+);
51+
52+it.effect("lists Bitbucket PRs through provider-neutral input names", () =>
53+ Effect.gen(function* () {
54+ let listInput: Parameters<BitbucketApi.BitbucketApiShape["listPullRequests"]>[0] | null = null;
55+ const provider = yield* makeProvider({
56+ listPullRequests: (input) => {
57+ listInput = input;
58+ return Effect.succeed([]);
59+ },
60+ });
61+
62+ yield* provider.listChangeRequests({
63+ cwd: "/repo",
64+ headSelector: "feature/provider",
65+ state: "all",
66+ limit: 10,
67+ });
68+
69+ assert.deepStrictEqual(listInput, {
70+ cwd: "/repo",
71+ headSelector: "feature/provider",
72+ state: "all",
73+ limit: 10,
74+ });
75+ }),
76+);
77+
78+it.effect("creates Bitbucket PRs through provider-neutral input names", () =>
79+ Effect.gen(function* () {
80+ let createInput: Parameters<BitbucketApi.BitbucketApiShape["createPullRequest"]>[0] | null =
81+ null;
82+ const provider = yield* makeProvider({
83+ createPullRequest: (input) => {
84+ createInput = input;
85+ return Effect.void;
86+ },
87+ });
88+
89+ yield* provider.createChangeRequest({
90+ cwd: "/repo",
91+ baseRefName: "main",
92+ headSelector: "owner:feature/provider",
93+ title: "Provider PR",
94+ bodyFile: "/tmp/body.md",
95+ });
96+
97+ assert.deepStrictEqual(createInput, {
98+ cwd: "/repo",
99+ baseBranch: "main",
100+ headSelector: "owner:feature/provider",
101+ source: {
102+ owner: "owner",
103+ refName: "feature/provider",
104+ },
105+ title: "Provider PR",
106+ bodyFile: "/tmp/body.md",
107+ });
108+ }),
109+);
110+
111+it.effect("uses Bitbucket API repository detection for default branch lookup", () =>
112+ Effect.gen(function* () {
113+ let cwdInput: string | null = null;
114+ const provider = yield* makeProvider({
115+ getDefaultBranch: (input) => {
116+ cwdInput = input.cwd;
117+ return Effect.succeed("main");
118+ },
119+ });
120+
121+ const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" });
122+
123+ assert.strictEqual(defaultBranch, "main");
124+ assert.strictEqual(cwdInput, "/repo");
125+ }),
126+);
apps/server/src/sourceControl/BitbucketSourceControlProvider.ts+126−0
@@ -0,0 +1,126 @@
1+import { Effect, Layer, Option } from "effect";
2+import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
3+
4+import * as BitbucketApi from "./BitbucketApi.ts";
5+import * as BitbucketPullRequests from "./bitbucketPullRequests.ts";
6+import * as SourceControlProvider from "./SourceControlProvider.ts";
7+import type * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
8+
9+function providerError(
10+ operation: string,
11+ cause: BitbucketApi.BitbucketApiError,
12+): SourceControlProviderError {
13+ return new SourceControlProviderError({
14+ provider: "bitbucket",
15+ operation,
16+ detail: cause.detail,
17+ cause,
18+ });
19+}
20+
21+function toChangeRequest(
22+ summary: BitbucketPullRequests.NormalizedBitbucketPullRequestRecord,
23+): ChangeRequest {
24+ return {
25+ provider: "bitbucket",
26+ number: summary.number,
27+ title: summary.title,
28+ url: summary.url,
29+ baseRefName: summary.baseRefName,
30+ headRefName: summary.headRefName,
31+ state: summary.state,
32+ updatedAt: summary.updatedAt ?? Option.none(),
33+ ...(summary.isCrossRepository !== undefined
34+ ? { isCrossRepository: summary.isCrossRepository }
35+ : {}),
36+ ...(summary.headRepositoryNameWithOwner !== undefined
37+ ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner }
38+ : {}),
39+ ...(summary.headRepositoryOwnerLogin !== undefined
40+ ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin }
41+ : {}),
42+ };
43+}
44+
45+export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () {
46+ const bitbucket = yield* BitbucketApi.BitbucketApi;
47+
48+ return SourceControlProvider.SourceControlProvider.of({
49+ kind: "bitbucket",
50+ listChangeRequests: (input) => {
51+ const source = SourceControlProvider.sourceControlRefFromInput(input);
52+ return bitbucket
53+ .listPullRequests({
54+ cwd: input.cwd,
55+ ...(input.context ? { context: input.context } : {}),
56+ headSelector: input.headSelector,
57+ ...(source ? { source } : {}),
58+ state: input.state,
59+ ...(input.limit !== undefined ? { limit: input.limit } : {}),
60+ })
61+ .pipe(
62+ Effect.map((items) => items.map(toChangeRequest)),
63+ Effect.mapError((error) => providerError("listChangeRequests", error)),
64+ );
65+ },
66+ getChangeRequest: (input) =>
67+ bitbucket.getPullRequest(input).pipe(
68+ Effect.map(toChangeRequest),
69+ Effect.mapError((error) => providerError("getChangeRequest", error)),
70+ ),
71+ createChangeRequest: (input) => {
72+ const source = SourceControlProvider.sourceControlRefFromInput(input);
73+ return bitbucket
74+ .createPullRequest({
75+ cwd: input.cwd,
76+ ...(input.context ? { context: input.context } : {}),
77+ baseBranch: input.baseRefName,
78+ headSelector: input.headSelector,
79+ ...(source ? { source } : {}),
80+ ...(input.target ? { target: input.target } : {}),
81+ title: input.title,
82+ bodyFile: input.bodyFile,
83+ })
84+ .pipe(Effect.mapError((error) => providerError("createChangeRequest", error)));
85+ },
86+ getRepositoryCloneUrls: (input) =>
87+ bitbucket
88+ .getRepositoryCloneUrls(input)
89+ .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))),
90+ createRepository: (input) =>
91+ bitbucket
92+ .createRepository(input)
93+ .pipe(Effect.mapError((error) => providerError("createRepository", error))),
94+ getDefaultBranch: (input) =>
95+ bitbucket
96+ .getDefaultBranch({
97+ cwd: input.cwd,
98+ ...(input.context ? { context: input.context } : {}),
99+ })
100+ .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))),
101+ checkoutChangeRequest: (input) =>
102+ bitbucket
103+ .checkoutPullRequest({
104+ cwd: input.cwd,
105+ ...(input.context ? { context: input.context } : {}),
106+ reference: input.reference,
107+ ...(input.force !== undefined ? { force: input.force } : {}),
108+ })
109+ .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))),
110+ });
111+});
112+
113+export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make());
114+
115+export const makeDiscovery = Effect.fn("makeBitbucketSourceControlProviderDiscovery")(function* () {
116+ const bitbucket = yield* BitbucketApi.BitbucketApi;
117+
118+ return {
119+ type: "api",
120+ kind: "bitbucket",
121+ label: "Bitbucket",
122+ installHint:
123+ "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request and repository scopes).",
124+ probeAuth: bitbucket.probeAuth,
125+ } satisfies SourceControlProviderDiscovery.SourceControlApiDiscoverySpec;
126+});
apps/server/src/sourceControl/GitHubCli.test.ts+5−6
@@ -1,13 +1,12 @@
1−import { assert, it } from "@effect/vitest";
1+import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest";
22 import { Effect, Layer } from "effect";
33 import { ChildProcessSpawner } from "effect/unstable/process";
44 import { VcsProcessExitError, type VcsError } from "@t3tools/contracts";
5−import { afterEach, describe, expect, vi } from "vitest";
65
7−import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
6+import * as VcsProcess from "../vcs/VcsProcess.ts";
87 import * as GitHubCli from "./GitHubCli.ts";
98
10−const processOutput = (stdout: string): VcsProcessOutput => ({
9+const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
1110 exitCode: ChildProcessSpawner.ExitCode(0),
1211 stdout,
1312 stderr: "",
@@ -15,11 +14,11 @@ const processOutput = (stdout: string): VcsProcessOutput => ({ · 1 unmodified line
1514 stderrTruncated: false,
1615 });
1716
18−const mockRun = vi.fn<(input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>>();
17+const mockRun = vi.fn<VcsProcess.VcsProcessShape["run"]>();
1918
2019 const layer = GitHubCli.layer.pipe(
2120 Layer.provide(
22− Layer.mock(VcsProcess)({
21+ Layer.mock(VcsProcess.VcsProcess)({
2322 run: mockRun,
2423 }),
2524 ),
apps/server/src/sourceControl/GitHubCli.ts+8−12
@@ -6,12 +6,8 @@ import { · 5 unmodified lines
66 type VcsError,
77 } from "@t3tools/contracts";
88
9−import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
10−import {
11− decodeGitHubPullRequestJson,
12− decodeGitHubPullRequestListJson,
13− formatGitHubJsonDecodeError,
14−} from "./gitHubPullRequests.ts";
9+import * as VcsProcess from "../vcs/VcsProcess.ts";
10+import * as GitHubPullRequests from "./gitHubPullRequests.ts";
1511
1612 const DEFAULT_TIMEOUT_MS = 30_000;
1713
@@ -48,7 +44,7 @@ export interface GitHubCliShape { · 30 unmodified lines
4844 readonly cwd: string;
4945 readonly args: ReadonlyArray<string>;
5046 readonly timeoutMs?: number;
51− }) => Effect.Effect<VcsProcessOutput, GitHubCliError>;
47+ }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCliError>;
5248
5349 readonly listOpenPullRequests: (input: {
5450 readonly cwd: string;
@@ -226,7 +222,7 @@ function decodeGitHubJson<S extends Schema.Top>( · 171 unmodified lines
226222 }
227223
228224 export const make = Effect.fn("makeGitHubCli")(function* () {
229− const process = yield* VcsProcess;
225+ const process = yield* VcsProcess.VcsProcess;
230226
231227 const execute: GitHubCliShape["execute"] = (input) =>
232228 process
@@ -261,13 +257,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { · 28 unmodified lines
261257 Effect.flatMap((raw) =>
262258 raw.length === 0
263259 ? Effect.succeed([])
264− : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
260+ : Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe(
265261 Effect.flatMap((decoded) => {
266262 if (!Result.isSuccess(decoded)) {
267263 return Effect.fail(
268264 new GitHubCliError({
269265 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)}`,
271267 cause: decoded.failure,
272268 }),
273269 );
@@ -293,13 +289,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { · 19 unmodified lines
293289 }).pipe(
294290 Effect.map((result) => result.stdout.trim()),
295291 Effect.flatMap((raw) =>
296− Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe(
292+ Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestJson(raw)).pipe(
297293 Effect.flatMap((decoded) => {
298294 if (!Result.isSuccess(decoded)) {
299295 return Effect.fail(
300296 new GitHubCliError({
301297 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)}`,
303299 cause: decoded.failure,
304300 }),
305301 );
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+8−6
@@ -2,11 +2,11 @@ import { assert, it } from "@effect/vitest"; · 1 unmodified line
22 import { DateTime, Effect, Layer, Option } from "effect";
33 import { ChildProcessSpawner } from "effect/unstable/process";
44
5−import { GitHubCli, type GitHubCliShape } from "./GitHubCli.ts";
6−import type { VcsProcessOutput } from "../vcs/VcsProcess.ts";
5+import * as VcsProcess from "../vcs/VcsProcess.ts";
6+import * as GitHubCli from "./GitHubCli.ts";
77 import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
88
9−const processResult = (stdout: string): VcsProcessOutput => ({
9+const processResult = (stdout: string): VcsProcess.VcsProcessOutput => ({
1010 exitCode: ChildProcessSpawner.ExitCode(0),
1111 stdout,
1212 stderr: "",
@@ -14,8 +14,10 @@ const processResult = (stdout: string): VcsProcessOutput => ({ · 1 unmodified line
1414 stderrTruncated: false,
1515 });
1616
17−function makeProvider(github: Partial<GitHubCliShape>) {
18− return GitHubSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitHubCli)(github)));
17+function makeProvider(github: Partial<GitHubCli.GitHubCliShape>) {
18+ return GitHubSourceControlProvider.make().pipe(
19+ Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)),
20+ );
1921 }
2022
2123 it.effect("maps GitHub PR summaries into provider-neutral change requests", () =>
@@ -127,7 +129,7 @@ it.effect("treats empty non-open change request listing output as no results", ( · 105 unmodified lines
127129
128130 it.effect("creates GitHub PRs through provider-neutral input names", () =>
129131 Effect.gen(function* () {
130− let createInput: Parameters<GitHubCliShape["createPullRequest"]>[0] | null = null;
132+ let createInput: Parameters<GitHubCli.GitHubCliShape["createPullRequest"]>[0] | null = null;
131133 const provider = yield* makeProvider({
132134 createPullRequest: (input) => {
133135 createInput = input;
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+115−67
@@ -5,11 +5,15 @@ import { · 4 unmodified lines
55 type ChangeRequestState,
66 } from "@t3tools/contracts";
77
8−import { GitHubCli, type GitHubCliError, type GitHubPullRequestSummary } from "./GitHubCli.ts";
9−import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts";
10−import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts";
8+import * as GitHubCli from "./GitHubCli.ts";
9+import * as GitHubPullRequests from "./gitHubPullRequests.ts";
10+import * as SourceControlProvider from "./SourceControlProvider.ts";
11+import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
1112
12−function providerError(operation: string, cause: GitHubCliError): SourceControlProviderError {
13+function providerError(
14+ operation: string,
15+ cause: GitHubCli.GitHubCliError,
16+): SourceControlProviderError {
1317 return new SourceControlProviderError({
1418 provider: "github",
1519 operation,
@@ -18,7 +22,7 @@ function providerError(operation: string, cause: GitHubCliError): SourceControlP · 2 unmodified lines
1822 });
1923 }
2024
21−function toChangeRequest(summary: GitHubPullRequestSummary): ChangeRequest {
25+function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest {
2226 return {
2327 provider: "github",
2428 number: summary.number,
@@ -40,75 +44,119 @@ function toChangeRequest(summary: GitHubPullRequestSummary): ChangeRequest { · 15 unmodified lines
4044 };
4145 }
4246
47+function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) {
48+ const output = SourceControlProviderDiscovery.combinedAuthOutput(input);
49+ const account = SourceControlProviderDiscovery.matchFirst(output, [
50+ /Logged in to .* account\s+([^\s(]+)/iu,
51+ /Logged in to .* as\s+([^\s(]+)/iu,
52+ ]);
53+ const host = SourceControlProviderDiscovery.parseCliHost(output);
54+
55+ if (input.exitCode !== 0) {
56+ return SourceControlProviderDiscovery.providerAuth({
57+ status: "unauthenticated",
58+ host,
59+ detail:
60+ SourceControlProviderDiscovery.firstSafeAuthLine(output) ??
61+ "Run `gh auth login` to authenticate GitHub CLI.",
62+ });
63+ }
64+
65+ if (account) {
66+ return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host });
67+ }
68+
69+ return SourceControlProviderDiscovery.providerAuth({
70+ status: "unknown",
71+ host,
72+ detail:
73+ SourceControlProviderDiscovery.firstSafeAuthLine(output) ??
74+ "GitHub CLI auth status could not be parsed.",
75+ });
76+}
77+
78+export const discovery = {
79+ type: "cli",
80+ kind: "github",
81+ label: "GitHub",
82+ executable: "gh",
83+ versionArgs: ["--version"],
84+ authArgs: ["auth", "status"],
85+ parseAuth: parseGitHubAuth,
86+ installHint:
87+ "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).",
88+} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec;
89+
4390 export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () {
44− const github = yield* GitHubCli;
91+ const github = yield* GitHubCli.GitHubCli;
4592
46− const listChangeRequests: SourceControlProviderShape["listChangeRequests"] = (input) => {
47− if (input.state === "open") {
93+ const listChangeRequests: SourceControlProvider.SourceControlProviderShape["listChangeRequests"] =
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;
48109 return github
49− .listOpenPullRequests({
110+ .execute({
50111 cwd: input.cwd,
51− headSelector: input.headSelector,
52− ...(input.limit !== undefined ? { limit: input.limit } : {}),
112+ args: [
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+ ],
53124 })
54125 .pipe(
55− Effect.map((items) => items.map(toChangeRequest)),
56− Effect.mapError((error) => providerError("listChangeRequests", error)),
126+ Effect.flatMap((result) => {
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+ ),
57156 );
58− }
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− };
157+ };
110158
111− return SourceControlProvider.of({
159+ return SourceControlProvider.SourceControlProvider.of({
112160 kind: "github",
113161 listChangeRequests,
114162 getChangeRequest: (input) =>
@@ -145,4 +193,4 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { · 30 unmodified lines
145193 });
146194 });
147195
148−export const layer = Layer.effect(SourceControlProvider, make());
196+export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make());
apps/server/src/sourceControl/GitLabCli.test.ts+5−6
@@ -1,25 +1,24 @@
1−import { assert, it } from "@effect/vitest";
1+import { assert, it, afterEach, expect, vi } from "@effect/vitest";
22 import { Effect, Layer } from "effect";
33 import { ChildProcessSpawner } from "effect/unstable/process";
4−import { afterEach, expect, vi } from "vitest";
54
65 import { VcsProcessExitError } from "@t3tools/contracts";
76
8−import { VcsProcess, type VcsProcessOutput, type VcsProcessShape } from "../vcs/VcsProcess.ts";
7+import * as VcsProcess from "../vcs/VcsProcess.ts";
98 import * as GitLabCli from "./GitLabCli.ts";
109
11−const mockedRun = vi.fn<VcsProcessShape["run"]>();
10+const mockedRun = vi.fn<VcsProcess.VcsProcessShape["run"]>();
1211 const layer = it.layer(
1312 GitLabCli.layer.pipe(
1413 Layer.provide(
15− Layer.mock(VcsProcess)({
14+ Layer.mock(VcsProcess.VcsProcess)({
1615 run: mockedRun,
1716 }),
1817 ),
1918 ),
2019 );
2120
22−function processOutput(stdout: string): VcsProcessOutput {
21+function processOutput(stdout: string): VcsProcess.VcsProcessOutput {
2322 return {
2423 exitCode: ChildProcessSpawner.ExitCode(0),
2524 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
22
33 import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts";
44
5−import {
6− decodeGitLabMergeRequestJson,
7− decodeGitLabMergeRequestListJson,
8− formatGitLabJsonDecodeError,
9−} from "./gitLabMergeRequests.ts";
10−import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
11−import type { SourceControlRefSelector } from "./SourceControlProvider.ts";
5+import * as VcsProcess from "../vcs/VcsProcess.ts";
6+import * as GitLabMergeRequests from "./gitLabMergeRequests.ts";
7+import type * as SourceControlProvider from "./SourceControlProvider.ts";
128
139 const DEFAULT_TIMEOUT_MS = 30_000;
1410
@@ -46,12 +42,12 @@ export interface GitLabCliShape { · 31 unmodified lines
4642 readonly cwd: string;
4743 readonly args: ReadonlyArray<string>;
4844 readonly timeoutMs?: number;
49− }) => Effect.Effect<VcsProcessOutput, GitLabCliError>;
45+ }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitLabCliError>;
5046
5147 readonly listMergeRequests: (input: {
5248 readonly cwd: string;
5349 readonly headSelector: string;
54− readonly source?: SourceControlRefSelector;
50+ readonly source?: SourceControlProvider.SourceControlRefSelector;
5551 readonly state: "open" | "closed" | "merged" | "all";
5652 readonly limit?: number;
5753 }) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>;
@@ -76,8 +72,8 @@ export interface GitLabCliShape { · 18 unmodified lines
7672 readonly cwd: string;
7773 readonly baseBranch: string;
7874 readonly headSelector: string;
79− readonly source?: SourceControlRefSelector;
80− readonly target?: SourceControlRefSelector;
75+ readonly source?: SourceControlProvider.SourceControlRefSelector;
76+ readonly target?: SourceControlProvider.SourceControlRefSelector;
8177 readonly title: string;
8278 readonly bodyFile: string;
8379 }) => Effect.Effect<void, GitLabCliError>;
@@ -220,12 +216,14 @@ function normalizeHeadSelector(headSelector: string): string { · 136 unmodified lines
220216
221217 function sourceRefName(input: {
222218 readonly headSelector: string;
223− readonly source?: SourceControlRefSelector;
219+ readonly source?: SourceControlProvider.SourceControlRefSelector;
224220 }): string {
225221 return input.source?.refName ?? normalizeHeadSelector(input.headSelector);
226222 }
227223
228−function sourceProjectIdentifier(source: SourceControlRefSelector | undefined): string | null {
224+function sourceProjectIdentifier(
225+ source: SourceControlProvider.SourceControlRefSelector | undefined,
226+): string | null {
229227 return source?.repository ?? source?.owner ?? null;
230228 }
231229
@@ -252,7 +250,7 @@ function parseRepositoryPath(repository: string): { · 20 unmodified lines
252250 }
253251
254252 export const make = Effect.fn("makeGitLabCli")(function* () {
255− const process = yield* VcsProcess;
253+ const process = yield* VcsProcess.VcsProcess;
256254
257255 const execute: GitLabCliShape["execute"] = (input) =>
258256 process
@@ -286,13 +284,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () { · 27 unmodified lines
286284 Effect.flatMap((raw) =>
287285 raw.length === 0
288286 ? Effect.succeed([])
289− : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe(
287+ : Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestListJson(raw)).pipe(
290288 Effect.flatMap((decoded) => {
291289 if (!Result.isSuccess(decoded)) {
292290 return Effect.fail(
293291 new GitLabCliError({
294292 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)}`,
296294 cause: decoded.failure,
297295 }),
298296 );
@@ -310,13 +308,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () { · 11 unmodified lines
310308 }).pipe(
311309 Effect.map((result) => result.stdout.trim()),
312310 Effect.flatMap((raw) =>
313− Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe(
311+ Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestJson(raw)).pipe(
314312 Effect.flatMap((decoded) => {
315313 if (!Result.isSuccess(decoded)) {
316314 return Effect.fail(
317315 new GitLabCliError({
318316 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)}`,
320318 cause: decoded.failure,
321319 }),
322320 );
apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts+7−5
@@ -1,11 +1,13 @@
11 import { assert, it } from "@effect/vitest";
22 import { Effect, Layer, Option } from "effect";
33
4−import { GitLabCli, type GitLabCliShape } from "./GitLabCli.ts";
4+import * as GitLabCli from "./GitLabCli.ts";
55 import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
66
7−function makeProvider(gitlab: Partial<GitLabCliShape>) {
8− return GitLabSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitLabCli)(gitlab)));
7+function makeProvider(gitlab: Partial<GitLabCli.GitLabCliShape>) {
8+ return GitLabSourceControlProvider.make().pipe(
9+ Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)),
10+ );
911 }
1012
1113 it.effect("maps GitLab MR summaries into provider-neutral change requests", () =>
@@ -48,7 +50,7 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = · 36 unmodified lines
4850
4951 it.effect("lists GitLab MRs through provider-neutral input names", () =>
5052 Effect.gen(function* () {
51− let listInput: Parameters<GitLabCliShape["listMergeRequests"]>[0] | null = null;
53+ let listInput: Parameters<GitLabCli.GitLabCliShape["listMergeRequests"]>[0] | null = null;
5254 const provider = yield* makeProvider({
5355 listMergeRequests: (input) => {
5456 listInput = input;
@@ -74,7 +76,7 @@ it.effect("lists GitLab MRs through provider-neutral input names", () => · 19 unmodified lines
7476
7577 it.effect("creates GitLab MRs through provider-neutral input names", () =>
7678 Effect.gen(function* () {
77− let createInput: Parameters<GitLabCliShape["createMergeRequest"]>[0] | null = null;
79+ let createInput: Parameters<GitLabCli.GitLabCliShape["createMergeRequest"]>[0] | null = null;
7880 const provider = yield* makeProvider({
7981 createMergeRequest: (input) => {
8082 createInput = input;
apps/server/src/sourceControl/GitLabSourceControlProvider.ts+53−19
@@ -1,10 +1,14 @@
11 import { Effect, Layer, Option } from "effect";
22 import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
33
4−import { GitLabCli, type GitLabCliError, type GitLabMergeRequestSummary } from "./GitLabCli.ts";
5−import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts";
4+import * as GitLabCli from "./GitLabCli.ts";
5+import * as SourceControlProvider from "./SourceControlProvider.ts";
6+import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
67
7−function providerError(operation: string, cause: GitLabCliError): SourceControlProviderError {
8+function providerError(
9+ operation: string,
10+ cause: GitLabCli.GitLabCliError,
11+): SourceControlProviderError {
812 return new SourceControlProviderError({
913 provider: "gitlab",
1014 operation,
@@ -13,7 +17,7 @@ function providerError(operation: string, cause: GitLabCliError): SourceControlP · 2 unmodified lines
1317 });
1418 }
1519
16−function toChangeRequest(summary: GitLabMergeRequestSummary): ChangeRequest {
20+function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest {
1721 return {
1822 provider: "gitlab",
1923 number: summary.number,
@@ -35,27 +39,57 @@ function toChangeRequest(summary: GitLabMergeRequestSummary): ChangeRequest { · 15 unmodified lines
3539 };
3640 }
3741
38−function sourceFromInput(input: {
39− readonly headSelector: string;
40− readonly source?: SourceControlRefSelector;
41−}): SourceControlRefSelector | undefined {
42− if (input.source) {
43− return input.source;
42+function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) {
43+ const output = SourceControlProviderDiscovery.combinedAuthOutput(input);
44+ const account = SourceControlProviderDiscovery.matchFirst(output, [
45+ /Logged in to .* as\s+([^\s(]+)/iu,
46+ /Logged in to .* account\s+([^\s(]+)/iu,
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 });
4463 }
4564
46− const match = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim());
47− const owner = match?.[1]?.trim();
48− const refName = match?.[2]?.trim();
49− return owner && refName ? { owner, refName } : undefined;
65+ return SourceControlProviderDiscovery.providerAuth({
66+ status: "unknown",
67+ host,
68+ detail:
69+ SourceControlProviderDiscovery.firstSafeAuthLine(output) ??
70+ "GitLab CLI auth status could not be parsed.",
71+ });
5072 }
5173
74+export const discovery = {
75+ type: "cli",
76+ kind: "gitlab",
77+ label: "GitLab",
78+ executable: "glab",
79+ versionArgs: ["--version"],
80+ authArgs: ["auth", "status"],
81+ parseAuth: parseGitLabAuth,
82+ installHint:
83+ "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).",
84+} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec;
85+
5286 export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () {
53− const gitlab = yield* GitLabCli;
87+ const gitlab = yield* GitLabCli.GitLabCli;
5488
55− return SourceControlProvider.of({
89+ return SourceControlProvider.SourceControlProvider.of({
5690 kind: "gitlab",
5791 listChangeRequests: (input) => {
58− const source = sourceFromInput(input);
92+ const source = SourceControlProvider.sourceControlRefFromInput(input);
5993 return gitlab
6094 .listMergeRequests({
6195 cwd: input.cwd,
@@ -75,7 +109,7 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { · 13 unmodified lines
75109 Effect.mapError((error) => providerError("getChangeRequest", error)),
76110 ),
77111 createChangeRequest: (input) => {
78− const source = sourceFromInput(input);
112+ const source = SourceControlProvider.sourceControlRefFromInput(input);
79113 return gitlab
80114 .createMergeRequest({
81115 cwd: input.cwd,
@@ -107,4 +141,4 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { · 25 unmodified lines
107141 });
108142 });
109143
110−export const layer = Layer.effect(SourceControlProvider, make());
144+export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make());
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+122−82
@@ -5,8 +5,34 @@ import { ChildProcessSpawner } from "effect/unstable/process"; · 4 unmodified lines
55 import { VcsProcessSpawnError } from "@t3tools/contracts";
66
77 import { ServerConfig } from "../config.ts";
8+import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
89 import * as VcsProcess from "../vcs/VcsProcess.ts";
9−import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts";
10+import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
11+import * as BitbucketApi from "./BitbucketApi.ts";
12+import * as GitHubCli from "./GitHubCli.ts";
13+import * as GitLabCli from "./GitLabCli.ts";
14+import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";
15+import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
16+
17+const sourceControlProviderRegistryTestLayer = (input: {
18+ readonly bitbucket: Partial<BitbucketApi.BitbucketApiShape>;
19+ readonly process: Partial<VcsProcess.VcsProcessShape>;
20+}) =>
21+ SourceControlProviderRegistry.layer.pipe(
22+ Layer.provide(
23+ Layer.mergeAll(
24+ ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe(
25+ Layer.provide(NodeServices.layer),
26+ ),
27+ Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
28+ Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
29+ Layer.mock(GitHubCli.GitHubCli)({}),
30+ Layer.mock(GitLabCli.GitLabCli)({}),
31+ Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
32+ Layer.mock(VcsProcess.VcsProcess)(input.process),
33+ ),
34+ ),
35+ );
1036
1137 const processOutput = (
1238 stdout: string,
@@ -22,39 +48,53 @@ const processOutput = ( · 9 unmodified lines
2248 stderrTruncated: false,
2349 });
2450
25−it.effect("reports implemented tools separately from locally available CLIs", () => {
26− const testLayer = layer.pipe(
27− Layer.provide(
28− ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }),
29− ),
30− Layer.provide(
31− Layer.mock(VcsProcess.VcsProcess)({
32− run: (input) => {
33− if (input.command === "git") {
34− return Effect.succeed(processOutput("git version 2.51.0\n"));
35− }
36− if (input.command === "gh" && input.args[0] === "--version") {
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
51+it.effect("reports implemented tools separately from locally available executables", () => {
52+ const processMock = {
53+ run: (input: VcsProcess.VcsProcessInput) => {
54+ if (input.command === "git") {
55+ return Effect.succeed(processOutput("git version 2.51.0\n"));
56+ }
57+ if (input.command === "gh" && input.args[0] === "--version") {
58+ return Effect.succeed(processOutput("gh version 2.83.0\n"));
59+ }
60+ if (input.command === "gh" && input.args.join(" ") === "auth status") {
61+ return Effect.succeed(
62+ processOutput(`github.com
4263 Logged in to github.com account juliusmarminge (keyring)
4364 - Active account: true
4465 - Git operations protocol: ssh
4566 - Token: gho_************************************
4667 - Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo'
4768 `),
48− );
49− }
50− return Effect.fail(
51− new VcsProcessSpawnError({
52− operation: input.operation,
53− command: input.command,
54− cwd: input.cwd,
55− cause: new Error(`${input.command} not found`),
56− }),
57− );
69+ );
70+ }
71+ return Effect.fail(
72+ new VcsProcessSpawnError({
73+ operation: input.operation,
74+ command: input.command,
75+ cwd: input.cwd,
76+ cause: new Error(`${input.command} not found`),
77+ }),
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+ }),
5898 },
5999 }),
60100 ),
@@ -62,7 +102,7 @@ Logged in to github.com account juliusmarminge (keyring) · 1 unmodified line
62102 );
63103
64104 return Effect.gen(function* () {
65− const discovery = yield* SourceControlDiscovery;
105+ const discovery = yield* SourceControlDiscovery.SourceControlDiscovery;
66106 const result = yield* discovery.discover;
67107
68108 assert.deepStrictEqual(
@@ -79,7 +119,6 @@ Logged in to github.com account juliusmarminge (keyring) · 10 unmodified lines
79119 assert.deepStrictEqual(
80120 result.sourceControlProviders.map((item) => ({
81121 kind: item.kind,
82− implemented: item.implemented,
83122 status: item.status,
84123 auth: item.auth.status,
85124 account: item.auth.account,
@@ -87,88 +126,89 @@ Logged in to github.com account juliusmarminge (keyring) · 1 unmodified line
87126 [
88127 {
89128 kind: "github",
90− implemented: true,
91129 status: "available",
92130 auth: "authenticated",
93131 account: Option.some("juliusmarminge"),
94132 },
95133 {
96134 kind: "gitlab",
97− implemented: true,
98135 status: "missing",
99136 auth: "unknown",
100137 account: Option.none(),
101138 },
102139 {
103140 kind: "azure-devops",
104− implemented: false,
105141 status: "missing",
106142 auth: "unknown",
107143 account: Option.none(),
108144 },
109145 {
110146 kind: "bitbucket",
111− implemented: false,
112− status: "missing",
113− auth: "unknown",
147+ status: "available",
148+ auth: "unauthenticated",
114149 account: Option.none(),
115150 },
116151 ],
117152 );
118153 const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket");
119154 assert.ok(bitbucket);
120− assert.strictEqual("executable" in bitbucket, false);
155+ assert.strictEqual(bitbucket.executable, undefined);
121156 }).pipe(Effect.provide(testLayer));
122157 });
123158
124159 it.effect("probes provider authentication without exposing token details", () => {
125− const testLayer = layer.pipe(
126− Layer.provide(
127− ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }),
128− ),
129− Layer.provide(
130− Layer.mock(VcsProcess.VcsProcess)({
131− run: (input) => {
132− if (input.args[0] === "--version") {
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
160+ const processMock = {
161+ run: (input: VcsProcess.VcsProcessInput) => {
162+ if (input.args[0] === "--version") {
163+ return Effect.succeed(processOutput(`${input.command} version test\n`));
164+ }
165+ if (input.command === "gh" && input.args.join(" ") === "auth status") {
166+ return Effect.succeed(
167+ processOutput(`github.com
138168 Logged in to github.com account octocat (keyring)
139169 - Token: gho_************************************
140170 - Token scopes: 'repo'
141171 `),
142− );
143− }
144− if (input.command === "glab" && input.args.join(" ") === "auth status") {
145− return Effect.succeed(
146− processOutput(`gitlab.com
172+ );
173+ }
174+ if (input.command === "glab" && input.args.join(" ") === "auth status") {
175+ return Effect.succeed(
176+ processOutput(`gitlab.com
147177 Logged in to gitlab.com as gitlab-user
148178 `),
149− );
150− }
151− if (
152− input.command === "az" &&
153− input.args.join(" ") === "account show --query user.name -o tsv"
154− ) {
155− return Effect.succeed(processOutput("azure-user@example.com\n"));
156− }
157− if (input.command === "bb" && input.args.join(" ") === "auth status") {
158− return Effect.succeed(
159− processOutput(`bitbucket.org
160−Logged in as bitbucket-user
161−`),
162− );
163− }
164− return Effect.fail(
165− new VcsProcessSpawnError({
166− operation: input.operation,
167− command: input.command,
168− cwd: input.cwd,
169− cause: new Error(`${input.command} not found`),
170− }),
171− );
179+ );
180+ }
181+ if (
182+ input.command === "az" &&
183+ input.args.join(" ") === "account show --query user.name -o tsv"
184+ ) {
185+ return Effect.succeed(processOutput("azure-user@example.com\n"));
186+ }
187+ return Effect.fail(
188+ new VcsProcessSpawnError({
189+ operation: input.operation,
190+ command: input.command,
191+ cwd: input.cwd,
192+ cause: new Error(`${input.command} not found`),
193+ }),
194+ );
195+ },
196+ } satisfies Partial<VcsProcess.VcsProcessShape>;
197+ const testLayer = SourceControlDiscovery.layer.pipe(
198+ Layer.provide(
199+ ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }),
200+ ),
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+ }),
172212 },
173213 }),
174214 ),
@@ -176,7 +216,7 @@ Logged in as bitbucket-user · 1 unmodified line
176216 );
177217
178218 return Effect.gen(function* () {
179− const discovery = yield* SourceControlDiscovery;
219+ const discovery = yield* SourceControlDiscovery.SourceControlDiscovery;
180220 const result = yield* discovery.discover;
181221
182222 assert.deepStrictEqual(
@@ -207,9 +247,9 @@ Logged in as bitbucket-user · 24 unmodified lines
207247 },
208248 {
209249 kind: "bitbucket",
210− auth: "unknown",
211− account: Option.none(),
212− detail: Option.some("Bitbucket provider support is not available yet."),
250+ auth: "authenticated",
251+ account: Option.some("bitbucket-user"),
252+ detail: Option.none(),
213253 },
214254 ],
215255 );
apps/server/src/sourceControl/SourceControlDiscovery.ts+10−272
@@ -1,8 +1,5 @@
11 import {
2− type SourceControlProviderAuth,
32 type SourceControlDiscoveryResult,
4− type SourceControlProviderDiscoveryItem,
5− type SourceControlProviderKind,
63 type VcsDiscoveryItem,
74 type VcsDriverKind,
85 } from "@t3tools/contracts";
@@ -10,6 +7,8 @@ import { Context, Effect, Layer, Option } from "effect"; · 1 unmodified line
107
118 import { ServerConfig } from "../config.ts";
129 import * as VcsProcess from "../vcs/VcsProcess.ts";
10+import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
11+import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
1312
1413 interface DiscoveryProbe {
1514 readonly label: string;
@@ -25,18 +24,6 @@ type VcsProbe = DiscoveryProbe & { · 9 unmodified lines
2524 readonly versionArgs: ReadonlyArray<string>;
2625 };
2726
28−type ProviderProbe = DiscoveryProbe & {
29− readonly kind: SourceControlProviderKind;
30− readonly authArgs?: ReadonlyArray<string>;
31− readonly parseAuth?: (input: AuthProbeInput) => SourceControlProviderAuth;
32−};
33−
34−interface AuthProbeInput {
35− readonly stdout: string;
36− readonly stderr: string;
37− readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"];
38−}
39−
4027 interface DiscoveryProbeResult<Kind extends string> {
4128 readonly kind: Kind;
4229 readonly label: string;
@@ -67,203 +54,6 @@ const VCS_PROBES: ReadonlyArray<VcsProbe> = [ · 24 unmodified lines
6754 },
6855 ];
6956
70−const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [
71− {
72− kind: "github",
73− label: "GitHub",
74− executable: "gh",
75− versionArgs: ["--version"],
76− authArgs: ["auth", "status"],
77− parseAuth: parseGitHubAuth,
78− implemented: true,
79− installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.",
80− },
81− {
82− kind: "gitlab",
83− label: "GitLab",
84− executable: "glab",
85− versionArgs: ["--version"],
86− authArgs: ["auth", "status"],
87− parseAuth: parseGitLabAuth,
88− implemented: true,
89− installHint:
90− "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.",
91− },
92− {
93− kind: "azure-devops",
94− label: "Azure DevOps",
95− executable: "az",
96− versionArgs: ["--version"],
97− authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"],
98− parseAuth: parseAzureAuth,
99− implemented: false,
100− installHint:
101− "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.",
102− },
103− {
104− kind: "bitbucket",
105− label: "Bitbucket",
106− implemented: false,
107− installHint: "Bitbucket provider support is not available yet.",
108− },
109−];
110−
111−function firstNonEmptyLine(text: string): Option.Option<string> {
112− const line = text
113− .split(/\r?\n/)
114− .map((entry) => entry.trim())
115− .find((entry) => entry.length > 0);
116− return line === undefined ? Option.none() : Option.some(line);
117−}
118−
119−function detailFromCause(cause: unknown): Option.Option<string> {
120− if (cause instanceof Error && cause.message.trim().length > 0) {
121− return Option.some(cause.message.trim());
122− }
123− return Option.none();
124−}
125−
126−function authAccount(account: string | undefined): Option.Option<string> {
127− const trimmed = account?.trim();
128− return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
129−}
130−
131−function authHost(host: string | undefined): Option.Option<string> {
132− const trimmed = host?.trim();
133− return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
134−}
135−
136−function authDetail(detail: string | undefined): Option.Option<string> {
137− const trimmed = detail?.trim();
138− return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
139−}
140−
141−function providerAuth(input: {
142− readonly status: SourceControlProviderAuth["status"];
143− readonly account?: string | undefined;
144− readonly host?: string | undefined;
145− readonly detail?: string | undefined;
146−}): SourceControlProviderAuth {
147− return {
148− status: input.status,
149− account: authAccount(input.account),
150− host: authHost(input.host),
151− detail: authDetail(input.detail),
152− };
153−}
154−
155−function unknownAuth(detail?: string): SourceControlProviderAuth {
156− return providerAuth({ status: "unknown", detail });
157−}
158−
159−function combinedAuthOutput(input: AuthProbeInput): string {
160− return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n");
161−}
162−
163−function sanitizedAuthLines(text: string): ReadonlyArray<string> {
164− return text
165− .split(/\r?\n/)
166− .map((entry) => entry.trim())
167− .filter((entry) => entry.length > 0)
168− .filter((entry) => !/^[-\s]*token(?:\s+scopes?)?:/iu.test(entry));
169−}
170−
171−function firstSafeAuthLine(text: string): string | undefined {
172− return sanitizedAuthLines(text)[0];
173−}
174−
175−function parseCliHost(text: string): string | undefined {
176− return sanitizedAuthLines(text)
177− .map((line) => line.replace(/^[^a-z0-9]+/iu, ""))
178− .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line));
179−}
180−
181−function matchFirst(text: string, patterns: ReadonlyArray<RegExp>): string | undefined {
182− for (const pattern of patterns) {
183− const match = pattern.exec(text);
184− const value = match?.[1]?.trim();
185− if (value && value.length > 0) return value;
186− }
187− return undefined;
188−}
189−
190−function parseGitHubAuth(input: AuthProbeInput): SourceControlProviderAuth {
191− const output = combinedAuthOutput(input);
192− const account = matchFirst(output, [
193− /Logged in to .* account\s+([^\s(]+)/iu,
194− /Logged in to .* as\s+([^\s(]+)/iu,
195− ]);
196− const host = parseCliHost(output);
197−
198− if (input.exitCode !== 0) {
199− return providerAuth({
200− status: "unauthenticated",
201− host,
202− detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.",
203− });
204− }
205−
206− if (account) {
207− return providerAuth({ status: "authenticated", account, host });
208− }
209−
210− return providerAuth({
211− status: "unknown",
212− host,
213− detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.",
214− });
215−}
216−
217−function parseGitLabAuth(input: AuthProbeInput): SourceControlProviderAuth {
218− const output = combinedAuthOutput(input);
219− const account = matchFirst(output, [
220− /Logged in to .* as\s+([^\s(]+)/iu,
221− /Logged in to .* account\s+([^\s(]+)/iu,
222− /account:\s*([^\s(]+)/iu,
223− ]);
224− const host = parseCliHost(output);
225−
226− if (input.exitCode !== 0) {
227− return providerAuth({
228− status: "unauthenticated",
229− host,
230− detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.",
231− });
232− }
233−
234− if (account) {
235− return providerAuth({ status: "authenticated", account, host });
236− }
237−
238− return providerAuth({
239− status: "unknown",
240− host,
241− detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.",
242− });
243−}
244−
245−function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth {
246− const account = input.stdout.trim().split(/\r?\n/)[0]?.trim();
247−
248− if (input.exitCode !== 0) {
249− return providerAuth({
250− status: "unauthenticated",
251− detail:
252− firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.",
253− });
254− }
255−
256− if (account && account.length > 0) {
257− return providerAuth({ status: "authenticated", account, host: "dev.azure.com" });
258− }
259−
260− return providerAuth({
261− status: "unknown",
262− host: "dev.azure.com",
263− detail: "Azure CLI account status could not be parsed.",
264− });
265−}
266−
26757 export interface SourceControlDiscoveryShape {
26858 readonly discover: Effect.Effect<SourceControlDiscoveryResult>;
26959 }
@@ -278,8 +68,10 @@ export const layer = Layer.effect( · 8 unmodified lines
27868 Effect.gen(function* () {
27969 const config = yield* ServerConfig;
28070 const process = yield* VcsProcess.VcsProcess;
71+ const sourceControlProviders =
72+ yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
28173
282− const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>(
74+ const probe = <Kind extends VcsDriverKind>(
28375 input: DiscoveryProbe & { readonly kind: Kind },
28476 ): Effect.Effect<DiscoveryProbeResult<Kind>> => {
28577 const executable = input.executable;
@@ -316,8 +108,9 @@ export const layer = Layer.effect( · 30 unmodified lines
316108 executable,
317109 implemented: input.implemented,
318110 status: "available" as const,
319− version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
320− firstNonEmptyLine(result.stderr),
111+ version: Option.orElse(
112+ SourceControlProviderDiscovery.firstNonEmptyLine(result.stdout),
113+ () => SourceControlProviderDiscovery.firstNonEmptyLine(result.stderr),
321114 ),
322115 installHint: input.installHint,
323116 detail: Option.none<string>(),
@@ -332,74 +125,19 @@ export const layer = Layer.effect( · 8 unmodified lines
332125 status: "missing" as const,
333126 version: Option.none<string>(),
334127 installHint: input.installHint,
335− detail: detailFromCause(cause),
128+ detail: SourceControlProviderDiscovery.detailFromCause(cause),
336129 } satisfies DiscoveryProbeResult<Kind>),
337130 ),
338131 );
339132 };
340133
341− const probeProvider = (input: ProviderProbe) =>
342− probe(input).pipe(
343− Effect.flatMap((item) => {
344− const executable = input.executable;
345− const authArgs = input.authArgs;
346− const parseAuth = input.parseAuth;
347−
348− if (!executable || !authArgs || !parseAuth) {
349− return Effect.succeed({
350− ...item,
351− auth: unknownAuth(input.installHint),
352− } satisfies SourceControlProviderDiscoveryItem);
353− }
354−
355− if (item.status !== "available") {
356− return Effect.succeed({
357− ...item,
358− auth: unknownAuth("CLI is not installed."),
359− } satisfies SourceControlProviderDiscoveryItem);
360− }
361−
362− return process
363− .run({
364− operation: "source-control.discovery.auth",
365− command: executable,
366− args: authArgs,
367− cwd: config.cwd,
368− allowNonZeroExit: true,
369− timeoutMs: 5_000,
370− maxOutputBytes: 8_000,
371− truncateOutputAtMaxBytes: true,
372− })
373− .pipe(
374− Effect.map(
375− (result) =>
376− ({
377− ...item,
378− auth: parseAuth(result),
379− }) satisfies SourceControlProviderDiscoveryItem,
380− ),
381− Effect.catch((cause) =>
382− Effect.succeed({
383− ...item,
384− auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))),
385− } satisfies SourceControlProviderDiscoveryItem),
386− ),
387− );
388− }),
389− );
390−
391134 return SourceControlDiscovery.of({
392135 discover: Effect.all({
393136 versionControlSystems: Effect.all(
394137 VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray<Effect.Effect<VcsDiscoveryItem>>,
395138 { concurrency: "unbounded" },
396139 ),
397− sourceControlProviders: Effect.all(
398− SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probeProvider(entry)) as ReadonlyArray<
399− Effect.Effect<SourceControlProviderDiscoveryItem>
400− >,
401− { concurrency: "unbounded" },
402− ),
140+ sourceControlProviders: sourceControlProviders.discover,
403141 }),
404142 });
405143 }),
apps/server/src/sourceControl/SourceControlProvider.ts+27−0
@@ -21,6 +21,33 @@ export interface SourceControlRefSelector { · 20 unmodified lines
2121 readonly repository?: string;
2222 }
2323
24+export function parseSourceControlOwnerRef(
25+ headSelector: string,
26+): SourceControlRefSelector | undefined {
27+ const match = /^([^:/\s]+):(.+)$/u.exec(headSelector.trim());
28+ const owner = match?.[1]?.trim();
29+ const refName = match?.[2]?.trim();
30+ return owner && refName ? { owner, refName } : undefined;
31+}
32+
33+export function normalizeSourceBranch(headSelector: string): string {
34+ return parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim();
35+}
36+
37+export function sourceBranch(input: {
38+ readonly headSelector: string;
39+ readonly source?: SourceControlRefSelector;
40+}): string {
41+ return input.source?.refName ?? normalizeSourceBranch(input.headSelector);
42+}
43+
44+export function sourceControlRefFromInput(input: {
45+ readonly headSelector: string;
46+ readonly source?: SourceControlRefSelector;
47+}): SourceControlRefSelector | undefined {
48+ return input.source ?? parseSourceControlOwnerRef(input.headSelector);
49+}
50+
2451 export interface SourceControlProviderShape {
2552 readonly kind: SourceControlProviderKind;
2653 readonly listChangeRequests: (input: {
apps/server/src/sourceControl/SourceControlProviderDiscovery.ts+237−0
@@ -0,0 +1,237 @@
1+import type {
2+ SourceControlProviderAuth,
3+ SourceControlProviderDiscoveryItem,
4+ SourceControlProviderKind,
5+} from "@t3tools/contracts";
6+import { Effect, Option } from "effect";
7+
8+import type * as VcsProcess from "../vcs/VcsProcess.ts";
9+
10+export interface SourceControlAuthProbeInput {
11+ readonly stdout: string;
12+ readonly stderr: string;
13+ readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"];
14+}
15+
16+interface SourceControlDiscoverySpecBase {
17+ readonly kind: SourceControlProviderKind;
18+ readonly label: string;
19+ readonly installHint: string;
20+}
21+
22+export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & {
23+ readonly type: "cli";
24+ readonly executable: string;
25+ readonly versionArgs: ReadonlyArray<string>;
26+ readonly authArgs: ReadonlyArray<string>;
27+ readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth;
28+};
29+
30+export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & {
31+ readonly type: "api";
32+ readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;
33+};
34+
35+export type SourceControlProviderDiscoverySpec =
36+ | SourceControlCliDiscoverySpec
37+ | SourceControlApiDiscoverySpec;
38+
39+interface DiscoveryProbeResult {
40+ readonly kind: SourceControlProviderKind;
41+ readonly label: string;
42+ readonly executable: string;
43+ readonly status: "available" | "missing";
44+ readonly version: Option.Option<string>;
45+ readonly installHint: string;
46+ readonly detail: Option.Option<string>;
47+}
48+
49+export function firstNonEmptyLine(text: string): Option.Option<string> {
50+ const line = text
51+ .split(/\r?\n/)
52+ .map((entry) => entry.trim())
53+ .find((entry) => entry.length > 0);
54+ return line === undefined ? Option.none() : Option.some(line);
55+}
56+
57+export function detailFromCause(cause: unknown): Option.Option<string> {
58+ if (cause instanceof Error && cause.message.trim().length > 0) {
59+ return Option.some(cause.message.trim());
60+ }
61+ return Option.none();
62+}
63+
64+function authAccount(account: string | undefined): Option.Option<string> {
65+ const trimmed = account?.trim();
66+ return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
67+}
68+
69+function authHost(host: string | undefined): Option.Option<string> {
70+ const trimmed = host?.trim();
71+ return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
72+}
73+
74+function authDetail(detail: string | undefined): Option.Option<string> {
75+ const trimmed = detail?.trim();
76+ return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
77+}
78+
79+export function providerAuth(input: {
80+ readonly status: SourceControlProviderAuth["status"];
81+ readonly account?: string | undefined;
82+ readonly host?: string | undefined;
83+ readonly detail?: string | undefined;
84+}): SourceControlProviderAuth {
85+ return {
86+ status: input.status,
87+ account: authAccount(input.account),
88+ host: authHost(input.host),
89+ detail: authDetail(input.detail),
90+ };
91+}
92+
93+export function unknownAuth(detail?: string): SourceControlProviderAuth {
94+ return providerAuth({ status: "unknown", detail });
95+}
96+
97+export function combinedAuthOutput(input: SourceControlAuthProbeInput): string {
98+ return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n");
99+}
100+
101+function sanitizedAuthLines(text: string): ReadonlyArray<string> {
102+ return text
103+ .split(/\r?\n/)
104+ .map((entry) => entry.trim())
105+ .filter((entry) => entry.length > 0)
106+ .filter((entry) => !/^[-\s]*token(?:\s+scopes?)?:/iu.test(entry));
107+}
108+
109+export function firstSafeAuthLine(text: string): string | undefined {
110+ return sanitizedAuthLines(text)[0];
111+}
112+
113+export function parseCliHost(text: string): string | undefined {
114+ return sanitizedAuthLines(text)
115+ .map((line) => line.replace(/^[^a-z0-9]+/iu, ""))
116+ .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line));
117+}
118+
119+export function matchFirst(text: string, patterns: ReadonlyArray<RegExp>): string | undefined {
120+ for (const pattern of patterns) {
121+ const match = pattern.exec(text);
122+ const value = match?.[1]?.trim();
123+ if (value && value.length > 0) return value;
124+ }
125+ return undefined;
126+}
127+
128+function probeCli(input: {
129+ readonly spec: SourceControlCliDiscoverySpec;
130+ readonly process: VcsProcess.VcsProcessShape;
131+ readonly cwd: string;
132+}): Effect.Effect<DiscoveryProbeResult> {
133+ return input.process
134+ .run({
135+ operation: "source-control.discovery.probe",
136+ command: input.spec.executable,
137+ args: input.spec.versionArgs,
138+ cwd: input.cwd,
139+ timeoutMs: 5_000,
140+ maxOutputBytes: 8_000,
141+ truncateOutputAtMaxBytes: true,
142+ })
143+ .pipe(
144+ Effect.map(
145+ (result) =>
146+ ({
147+ kind: input.spec.kind,
148+ label: input.spec.label,
149+ executable: input.spec.executable,
150+ status: "available" as const,
151+ version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
152+ firstNonEmptyLine(result.stderr),
153+ ),
154+ installHint: input.spec.installHint,
155+ detail: Option.none<string>(),
156+ }) satisfies DiscoveryProbeResult,
157+ ),
158+ Effect.catch((cause) =>
159+ Effect.succeed({
160+ kind: input.spec.kind,
161+ label: input.spec.label,
162+ executable: input.spec.executable,
163+ status: "missing" as const,
164+ version: Option.none<string>(),
165+ installHint: input.spec.installHint,
166+ detail: detailFromCause(cause),
167+ } satisfies DiscoveryProbeResult),
168+ ),
169+ );
170+}
171+
172+export function probeSourceControlProvider(input: {
173+ readonly spec: SourceControlProviderDiscoverySpec;
174+ readonly process: VcsProcess.VcsProcessShape;
175+ readonly cwd: string;
176+}): Effect.Effect<SourceControlProviderDiscoveryItem> {
177+ if (input.spec.type === "api") {
178+ return input.spec.probeAuth.pipe(
179+ Effect.map(
180+ (auth) =>
181+ ({
182+ kind: input.spec.kind,
183+ label: input.spec.label,
184+ status: "available" as const,
185+ version: Option.none<string>(),
186+ installHint: input.spec.installHint,
187+ detail: Option.none<string>(),
188+ auth,
189+ }) satisfies SourceControlProviderDiscoveryItem,
190+ ),
191+ );
192+ }
193+
194+ const spec = input.spec;
195+
196+ return probeCli({
197+ spec,
198+ process: input.process,
199+ cwd: input.cwd,
200+ }).pipe(
201+ Effect.flatMap((item) => {
202+ if (item.status !== "available") {
203+ return Effect.succeed({
204+ ...item,
205+ auth: unknownAuth("Hosting integration command was not found on the server PATH."),
206+ } satisfies SourceControlProviderDiscoveryItem);
207+ }
208+
209+ return input.process
210+ .run({
211+ operation: "source-control.discovery.auth",
212+ command: spec.executable,
213+ args: spec.authArgs,
214+ cwd: input.cwd,
215+ allowNonZeroExit: true,
216+ timeoutMs: 5_000,
217+ maxOutputBytes: 8_000,
218+ truncateOutputAtMaxBytes: true,
219+ })
220+ .pipe(
221+ Effect.map(
222+ (result) =>
223+ ({
224+ ...item,
225+ auth: spec.parseAuth(result),
226+ }) satisfies SourceControlProviderDiscoveryItem,
227+ ),
228+ Effect.catch((cause) =>
229+ Effect.succeed({
230+ ...item,
231+ auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))),
232+ } satisfies SourceControlProviderDiscoveryItem),
233+ ),
234+ );
235+ }),
236+ );
237+}
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+48−9
@@ -1,11 +1,16 @@
11 import { assert, it } from "@effect/vitest";
2+import * as NodeServices from "@effect/platform-node/NodeServices";
23 import { DateTime, Effect, Layer, Option } from "effect";
34
4−import { GitHubCli } from "./GitHubCli.ts";
5−import { GitLabCli } from "./GitLabCli.ts";
5+import { ServerConfig } from "../config.ts";
6+import type * as VcsDriver from "../vcs/VcsDriver.ts";
7+import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
8+import * as VcsProcess from "../vcs/VcsProcess.ts";
9+import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
10+import * as BitbucketApi from "./BitbucketApi.ts";
11+import * as GitHubCli from "./GitHubCli.ts";
12+import * as GitLabCli from "./GitLabCli.ts";
613 import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
7−import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";
8−import type { VcsDriverShape } from "../vcs/VcsDriver.ts";
914
1015 const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
1116
@@ -29,10 +34,10 @@ function makeRegistry(input: { · 17 unmodified lines
2934 expiresAt: Option.none(),
3035 },
3136 }),
32− } satisfies Partial<VcsDriverShape>;
37+ } satisfies Partial<VcsDriver.VcsDriverShape>;
3338
34− const registryLayer = Layer.mock(VcsDriverRegistry)({
35− get: () => Effect.succeed(driver as unknown as VcsDriverShape),
39+ const registryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
40+ get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriverShape),
3641 resolve: () =>
3742 Effect.succeed({
3843 kind: "git",
@@ -46,13 +51,23 @@ function makeRegistry(input: { · 7 unmodified lines
4651 expiresAt: Option.none(),
4752 },
4853 },
49− driver: driver as unknown as VcsDriverShape,
54+ driver: driver as unknown as VcsDriver.VcsDriverShape,
5055 }),
5156 });
5257
5358 return SourceControlProviderRegistry.make().pipe(
5459 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+ ),
5671 ),
5772 );
5873 }
@@ -93,6 +108,30 @@ it.effect("routes GitLab remotes to the GitLab provider", () => · 34 unmodified lines
93108 }),
94109 );
95110
111+it.effect("routes Bitbucket remotes to the Bitbucket provider", () =>
112+ Effect.gen(function* () {
113+ const registry = yield* makeRegistry({
114+ remotes: [{ name: "origin", url: "git@bitbucket.org:pingdotgg/t3code.git" }],
115+ });
116+
117+ const provider = yield* registry.resolve({ cwd: "/repo" });
118+
119+ assert.strictEqual(provider.kind, "bitbucket");
120+ }),
121+);
122+
123+it.effect("routes Azure DevOps remotes to the Azure DevOps provider", () =>
124+ Effect.gen(function* () {
125+ const registry = yield* makeRegistry({
126+ remotes: [{ name: "origin", url: "https://dev.azure.com/acme/project/_git/repo" }],
127+ });
128+
129+ const provider = yield* registry.resolve({ cwd: "/repo" });
130+
131+ assert.strictEqual(provider.kind, "azure-devops");
132+ }),
133+);
134+
96135 it.effect("falls back to a non-origin remote when origin is not configured", () =>
97136 Effect.gen(function* () {
98137 const registry = yield* makeRegistry({
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+104−22
@@ -1,40 +1,46 @@
11 import { Cache, Context, Duration, Effect, Exit, Layer } from "effect";
2−import { SourceControlProviderError } from "@t3tools/contracts";
2+import {
3+ SourceControlProviderError,
4+ type SourceControlProviderDiscoveryItem,
5+} from "@t3tools/contracts";
36 import type { SourceControlProviderKind } from "@t3tools/contracts";
47 import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
58
6−import {
7− SourceControlProvider,
8− type SourceControlProviderContext,
9− type SourceControlProviderShape,
10−} from "./SourceControlProvider.ts";
9+import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts";
10+import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
1111 import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
1212 import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
13−import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";
13+import * as SourceControlProvider from "./SourceControlProvider.ts";
14+import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
15+import { ServerConfig } from "../config.ts";
16+import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
17+import * as VcsProcess from "../vcs/VcsProcess.ts";
1418
1519 const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048;
1620 const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5);
1721
1822 export interface SourceControlProviderRegistration {
1923 readonly kind: SourceControlProviderKind;
20− readonly provider: SourceControlProviderShape;
24+ readonly provider: SourceControlProvider.SourceControlProviderShape;
25+ readonly discovery: SourceControlProviderDiscovery.SourceControlProviderDiscoverySpec;
2126 }
2227
2328 export interface SourceControlProviderHandle {
24− readonly provider: SourceControlProviderShape;
25− readonly context: SourceControlProviderContext | null;
29+ readonly provider: SourceControlProvider.SourceControlProviderShape;
30+ readonly context: SourceControlProvider.SourceControlProviderContext | null;
2631 }
2732
2833 export interface SourceControlProviderRegistryShape {
2934 readonly get: (
3035 kind: SourceControlProviderKind,
31− ) => Effect.Effect<SourceControlProviderShape, SourceControlProviderError>;
36+ ) => Effect.Effect<SourceControlProvider.SourceControlProviderShape, SourceControlProviderError>;
3237 readonly resolveHandle: (input: {
3338 readonly cwd: string;
3439 }) => Effect.Effect<SourceControlProviderHandle, SourceControlProviderError>;
3540 readonly resolve: (input: {
3641 readonly cwd: string;
37− }) => Effect.Effect<SourceControlProviderShape, SourceControlProviderError>;
42+ }) => Effect.Effect<SourceControlProvider.SourceControlProviderShape, SourceControlProviderError>;
43+ readonly discover: Effect.Effect<ReadonlyArray<SourceControlProviderDiscoveryItem>>;
3844 }
3945
4046 export class SourceControlProviderRegistry extends Context.Service<
@@ -42,7 +48,9 @@ export class SourceControlProviderRegistry extends Context.Service< · 1 unmodified line
4248 SourceControlProviderRegistryShape
4349 >()("t3/source-control/SourceControlProviderRegistry") {}
4450
45−function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProviderShape {
51+function unsupportedProvider(
52+ kind: SourceControlProviderKind,
53+): SourceControlProvider.SourceControlProviderShape {
4654 const unsupported = (operation: string) =>
4755 Effect.fail(
4856 new SourceControlProviderError({
@@ -52,7 +60,7 @@ function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProv · 3 unmodified lines
5260 }),
5361 );
5462
55− return SourceControlProvider.of({
63+ return SourceControlProvider.SourceControlProvider.of({
5664 kind,
5765 listChangeRequests: () => unsupported("listChangeRequests"),
5866 getChangeRequest: () => unsupported("getChangeRequest"),
@@ -78,7 +86,7 @@ function selectProviderContext( · 19 unmodified lines
7886 readonly name: string;
7987 readonly url: string;
8088 }>,
81−): SourceControlProviderContext | null {
89+): SourceControlProvider.SourceControlProviderContext | null {
8290 const candidates = remotes
8391 .map((remote) => {
8492 const provider = detectSourceControlProviderFromRemoteUrl(remote.url);
@@ -90,7 +98,7 @@ function selectProviderContext( · 5 unmodified lines
9098 }
9199 : null;
92100 })
93− .filter((value): value is SourceControlProviderContext => value !== null);
101+ .filter((value): value is SourceControlProvider.SourceControlProviderContext => value !== null);
94102
95103 return (
96104 candidates.find((candidate) => candidate.remoteName === "origin") ??
@@ -100,12 +108,60 @@ function selectProviderContext( · 3 unmodified lines
100108 );
101109 }
102110
111+function bindProviderContext(
112+ provider: SourceControlProvider.SourceControlProviderShape,
113+ context: SourceControlProvider.SourceControlProviderContext | null,
114+): SourceControlProvider.SourceControlProviderShape {
115+ if (context === null) {
116+ return provider;
117+ }
118+
119+ return SourceControlProvider.SourceControlProvider.of({
120+ kind: provider.kind,
121+ listChangeRequests: (input) =>
122+ provider.listChangeRequests({
123+ ...input,
124+ context: input.context ?? context,
125+ }),
126+ getChangeRequest: (input) =>
127+ provider.getChangeRequest({
128+ ...input,
129+ context: input.context ?? context,
130+ }),
131+ createChangeRequest: (input) =>
132+ provider.createChangeRequest({
133+ ...input,
134+ context: input.context ?? context,
135+ }),
136+ getRepositoryCloneUrls: (input) =>
137+ provider.getRepositoryCloneUrls({
138+ ...input,
139+ context: input.context ?? context,
140+ }),
141+ createRepository: (input) => provider.createRepository(input),
142+ getDefaultBranch: (input) =>
143+ provider.getDefaultBranch({
144+ ...input,
145+ context: input.context ?? context,
146+ }),
147+ checkoutChangeRequest: (input) =>
148+ provider.checkoutChangeRequest({
149+ ...input,
150+ context: input.context ?? context,
151+ }),
152+ });
153+}
154+
103155 export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")(
104156 function* (registrations: ReadonlyArray<SourceControlProviderRegistration>) {
105− const vcsRegistry = yield* VcsDriverRegistry;
106− const providers = new Map<SourceControlProviderKind, SourceControlProviderShape>(
107− registrations.map((registration) => [registration.kind, registration.provider]),
108− );
157+ const config = yield* ServerConfig;
158+ const process = yield* VcsProcess.VcsProcess;
159+ const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;
160+ const providers = new Map<
161+ SourceControlProviderKind,
162+ SourceControlProvider.SourceControlProviderShape
163+ >(registrations.map((registration) => [registration.kind, registration.provider]));
164+ const discoverySpecs = registrations.map((registration) => registration.discovery);
109165
110166 const get: SourceControlProviderRegistryShape["get"] = (kind) =>
111167 Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind));
@@ -125,7 +181,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit · 13 unmodified lines
125181
126182 const providerContextCache = yield* Cache.makeWith<
127183 string,
128− SourceControlProviderContext | null,
184+ SourceControlProvider.SourceControlProviderContext | null,
129185 SourceControlProviderError
130186 >(detectProviderContext, {
131187 capacity: PROVIDER_DETECTION_CACHE_CAPACITY,
@@ -136,8 +192,9 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit · 4 unmodified lines
136192 Cache.get(providerContextCache, input.cwd).pipe(
137193 Effect.map((context) => {
138194 const kind = context?.provider.kind ?? "unknown";
195+ const provider = providers.get(kind) ?? unsupportedProvider(kind);
139196 return {
140− provider: providers.get(kind) ?? unsupportedProvider(kind),
197+ provider: bindProviderContext(provider, context),
141198 context,
142199 } satisfies SourceControlProviderHandle;
143200 }),
@@ -147,6 +204,16 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit · 3 unmodified lines
147204 get,
148205 resolveHandle,
149206 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+ ),
150217 });
151218 },
152219 );
@@ -154,14 +221,29 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit · 1 unmodified line
154221 export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () {
155222 const github = yield* GitHubSourceControlProvider.make();
156223 const gitlab = yield* GitLabSourceControlProvider.make();
224+ const bitbucket = yield* BitbucketSourceControlProvider.make();
225+ const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery();
226+ const azureDevOps = yield* AzureDevOpsSourceControlProvider.make();
157227 return yield* makeWithProviders([
158228 {
159229 kind: "github",
160230 provider: github,
231+ discovery: GitHubSourceControlProvider.discovery,
161232 },
162233 {
163234 kind: "gitlab",
164235 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,
165247 },
166248 ]);
167249 });
apps/server/src/sourceControl/SourceControlRepositoryService.test.ts+17−21
@@ -6,14 +6,10 @@ import { ChildProcessSpawner } from "effect/unstable/process"; · 5 unmodified lines
66 import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts";
77
88 import { ServerConfig } from "../config.ts";
9−import {
10− GitVcsDriver,
11− type ExecuteGitResult,
12− type GitVcsDriverShape,
13−} from "../vcs/GitVcsDriver.ts";
14−import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts";
15−import type { SourceControlProviderShape } from "./SourceControlProvider.ts";
16−import { SourceControlRepositoryService, layer } from "./SourceControlRepositoryService.ts";
9+import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
10+import type * as SourceControlProvider from "./SourceControlProvider.ts";
11+import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
12+import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts";
1713
1814 const CLONE_URLS = {
1915 nameWithOwner: "octocat/t3code",
@@ -22,8 +18,8 @@ const CLONE_URLS = { · 2 unmodified lines
2218 };
2319
2420 function makeProvider(
25− overrides: Partial<SourceControlProviderShape> = {},
26−): SourceControlProviderShape {
21+ overrides: Partial<SourceControlProvider.SourceControlProviderShape> = {},
22+): SourceControlProvider.SourceControlProviderShape {
2723 const unsupported = (operation: string) =>
2824 Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect<
2925 never,
@@ -43,7 +39,7 @@ function makeProvider( · 13 unmodified lines
4339 };
4440 }
4541
46−function processOutput(): ExecuteGitResult {
42+function processOutput(): GitVcsDriver.ExecuteGitResult {
4743 return {
4844 exitCode: ChildProcessSpawner.ExitCode(0),
4945 stdout: "",
@@ -54,17 +50,17 @@ function processOutput(): ExecuteGitResult { · 4 unmodified lines
5450 }
5551
5652 function makeLayer(input: {
57− readonly provider?: SourceControlProviderShape;
58− readonly git?: Partial<GitVcsDriverShape>;
53+ readonly provider?: SourceControlProvider.SourceControlProviderShape;
54+ readonly git?: Partial<GitVcsDriver.GitVcsDriverShape>;
5955 }) {
60− return layer.pipe(
56+ return SourceControlRepositoryService.layer.pipe(
6157 Layer.provide(
62− Layer.mock(SourceControlProviderRegistry)({
58+ Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
6359 get: () => Effect.succeed(input.provider ?? makeProvider()),
6460 }),
6561 ),
6662 Layer.provide(
67− Layer.mock(GitVcsDriver)({
63+ Layer.mock(GitVcsDriver.GitVcsDriver)({
6864 execute: () => Effect.succeed(processOutput()),
6965 ensureRemote: () => Effect.succeed("origin"),
7066 pushCurrentBranch: () =>
@@ -93,7 +89,7 @@ it.effect("looks up repositories through the requested provider without search", · 22 unmodified lines
9389 });
9490
9591 return Effect.gen(function* () {
96− const service = yield* SourceControlRepositoryService;
92+ const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
9793 const result = yield* service.lookupRepository({
9894 provider: "github",
9995 repository: "octocat/t3code",
@@ -115,7 +111,7 @@ it.effect("clones a looked-up repository into the requested destination", () => · 15 unmodified lines
115111 const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = [];
116112
117113 yield* Effect.gen(function* () {
118− const service = yield* SourceControlRepositoryService;
114+ const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
119115 const result = yield* service.cloneRepository({
120116 provider: "github",
121117 repository: "octocat/t3code",
@@ -167,7 +163,7 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up · 45 unmodified lines
167163 });
168164
169165 return Effect.gen(function* () {
170− const service = yield* SourceControlRepositoryService;
166+ const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
171167 const result = yield* service.publishRepository({
172168 cwd: "/workspace",
173169 provider: "github",
@@ -222,7 +218,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => { · 48 unmodified lines
222218 const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = [];
223219
224220 return Effect.gen(function* () {
225− const service = yield* SourceControlRepositoryService;
221+ const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
226222 const result = yield* service.publishRepository({
227223 cwd: "/workspace",
228224 provider: "github",
@@ -258,7 +254,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => { · 29 unmodified lines
258254 it.effect("publish succeeds with status remote_added when the local repo has no commits", () => {
259255 let pushCalls = 0;
260256 return Effect.gen(function* () {
261− const service = yield* SourceControlRepositoryService;
257+ const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
262258 const result = yield* service.publishRepository({
263259 cwd: "/workspace",
264260 provider: "github",
apps/server/src/sourceControl/SourceControlRepositoryService.ts+7−7
@@ -1,4 +1,4 @@
1−import OS from "node:os";
1+import * as NodeOS from "node:os";
22 import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect";
33
44 import {
@@ -15,8 +15,8 @@ import { · 10 unmodified lines
1515 } from "@t3tools/contracts";
1616
1717 import { ServerConfig } from "../config.ts";
18−import { GitVcsDriver } from "../vcs/GitVcsDriver.ts";
19−import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts";
18+import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
19+import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
2020
2121 export interface SourceControlRepositoryServiceShape {
2222 readonly lookupRepository: (
@@ -102,10 +102,10 @@ function selectRemoteUrl( · 79 unmodified lines
102102
103103 function expandHomePath(input: string, path: Path.Path): string {
104104 if (input === "~") {
105− return OS.homedir();
105+ return NodeOS.homedir();
106106 }
107107 if (input.startsWith("~/") || input.startsWith("~\\")) {
108− return path.join(OS.homedir(), input.slice(2));
108+ return path.join(NodeOS.homedir(), input.slice(2));
109109 }
110110 return input;
111111 }
@@ -113,9 +113,9 @@ function expandHomePath(input: string, path: Path.Path): string { · 1 unmodified line
113113 export const make = Effect.fn("makeSourceControlRepositoryService")(function* () {
114114 const config = yield* ServerConfig;
115115 const fileSystem = yield* FileSystem.FileSystem;
116− const git = yield* GitVcsDriver;
116+ const git = yield* GitVcsDriver.GitVcsDriver;
117117 const path = yield* Path.Path;
118− const providers = yield* SourceControlProviderRegistry;
118+ const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
119119
120120 const ensureConcreteProvider = (input: {
121121 readonly operation: string;
apps/server/src/sourceControl/azureDevOpsPullRequests.ts+106−0
@@ -0,0 +1,106 @@
1+import { Cause, DateTime, Exit, Option, Result, Schema } from "effect";
2+import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
3+import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
4+
5+export interface NormalizedAzureDevOpsPullRequestRecord {
6+ readonly number: number;
7+ readonly title: string;
8+ readonly url: string;
9+ readonly baseRefName: string;
10+ readonly headRefName: string;
11+ readonly state: "open" | "closed" | "merged";
12+ readonly updatedAt: Option.Option<DateTime.Utc>;
13+}
14+
15+const AzureDevOpsPullRequestSchema = Schema.Struct({
16+ pullRequestId: PositiveInt,
17+ title: TrimmedNonEmptyString,
18+ url: Schema.optional(Schema.String),
19+ sourceRefName: TrimmedNonEmptyString,
20+ targetRefName: TrimmedNonEmptyString,
21+ status: Schema.String,
22+ creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
23+ closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
24+ _links: Schema.optional(
25+ Schema.Struct({
26+ web: Schema.optional(
27+ Schema.Struct({
28+ href: Schema.String,
29+ }),
30+ ),
31+ }),
32+ ),
33+});
34+
35+function trimOptionalString(value: string | null | undefined): string | null {
36+ const trimmed = value?.trim() ?? "";
37+ return trimmed.length > 0 ? trimmed : null;
38+}
39+
40+function normalizeRefName(refName: string): string {
41+ return refName.trim().replace(/^refs\/heads\//, "");
42+}
43+
44+function normalizeAzureDevOpsPullRequestState(status: string): "open" | "closed" | "merged" {
45+ switch (status.trim().toLowerCase()) {
46+ case "completed":
47+ return "merged";
48+ case "abandoned":
49+ return "closed";
50+ default:
51+ return "open";
52+ }
53+}
54+
55+function normalizeAzureDevOpsPullRequestRecord(
56+ raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>,
57+): NormalizedAzureDevOpsPullRequestRecord {
58+ return {
59+ number: raw.pullRequestId,
60+ title: raw.title,
61+ url: trimOptionalString(raw._links?.web?.href) ?? trimOptionalString(raw.url) ?? "",
62+ baseRefName: normalizeRefName(raw.targetRefName),
63+ headRefName: normalizeRefName(raw.sourceRefName),
64+ state: normalizeAzureDevOpsPullRequestState(raw.status),
65+ updatedAt: (raw.closedDate ?? Option.none()).pipe(
66+ Option.orElse(() => raw.creationDate ?? Option.none()),
67+ ),
68+ };
69+}
70+
71+const decodeAzureDevOpsPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
72+const decodeAzureDevOpsPullRequest = decodeJsonResult(AzureDevOpsPullRequestSchema);
73+const decodeAzureDevOpsPullRequestEntry = Schema.decodeUnknownExit(AzureDevOpsPullRequestSchema);
74+
75+export const formatAzureDevOpsJsonDecodeError = formatSchemaError;
76+
77+export function decodeAzureDevOpsPullRequestListJson(
78+ raw: string,
79+): Result.Result<
80+ ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>,
81+ Cause.Cause<Schema.SchemaError>
82+> {
83+ const result = decodeAzureDevOpsPullRequestList(raw);
84+ if (Result.isSuccess(result)) {
85+ const pullRequests: NormalizedAzureDevOpsPullRequestRecord[] = [];
86+ for (const entry of result.success) {
87+ const decodedEntry = decodeAzureDevOpsPullRequestEntry(entry);
88+ if (Exit.isFailure(decodedEntry)) {
89+ continue;
90+ }
91+ pullRequests.push(normalizeAzureDevOpsPullRequestRecord(decodedEntry.value));
92+ }
93+ return Result.succeed(pullRequests);
94+ }
95+ return Result.fail(result.failure);
96+}
97+
98+export function decodeAzureDevOpsPullRequestJson(
99+ raw: string,
100+): Result.Result<NormalizedAzureDevOpsPullRequestRecord, Cause.Cause<Schema.SchemaError>> {
101+ const result = decodeAzureDevOpsPullRequest(raw);
102+ if (Result.isSuccess(result)) {
103+ return Result.succeed(normalizeAzureDevOpsPullRequestRecord(result.success));
104+ }
105+ return Result.fail(result.failure);
106+}
apps/server/src/sourceControl/bitbucketPullRequests.ts+104−0
@@ -0,0 +1,104 @@
1+import { DateTime, Option, Schema } from "effect";
2+import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
3+
4+export interface NormalizedBitbucketPullRequestRecord {
5+ readonly number: number;
6+ readonly title: string;
7+ readonly url: string;
8+ readonly baseRefName: string;
9+ readonly headRefName: string;
10+ readonly state: "open" | "closed" | "merged";
11+ readonly updatedAt: Option.Option<DateTime.Utc>;
12+ readonly isCrossRepository?: boolean;
13+ readonly headRepositoryNameWithOwner?: string | null;
14+ readonly headRepositoryOwnerLogin?: string | null;
15+}
16+
17+export const BitbucketRepositoryRefSchema = Schema.Struct({
18+ full_name: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
19+ workspace: Schema.optional(
20+ Schema.NullOr(
21+ Schema.Struct({
22+ slug: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
23+ }),
24+ ),
25+ ),
26+});
27+
28+export const BitbucketPullRequestBranchSchema = Schema.Struct({
29+ repository: Schema.optional(Schema.NullOr(BitbucketRepositoryRefSchema)),
30+ branch: Schema.Struct({
31+ name: TrimmedNonEmptyString,
32+ }),
33+});
34+
35+export const BitbucketPullRequestSchema = Schema.Struct({
36+ id: PositiveInt,
37+ title: TrimmedNonEmptyString,
38+ state: Schema.optional(Schema.NullOr(Schema.String)),
39+ updated_on: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
40+ links: Schema.Struct({
41+ html: Schema.Struct({
42+ href: TrimmedNonEmptyString,
43+ }),
44+ }),
45+ source: BitbucketPullRequestBranchSchema,
46+ destination: BitbucketPullRequestBranchSchema,
47+});
48+
49+export const BitbucketPullRequestListSchema = Schema.Struct({
50+ values: Schema.Array(BitbucketPullRequestSchema),
51+ next: Schema.optional(TrimmedNonEmptyString),
52+});
53+
54+function trimOptionalString(value: string | null | undefined): string | null {
55+ const trimmed = value?.trim() ?? "";
56+ return trimmed.length > 0 ? trimmed : null;
57+}
58+
59+function repositoryOwner(repository: Schema.Schema.Type<typeof BitbucketRepositoryRefSchema>) {
60+ return (
61+ trimOptionalString(repository.workspace?.slug) ??
62+ (repository.full_name?.includes("/") ? (repository.full_name.split("/")[0] ?? null) : null)
63+ );
64+}
65+
66+function normalizeBitbucketPullRequestState(state: string | null | undefined) {
67+ switch (state?.trim().toUpperCase()) {
68+ case "MERGED":
69+ return "merged" as const;
70+ case "DECLINED":
71+ case "SUPERSEDED":
72+ return "closed" as const;
73+ case "OPEN":
74+ default:
75+ return "open" as const;
76+ }
77+}
78+
79+export function normalizeBitbucketPullRequestRecord(
80+ raw: Schema.Schema.Type<typeof BitbucketPullRequestSchema>,
81+): NormalizedBitbucketPullRequestRecord {
82+ const headRepositoryNameWithOwner = trimOptionalString(raw.source.repository?.full_name);
83+ const baseRepositoryNameWithOwner = trimOptionalString(raw.destination.repository?.full_name);
84+ const headRepositoryOwnerLogin = raw.source.repository
85+ ? repositoryOwner(raw.source.repository)
86+ : null;
87+ const isCrossRepository =
88+ headRepositoryNameWithOwner !== null &&
89+ baseRepositoryNameWithOwner !== null &&
90+ headRepositoryNameWithOwner !== baseRepositoryNameWithOwner;
91+
92+ return {
93+ number: raw.id,
94+ title: raw.title,
95+ url: raw.links.html.href,
96+ baseRefName: raw.destination.branch.name,
97+ headRefName: raw.source.branch.name,
98+ state: normalizeBitbucketPullRequestState(raw.state),
99+ updatedAt: raw.updated_on ?? Option.none(),
100+ ...(isCrossRepository ? { isCrossRepository: true } : {}),
101+ ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
102+ ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
103+ };
104+}

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