From 7c90ddd76edee040b2870fc800974a8fa0fc222c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 11:55:48 -0700 Subject: [PATCH 01/24] Add Bitbucket source control provider --- apps/server/src/server.ts | 3 +- .../src/sourceControl/BitbucketCli.test.ts | 241 ++++++++++++ apps/server/src/sourceControl/BitbucketCli.ts | 370 ++++++++++++++++++ .../BitbucketSourceControlProvider.test.ts | 125 ++++++ .../BitbucketSourceControlProvider.ts | 111 ++++++ .../SourceControlProviderRegistry.test.ts | 20 +- .../SourceControlProviderRegistry.ts | 6 + .../sourceControl/bitbucketPullRequests.ts | 146 +++++++ 8 files changed, 1020 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/sourceControl/BitbucketCli.test.ts create mode 100644 apps/server/src/sourceControl/BitbucketCli.ts create mode 100644 apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts create mode 100644 apps/server/src/sourceControl/BitbucketSourceControlProvider.ts create mode 100644 apps/server/src/sourceControl/bitbucketPullRequests.ts diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 49e18ffefa..d8227116bb 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -25,6 +25,7 @@ import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReap import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; +import * as BitbucketCli from "./sourceControl/BitbucketCli.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; @@ -163,7 +164,7 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( ); const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe( - Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)), + Layer.provide(Layer.mergeAll(BitbucketCli.layer, GitHubCli.layer, GitLabCli.layer)), Layer.provideMerge(VcsDriverRegistryLayerLive), ); diff --git a/apps/server/src/sourceControl/BitbucketCli.test.ts b/apps/server/src/sourceControl/BitbucketCli.test.ts new file mode 100644 index 0000000000..477c450e2f --- /dev/null +++ b/apps/server/src/sourceControl/BitbucketCli.test.ts @@ -0,0 +1,241 @@ +import { assert, it } from "@effect/vitest"; +import { DateTime, Effect, Layer, Option } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { afterEach, describe, vi } from "vitest"; +import type { VcsError } from "@t3tools/contracts"; + +import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; +import * as BitbucketCli from "./BitbucketCli.ts"; + +const processOutput = (stdout: string): VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const mockRun = vi.fn<(input: VcsProcessInput) => Effect.Effect>(); + +const layer = BitbucketCli.layer.pipe( + Layer.provide( + Layer.mock(VcsProcess)({ + run: mockRun, + }), + ), +); + +afterEach(() => { + mockRun.mockReset(); +}); + +describe("BitbucketCli.layer", () => { + it.effect("parses pull request view output", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + id: 42, + title: "Add Bitbucket provider", + state: "OPEN", + updated_on: "2026-01-02T00:00:00.000Z", + links: { + html: { + href: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", + }, + }, + source: { + branch: { name: "feature/source-control" }, + repository: { + full_name: "octocat/t3code", + workspace: { slug: "octocat" }, + }, + }, + destination: { + branch: { name: "main" }, + repository: { + full_name: "pingdotgg/t3code", + workspace: { slug: "pingdotgg" }, + }, + }, + }), + ), + ), + ); + + const bb = yield* BitbucketCli.BitbucketCli; + const result = yield* bb.getPullRequest({ + cwd: "/repo", + reference: "#42", + }); + + assert.deepStrictEqual(result, { + number: 42, + title: "Add Bitbucket provider", + url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/t3code", + headRepositoryOwnerLogin: "octocat", + }); + assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], { + operation: "BitbucketCli.execute", + command: "bb", + args: ["pr", "view", "42", "--json"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("lists pull requests with Bitbucket state and source branch arguments", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + values: [ + { + id: 7, + title: "Merged work", + state: "MERGED", + links: { + html: { + href: "https://bitbucket.org/pingdotgg/t3code/pull-requests/7", + }, + }, + source: { + branch: { name: "feature/merged" }, + repository: { full_name: "pingdotgg/t3code" }, + }, + destination: { + branch: { name: "main" }, + repository: { full_name: "pingdotgg/t3code" }, + }, + }, + ], + }), + ), + ), + ); + + const bb = yield* BitbucketCli.BitbucketCli; + const result = yield* bb.listPullRequests({ + cwd: "/repo", + headSelector: "origin:feature/merged", + state: "merged", + limit: 10, + }); + + assert.strictEqual(result[0]?.state, "merged"); + assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], { + operation: "BitbucketCli.execute", + command: "bb", + args: [ + "pr", + "list", + "--head", + "feature/merged", + "--state", + "merged", + "--limit", + "10", + "--json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reads repository clone URLs and default branch", () => + Effect.gen(function* () { + const repositoryJson = JSON.stringify({ + full_name: "pingdotgg/t3code", + links: { + html: { href: "https://bitbucket.org/pingdotgg/t3code" }, + clone: [ + { name: "https", href: "https://bitbucket.org/pingdotgg/t3code.git" }, + { name: "ssh", href: "git@bitbucket.org:pingdotgg/t3code.git" }, + ], + }, + mainbranch: { name: "main" }, + }); + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(repositoryJson))); + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(repositoryJson))); + + const bb = yield* BitbucketCli.BitbucketCli; + const cloneUrls = yield* bb.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "pingdotgg/t3code", + }); + const defaultBranch = yield* bb.getDefaultBranch({ cwd: "/repo" }); + + assert.deepStrictEqual(cloneUrls, { + nameWithOwner: "pingdotgg/t3code", + url: "https://bitbucket.org/pingdotgg/t3code.git", + sshUrl: "git@bitbucket.org:pingdotgg/t3code.git", + }); + assert.strictEqual(defaultBranch, "main"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("creates pull requests using provider-neutral branch names", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); + + const bb = yield* BitbucketCli.BitbucketCli; + yield* bb.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "owner:feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + + assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], { + operation: "BitbucketCli.execute", + command: "bb", + args: [ + "pr", + "create", + "--destination", + "main", + "--source", + "feature/provider", + "--title", + "Provider PR", + "--body-file", + "/tmp/body.md", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("passes --force when checking out pull requests with force enabled", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const bb = yield* BitbucketCli.BitbucketCli; + yield* bb.checkoutPullRequest({ + cwd: "/repo", + reference: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", + force: true, + }); + + assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], { + operation: "BitbucketCli.execute", + command: "bb", + args: ["pr", "checkout", "42", "--force"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/apps/server/src/sourceControl/BitbucketCli.ts b/apps/server/src/sourceControl/BitbucketCli.ts new file mode 100644 index 0000000000..459e77d29d --- /dev/null +++ b/apps/server/src/sourceControl/BitbucketCli.ts @@ -0,0 +1,370 @@ +import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; +import { TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts"; + +import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; +import { + decodeBitbucketPullRequestJson, + decodeBitbucketPullRequestListJson, + formatBitbucketJsonDecodeError, + type NormalizedBitbucketPullRequestRecord, +} from "./bitbucketPullRequests.ts"; +import type { SourceControlRefSelector } from "./SourceControlProvider.ts"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +export class BitbucketCliError extends Schema.TaggedErrorClass()( + "BitbucketCliError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Bitbucket CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export interface BitbucketRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; +} + +export interface BitbucketCliShape { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly source?: SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, BitbucketCliError>; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlRefSelector; + readonly target?: SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; +} + +export class BitbucketCli extends Context.Service()( + "t3/source-control/BitbucketCli", +) {} + +function errorText(error: VcsError | unknown): string { + if (typeof error === "object" && error !== null) { + const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; + const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; + const message = "message" in error && typeof error.message === "string" ? error.message : ""; + return [tag, detail, message].filter(Boolean).join("\n"); + } + + return String(error); +} + +function normalizeBitbucketCliError( + operation: "execute", + error: VcsError | unknown, +): BitbucketCliError { + const text = errorText(error); + const lower = text.toLowerCase(); + + if (lower.includes("command not found: bb") || lower.includes("enoent")) { + return new BitbucketCliError({ + operation, + detail: + "Bitbucket CLI (`bb`) is required but not available on PATH. Install a gh-style Bitbucket CLI and retry.", + cause: error, + }); + } + + if ( + lower.includes("bb auth login") || + lower.includes("not logged in") || + lower.includes("authentication failed") || + lower.includes("unauthorized") || + lower.includes("forbidden") + ) { + return new BitbucketCliError({ + operation, + detail: "Bitbucket CLI is not authenticated. Run `bb auth login` and retry.", + cause: error, + }); + } + + if (lower.includes("pull request") && lower.includes("not found")) { + return new BitbucketCliError({ + operation, + detail: "Pull request not found. Check the PR number or URL and try again.", + cause: error, + }); + } + + return new BitbucketCliError({ + operation, + detail: text, + cause: error, + }); +} + +function normalizeChangeRequestId(reference: string): string { + const trimmed = reference.trim().replace(/^#/, ""); + const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec( + trimmed, + ); + return urlMatch?.[1] ?? trimmed; +} + +function normalizeSourceBranch(headSelector: string): string { + const trimmed = headSelector.trim(); + const ownerSelector = /^([^:/\s]+):(.+)$/u.exec(trimmed); + return ownerSelector?.[2]?.trim() ?? trimmed; +} + +function sourceBranch(input: { + readonly headSelector: string; + readonly source?: SourceControlRefSelector; +}): string { + return input.source?.refName ?? normalizeSourceBranch(input.headSelector); +} + +function toBitbucketState(state: "open" | "closed" | "merged" | "all"): string { + switch (state) { + case "open": + return "open"; + case "closed": + return "declined"; + case "merged": + return "merged"; + case "all": + return "all"; + } +} + +const RawBitbucketRepositorySchema = Schema.Struct({ + full_name: TrimmedNonEmptyString, + links: Schema.Struct({ + html: Schema.optional( + Schema.Struct({ + href: TrimmedNonEmptyString, + }), + ), + clone: Schema.optional( + Schema.Array( + Schema.Struct({ + name: TrimmedNonEmptyString, + href: TrimmedNonEmptyString, + }), + ), + ), + }), + mainbranch: Schema.optional( + Schema.NullOr( + Schema.Struct({ + name: TrimmedNonEmptyString, + }), + ), + ), +}); + +function normalizeRepositoryCloneUrls( + raw: Schema.Schema.Type, +): BitbucketRepositoryCloneUrls { + const httpClone = + raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ?? + raw.links.html?.href; + const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href; + + return { + nameWithOwner: raw.full_name, + url: httpClone ?? raw.links.html?.href ?? raw.full_name, + sshUrl: sshClone ?? httpClone ?? raw.full_name, + }; +} + +function decodeBitbucketJson( + raw: string, + schema: S, + operation: "getRepositoryCloneUrls" | "getDefaultBranch", + invalidDetail: string, +): Effect.Effect { + return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError( + (error) => + new BitbucketCliError({ + operation, + detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, + cause: error, + }), + ), + ); +} + +export const make = Effect.fn("makeBitbucketCli")(function* () { + const process = yield* VcsProcess; + + const execute: BitbucketCliShape["execute"] = (input) => + process + .run({ + operation: "BitbucketCli.execute", + command: "bb", + args: input.args, + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }) + .pipe(Effect.mapError((error) => normalizeBitbucketCliError("execute", error))); + + return BitbucketCli.of({ + execute, + listPullRequests: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + sourceBranch(input), + "--state", + toBitbucketState(input.state), + "--limit", + String(input.limit ?? 20), + "--json", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + raw.length === 0 + ? Effect.succeed([]) + : Effect.sync(() => decodeBitbucketPullRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new BitbucketCliError({ + operation: "listPullRequests", + detail: `Bitbucket CLI returned invalid PR list JSON: ${formatBitbucketJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed(decoded.success); + }), + ), + ), + ), + getPullRequest: (input) => + execute({ + cwd: input.cwd, + args: ["pr", "view", normalizeChangeRequestId(input.reference), "--json"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + Effect.sync(() => decodeBitbucketPullRequestJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new BitbucketCliError({ + operation: "getPullRequest", + detail: `Bitbucket CLI returned invalid pull request JSON: ${formatBitbucketJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed(decoded.success); + }), + ), + ), + ), + getRepositoryCloneUrls: (input) => + execute({ + cwd: input.cwd, + args: ["repo", "view", input.repository, "--json"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeBitbucketJson( + raw, + RawBitbucketRepositorySchema, + "getRepositoryCloneUrls", + "Bitbucket CLI returned invalid repository JSON.", + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ), + createPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "create", + "--destination", + input.target?.refName ?? input.baseBranch, + "--source", + sourceBranch(input), + "--title", + input.title, + "--body-file", + input.bodyFile, + ], + }).pipe(Effect.asVoid), + getDefaultBranch: (input) => + execute({ + cwd: input.cwd, + args: ["repo", "view", "--json"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeBitbucketJson( + raw, + RawBitbucketRepositorySchema, + "getDefaultBranch", + "Bitbucket CLI returned invalid repository JSON.", + ), + ), + Effect.map((repository) => repository.mainbranch?.name ?? null), + ), + checkoutPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "checkout", + normalizeChangeRequestId(input.reference), + ...(input.force ? ["--force"] : []), + ], + }).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(BitbucketCli, make()); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts new file mode 100644 index 0000000000..25d314bab3 --- /dev/null +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -0,0 +1,125 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; + +import { BitbucketCli, type BitbucketCliShape } from "./BitbucketCli.ts"; +import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; + +function makeProvider(bitbucket: Partial) { + return BitbucketSourceControlProvider.make().pipe( + Effect.provide(Layer.mock(BitbucketCli)(bitbucket)), + ); +} + +it.effect("maps Bitbucket PR summaries into provider-neutral change requests", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + getPullRequest: () => + Effect.succeed({ + number: 42, + title: "Add Bitbucket provider", + url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }), + }); + + const changeRequest = yield* provider.getChangeRequest({ + cwd: "/repo", + reference: "42", + }); + + assert.deepStrictEqual(changeRequest, { + provider: "bitbucket", + number: 42, + title: "Add Bitbucket provider", + url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }); + }), +); + +it.effect("lists Bitbucket PRs through provider-neutral input names", () => + Effect.gen(function* () { + let listInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + listPullRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature/provider", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(listInput, { + cwd: "/repo", + headSelector: "feature/provider", + state: "all", + limit: 10, + }); + }), +); + +it.effect("creates Bitbucket PRs through provider-neutral input names", () => + Effect.gen(function* () { + let createInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + createPullRequest: (input) => { + createInput = input; + return Effect.void; + }, + }); + + yield* provider.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "owner:feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + + assert.deepStrictEqual(createInput, { + cwd: "/repo", + baseBranch: "main", + headSelector: "owner:feature/provider", + source: { + owner: "owner", + refName: "feature/provider", + }, + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + }), +); + +it.effect("uses Bitbucket CLI repository detection for default branch lookup", () => + Effect.gen(function* () { + let cwdInput: string | null = null; + const provider = yield* makeProvider({ + getDefaultBranch: (input) => { + cwdInput = input.cwd; + return Effect.succeed("main"); + }, + }); + + const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); + + assert.strictEqual(defaultBranch, "main"); + assert.strictEqual(cwdInput, "/repo"); + }), +); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts new file mode 100644 index 0000000000..cba5630f42 --- /dev/null +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -0,0 +1,111 @@ +import { Effect, Layer, Option } from "effect"; +import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; + +import { BitbucketCli, type BitbucketCliError } from "./BitbucketCli.ts"; +import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts"; +import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts"; + +function providerError(operation: string, cause: BitbucketCliError): SourceControlProviderError { + return new SourceControlProviderError({ + provider: "bitbucket", + operation, + detail: cause.detail, + cause, + }); +} + +function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { + return { + provider: "bitbucket", + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state, + updatedAt: summary.updatedAt ?? Option.none(), + ...(summary.isCrossRepository !== undefined + ? { isCrossRepository: summary.isCrossRepository } + : {}), + ...(summary.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } + : {}), + ...(summary.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } + : {}), + }; +} + +function sourceFromInput(input: { + readonly headSelector: string; + readonly source?: SourceControlRefSelector; +}): SourceControlRefSelector | undefined { + if (input.source) { + return input.source; + } + + const match = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); + const owner = match?.[1]?.trim(); + const refName = match?.[2]?.trim(); + return owner && refName ? { owner, refName } : undefined; +} + +export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () { + const bitbucket = yield* BitbucketCli; + + return SourceControlProvider.of({ + kind: "bitbucket", + listChangeRequests: (input) => { + const source = sourceFromInput(input); + return bitbucket + .listPullRequests({ + cwd: input.cwd, + headSelector: input.headSelector, + ...(source ? { source } : {}), + state: input.state, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((items) => items.map(toChangeRequest)), + Effect.mapError((error) => providerError("listChangeRequests", error)), + ); + }, + getChangeRequest: (input) => + bitbucket.getPullRequest(input).pipe( + Effect.map(toChangeRequest), + Effect.mapError((error) => providerError("getChangeRequest", error)), + ), + createChangeRequest: (input) => { + const source = sourceFromInput(input); + return bitbucket + .createPullRequest({ + cwd: input.cwd, + baseBranch: input.baseRefName, + headSelector: input.headSelector, + ...(source ? { source } : {}), + ...(input.target ? { target: input.target } : {}), + title: input.title, + bodyFile: input.bodyFile, + }) + .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + }, + getRepositoryCloneUrls: (input) => + bitbucket + .getRepositoryCloneUrls(input) + .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + getDefaultBranch: (input) => + bitbucket + .getDefaultBranch({ cwd: input.cwd }) + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + checkoutChangeRequest: (input) => + bitbucket + .checkoutPullRequest({ + cwd: input.cwd, + reference: input.reference, + ...(input.force !== undefined ? { force: input.force } : {}), + }) + .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + }); +}); + +export const layer = Layer.effect(SourceControlProvider, make()); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index fbfe8a3689..bb7008f247 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -1,6 +1,7 @@ import { assert, it } from "@effect/vitest"; import { DateTime, Effect, Layer, Option } from "effect"; +import { BitbucketCli } from "./BitbucketCli.ts"; import { GitHubCli } from "./GitHubCli.ts"; import { GitLabCli } from "./GitLabCli.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; @@ -52,7 +53,12 @@ function makeRegistry(input: { return SourceControlProviderRegistry.make().pipe( Effect.provide( - Layer.mergeAll(registryLayer, Layer.mock(GitHubCli)({}), Layer.mock(GitLabCli)({})), + Layer.mergeAll( + registryLayer, + Layer.mock(BitbucketCli)({}), + Layer.mock(GitHubCli)({}), + Layer.mock(GitLabCli)({}), + ), ), ); } @@ -93,6 +99,18 @@ it.effect("routes GitLab remotes to the GitLab provider", () => }), ); +it.effect("routes Bitbucket remotes to the Bitbucket provider", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@bitbucket.org:pingdotgg/t3code.git" }], + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "bitbucket"); + }), +); + it.effect("falls back to a non-origin remote when origin is not configured", () => Effect.gen(function* () { const registry = yield* makeRegistry({ diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 6b04b1f8bb..ad9536ecd0 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -8,6 +8,7 @@ import { type SourceControlProviderContext, type SourceControlProviderShape, } from "./SourceControlProvider.ts"; +import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; @@ -154,6 +155,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () { const github = yield* GitHubSourceControlProvider.make(); const gitlab = yield* GitLabSourceControlProvider.make(); + const bitbucket = yield* BitbucketSourceControlProvider.make(); return yield* makeWithProviders([ { kind: "github", @@ -163,6 +165,10 @@ export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () kind: "gitlab", provider: gitlab, }, + { + kind: "bitbucket", + provider: bitbucket, + }, ]); }); diff --git a/apps/server/src/sourceControl/bitbucketPullRequests.ts b/apps/server/src/sourceControl/bitbucketPullRequests.ts new file mode 100644 index 0000000000..c2b5518104 --- /dev/null +++ b/apps/server/src/sourceControl/bitbucketPullRequests.ts @@ -0,0 +1,146 @@ +import { Cause, DateTime, Exit, Option, Result, Schema } from "effect"; +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; + +export interface NormalizedBitbucketPullRequestRecord { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: Option.Option; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +const BitbucketRepositoryRefSchema = Schema.Struct({ + full_name: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + workspace: Schema.optional( + Schema.NullOr( + Schema.Struct({ + slug: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + }), + ), + ), +}); + +const BitbucketPullRequestBranchSchema = Schema.Struct({ + repository: Schema.optional(Schema.NullOr(BitbucketRepositoryRefSchema)), + branch: Schema.Struct({ + name: TrimmedNonEmptyString, + }), +}); + +const BitbucketPullRequestSchema = Schema.Struct({ + id: PositiveInt, + title: TrimmedNonEmptyString, + state: Schema.optional(Schema.NullOr(Schema.String)), + updated_on: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), + links: Schema.Struct({ + html: Schema.Struct({ + href: TrimmedNonEmptyString, + }), + }), + source: BitbucketPullRequestBranchSchema, + destination: BitbucketPullRequestBranchSchema, +}); + +function trimOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function repositoryOwner(repository: Schema.Schema.Type) { + return ( + trimOptionalString(repository.workspace?.slug) ?? + (repository.full_name?.includes("/") ? (repository.full_name.split("/")[0] ?? null) : null) + ); +} + +function normalizeBitbucketPullRequestState(state: string | null | undefined) { + switch (state?.trim().toUpperCase()) { + case "MERGED": + return "merged" as const; + case "DECLINED": + case "SUPERSEDED": + return "closed" as const; + case "OPEN": + default: + return "open" as const; + } +} + +function normalizeBitbucketPullRequestRecord( + raw: Schema.Schema.Type, +): NormalizedBitbucketPullRequestRecord { + const headRepositoryNameWithOwner = trimOptionalString(raw.source.repository?.full_name); + const baseRepositoryNameWithOwner = trimOptionalString(raw.destination.repository?.full_name); + const headRepositoryOwnerLogin = raw.source.repository + ? repositoryOwner(raw.source.repository) + : null; + const isCrossRepository = + headRepositoryNameWithOwner !== null && + baseRepositoryNameWithOwner !== null && + headRepositoryNameWithOwner !== baseRepositoryNameWithOwner; + + return { + number: raw.id, + title: raw.title, + url: raw.links.html.href, + baseRefName: raw.destination.branch.name, + headRefName: raw.source.branch.name, + state: normalizeBitbucketPullRequestState(raw.state), + updatedAt: raw.updated_on ?? Option.none(), + ...(isCrossRepository ? { isCrossRepository: true } : {}), + ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), + }; +} + +const decodeBitbucketPullRequestList = decodeJsonResult(Schema.Unknown); +const decodeBitbucketPullRequest = decodeJsonResult(BitbucketPullRequestSchema); +const decodeBitbucketPullRequestEntry = Schema.decodeUnknownExit(BitbucketPullRequestSchema); + +export const formatBitbucketJsonDecodeError = formatSchemaError; + +export function decodeBitbucketPullRequestListJson( + raw: string, +): Result.Result< + ReadonlyArray, + Cause.Cause +> { + const result = decodeBitbucketPullRequestList(raw); + if (Result.isFailure(result)) { + return Result.fail(result.failure); + } + + const entries: ReadonlyArray = Array.isArray(result.success) + ? result.success + : typeof result.success === "object" && + result.success !== null && + "values" in result.success && + Array.isArray(result.success.values) + ? result.success.values + : []; + const pullRequests: NormalizedBitbucketPullRequestRecord[] = []; + for (const entry of entries) { + const decodedEntry = decodeBitbucketPullRequestEntry(entry); + if (Exit.isFailure(decodedEntry)) { + continue; + } + pullRequests.push(normalizeBitbucketPullRequestRecord(decodedEntry.value)); + } + return Result.succeed(pullRequests); +} + +export function decodeBitbucketPullRequestJson( + raw: string, +): Result.Result> { + const result = decodeBitbucketPullRequest(raw); + if (Result.isSuccess(result)) { + return Result.succeed(normalizeBitbucketPullRequestRecord(result.success)); + } + return Result.fail(result.failure); +} From f6ae51a3415fb98e3d71201d8ef652497db7adb5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 12:18:18 -0700 Subject: [PATCH 02/24] Enable Bitbucket source control discovery --- .../SourceControlDiscovery.test.ts | 2 +- .../sourceControl/SourceControlDiscovery.ts | 39 ++++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 4ac3a3ffb4..3a2ddf311d 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -108,7 +108,7 @@ Logged in to github.com account juliusmarminge (keyring) }, { kind: "bitbucket", - implemented: false, + implemented: true, status: "missing", auth: "unknown", account: Option.none(), diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index 8a387a96cd..64e5ef8722 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -103,8 +103,12 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray = [ { kind: "bitbucket", label: "Bitbucket", - implemented: false, - installHint: "Bitbucket provider support is not available yet.", + executable: "bb", + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: parseBitbucketAuth, + implemented: true, + installHint: "Install a Bitbucket CLI (`bb`) and authenticate it for your Bitbucket workspace.", }, ]; @@ -264,6 +268,37 @@ function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth { }); } +function parseBitbucketAuth(input: AuthProbeInput): SourceControlProviderAuth { + const output = combinedAuthOutput(input); + const account = matchFirst(output, [ + /Logged in to .* as\s+([^\s(]+)/iu, + /Logged in as\s+([^\s(]+)/iu, + /account:\s*([^\s(]+)/iu, + /user:\s*([^\s(]+)/iu, + /username:\s*([^\s(]+)/iu, + ]); + const host = parseCliHost(output) ?? "bitbucket.org"; + + if (input.exitCode !== 0) { + return providerAuth({ + status: "unauthenticated", + host, + detail: + firstSafeAuthLine(output) ?? "Authenticate the Bitbucket CLI before enabling Bitbucket.", + }); + } + + if (account) { + return providerAuth({ status: "authenticated", account, host }); + } + + return providerAuth({ + status: "unknown", + host, + detail: firstSafeAuthLine(output) ?? "Bitbucket CLI auth status could not be parsed.", + }); +} + export interface SourceControlDiscoveryShape { readonly discover: Effect.Effect; } From 1d66235f56f709b5676ee6758b7272cc5b75e913 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 19:23:19 -0700 Subject: [PATCH 03/24] Use Bitbucket REST API provider --- apps/server/src/server.ts | 4 +- .../src/sourceControl/BitbucketApi.test.ts | 292 ++++++++++ apps/server/src/sourceControl/BitbucketApi.ts | 507 ++++++++++++++++++ .../src/sourceControl/BitbucketCli.test.ts | 241 --------- apps/server/src/sourceControl/BitbucketCli.ts | 370 ------------- .../BitbucketSourceControlProvider.test.ts | 12 +- .../BitbucketSourceControlProvider.ts | 14 +- .../SourceControlDiscovery.test.ts | 34 +- .../sourceControl/SourceControlDiscovery.ts | 156 +++--- .../SourceControlProviderRegistry.test.ts | 4 +- .../SourceControlProviderRegistry.ts | 46 +- .../sourceControl/bitbucketPullRequests.ts | 13 +- apps/server/src/ws.ts | 6 +- packages/shared/src/sourceControl.ts | 2 +- 14 files changed, 976 insertions(+), 725 deletions(-) create mode 100644 apps/server/src/sourceControl/BitbucketApi.test.ts create mode 100644 apps/server/src/sourceControl/BitbucketApi.ts delete mode 100644 apps/server/src/sourceControl/BitbucketCli.test.ts delete mode 100644 apps/server/src/sourceControl/BitbucketCli.ts diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d8227116bb..1d579c7d03 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -25,7 +25,7 @@ import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReap import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; -import * as BitbucketCli from "./sourceControl/BitbucketCli.ts"; +import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; @@ -164,7 +164,7 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( ); const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe( - Layer.provide(Layer.mergeAll(BitbucketCli.layer, GitHubCli.layer, GitLabCli.layer)), + Layer.provide(Layer.mergeAll(BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer)), Layer.provideMerge(VcsDriverRegistryLayerLive), ); diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts new file mode 100644 index 0000000000..a989c9232b --- /dev/null +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -0,0 +1,292 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ConfigProvider, DateTime, Effect, FileSystem, Layer, Option } from "effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { vi } from "vitest"; + +import * as BitbucketApi from "./BitbucketApi.ts"; +import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; +import type { VcsDriverShape } from "../vcs/VcsDriver.ts"; + +const bitbucketPullRequest = { + id: 42, + title: "Add Bitbucket provider", + state: "OPEN", + updated_on: "2026-01-02T00:00:00.000Z", + links: { + html: { + href: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", + }, + }, + source: { + branch: { name: "feature/source-control" }, + repository: { + full_name: "octocat/t3code", + workspace: { slug: "octocat" }, + }, + }, + destination: { + branch: { name: "main" }, + repository: { + full_name: "pingdotgg/t3code", + workspace: { slug: "pingdotgg" }, + }, + }, +}; + +const repositoryJson = { + full_name: "pingdotgg/t3code", + links: { + html: { href: "https://bitbucket.org/pingdotgg/t3code" }, + clone: [ + { name: "https", href: "https://bitbucket.org/pingdotgg/t3code.git" }, + { name: "ssh", href: "git@bitbucket.org:pingdotgg/t3code.git" }, + ], + }, + mainbranch: { name: "main" }, +}; + +function makeLayer(input: { + readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; +}) { + const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => + Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), + ); + + const driver = { + listRemotes: () => + Effect.succeed({ + remotes: [ + { + name: "origin", + url: "git@bitbucket.org:pingdotgg/t3code.git", + pushUrl: Option.none(), + isPrimary: true, + }, + ], + freshness: { + source: "live-local" as const, + observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), + expiresAt: Option.none(), + }, + }), + } satisfies Partial; + + const layer = BitbucketApi.layer.pipe( + Layer.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => execute(request)), + ), + ), + Layer.provide( + Layer.mock(VcsDriverRegistry)({ + resolve: () => + Effect.succeed({ + kind: "git", + repository: { + kind: "git", + rootPath: "/repo", + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), + expiresAt: Option.none(), + }, + }, + driver: driver as unknown as VcsDriverShape, + }), + }), + ), + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0", + T3CODE_BITBUCKET_EMAIL: "user@example.com", + T3CODE_BITBUCKET_API_TOKEN: "token", + }, + }), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return { execute, layer }; +} + +it.effect("parses pull request responses from the Bitbucket REST API", () => { + const { execute, layer } = makeLayer({ + response: () => + Response.json({ + ...bitbucketPullRequest, + }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const result = yield* bitbucket.getPullRequest({ + cwd: "/repo", + reference: "#42", + }); + + assert.deepStrictEqual(result, { + number: 42, + title: "Add Bitbucket provider", + url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/t3code", + headRepositoryOwnerLogin: "octocat", + }); + assert.strictEqual( + execute.mock.calls[0]?.[0].url, + "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests/42", + ); + }).pipe(Effect.provide(layer)); +}); + +it.effect("lists pull requests with Bitbucket state and source branch query params", () => { + const { execute, layer } = makeLayer({ + response: () => + Response.json({ + values: [ + { + ...bitbucketPullRequest, + id: 7, + state: "MERGED", + source: { + branch: { name: "feature/merged" }, + repository: { full_name: "pingdotgg/t3code" }, + }, + }, + ], + }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const result = yield* bitbucket.listPullRequests({ + cwd: "/repo", + headSelector: "origin:feature/merged", + state: "merged", + limit: 10, + }); + + assert.strictEqual(result[0]?.state, "merged"); + const request = execute.mock.calls[0]?.[0]; + assert.strictEqual( + request?.url, + "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests", + ); + assert.deepStrictEqual(request?.urlParams.params, [ + ["pagelen", "10"], + ["sort", "-updated_on"], + ["q", 'source.branch.name = "feature/merged"'], + ["state", "MERGED"], + ]); + }).pipe(Effect.provide(layer)); +}); + +it.effect("reads repository clone URLs and default branch", () => { + const { layer } = makeLayer({ + response: () => Response.json(repositoryJson), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const cloneUrls = yield* bitbucket.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "pingdotgg/t3code", + }); + const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" }); + + assert.deepStrictEqual(cloneUrls, { + nameWithOwner: "pingdotgg/t3code", + url: "https://bitbucket.org/pingdotgg/t3code.git", + sshUrl: "git@bitbucket.org:pingdotgg/t3code.git", + }); + assert.strictEqual(defaultBranch, "main"); + }).pipe(Effect.provide(layer)); +}); + +it.effect("creates pull requests using the official REST payload shape", () => { + const { execute, layer } = makeLayer({ + response: () => Response.json(bitbucketPullRequest), + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const bodyFile = yield* fileSystem.makeTempFileScoped({ prefix: "bitbucket-pr-body-" }); + yield* fileSystem.writeFileString(bodyFile, "PR body"); + + const bitbucket = yield* BitbucketApi.BitbucketApi; + yield* bitbucket.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "owner:feature/provider", + title: "Provider PR", + bodyFile, + }); + + const request = execute.mock.calls[0]?.[0]; + assert.strictEqual( + request?.url, + "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests", + ); + assert.strictEqual(request?.method, "POST"); + assert.ok(request); + const rawBody = (request.body as { readonly body?: Uint8Array }).body; + assert.ok(rawBody); + assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), { + title: "Provider PR", + description: "PR body", + source: { + branch: { name: "feature/provider" }, + repository: { full_name: "owner/t3code" }, + }, + destination: { + branch: { name: "main" }, + }, + }); + }).pipe(Effect.provide(layer), Effect.scoped); +}); + +it.effect("reports auth status through the Bitbucket REST /user endpoint", () => { + const { layer } = makeLayer({ + response: () => Response.json({ username: "bitbucket-user" }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const auth = yield* bitbucket.probeAuth; + + assert.deepStrictEqual(auth, { + status: "authenticated", + account: Option.some("bitbucket-user"), + host: Option.some("bitbucket.org"), + detail: Option.none(), + }); + }).pipe(Effect.provide(layer)); +}); + +it.effect("does not pretend pull request checkout is supported by a non-existent CLI", () => { + const { layer } = makeLayer({ + response: () => Response.json({}), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const result = yield* Effect.exit( + bitbucket.checkoutPullRequest({ + cwd: "/repo", + reference: "42", + }), + ); + + assert.strictEqual(result._tag, "Failure"); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts new file mode 100644 index 0000000000..fe9aad4a4f --- /dev/null +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -0,0 +1,507 @@ +import { Config, Context, Effect, FileSystem, Layer, Option, Schema } from "effect"; +import { + TrimmedNonEmptyString, + type SourceControlProviderAuth, + type SourceControlRepositoryCloneUrls, +} from "@t3tools/contracts"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; + +import { + BitbucketPullRequestListSchema, + BitbucketPullRequestSchema, + normalizeBitbucketPullRequestRecord, + type NormalizedBitbucketPullRequestRecord, +} from "./bitbucketPullRequests.ts"; +import type { + SourceControlProviderContext, + SourceControlRefSelector, +} from "./SourceControlProvider.ts"; +import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; + +const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; + +const BitbucketApiEnvConfig = Config.all({ + baseUrl: Config.string("T3CODE_BITBUCKET_API_BASE_URL").pipe( + Config.withDefault(DEFAULT_API_BASE_URL), + ), + accessToken: Config.string("T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option), + email: Config.string("T3CODE_BITBUCKET_EMAIL").pipe(Config.option), + apiToken: Config.string("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), +}); + +export class BitbucketApiError extends Schema.TaggedErrorClass()( + "BitbucketApiError", + { + operation: Schema.String, + detail: Schema.String, + status: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: ${this.detail}`; + } +} + +const RawBitbucketRepositorySchema = Schema.Struct({ + full_name: TrimmedNonEmptyString, + links: Schema.Struct({ + html: Schema.optional( + Schema.Struct({ + href: TrimmedNonEmptyString, + }), + ), + clone: Schema.optional( + Schema.Array( + Schema.Struct({ + name: TrimmedNonEmptyString, + href: TrimmedNonEmptyString, + }), + ), + ), + }), + mainbranch: Schema.optional( + Schema.NullOr( + Schema.Struct({ + name: TrimmedNonEmptyString, + }), + ), + ), +}); + +const BitbucketUserSchema = Schema.Struct({ + username: Schema.optional(TrimmedNonEmptyString), + display_name: Schema.optional(TrimmedNonEmptyString), + account_id: Schema.optional(TrimmedNonEmptyString), +}); + +export interface BitbucketRepositoryLocator { + readonly workspace: string; + readonly repoSlug: string; +} + +export interface BitbucketApiShape { + readonly probeAuth: Effect.Effect; + readonly listPullRequests: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly headSelector: string; + readonly source?: SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, BitbucketApiError>; + readonly getPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + }) => Effect.Effect; + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly repository: string; + }) => Effect.Effect; + readonly createPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlRefSelector; + readonly target?: SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + readonly getDefaultBranch: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + }) => Effect.Effect; + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; +} + +export class BitbucketApi extends Context.Service()( + "t3/source-control/BitbucketApi", +) {} + +function nonEmpty(value: string | undefined): Option.Option { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +function normalizeChangeRequestId(reference: string): string { + const trimmed = reference.trim().replace(/^#/, ""); + const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec( + trimmed, + ); + return urlMatch?.[1] ?? trimmed; +} + +function normalizeSourceBranch(headSelector: string): string { + const trimmed = headSelector.trim(); + const ownerSelector = /^([^:/\s]+):(.+)$/u.exec(trimmed); + return ownerSelector?.[2]?.trim() ?? trimmed; +} + +function sourceBranch(input: { + readonly headSelector: string; + readonly source?: SourceControlRefSelector; +}): string { + return input.source?.refName ?? normalizeSourceBranch(input.headSelector); +} + +function sourceWorkspace(input: { + readonly headSelector: string; + readonly source?: SourceControlRefSelector; +}): string | undefined { + if (input.source?.owner) return input.source.owner; + const ownerSelector = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); + return ownerSelector?.[1]?.trim(); +} + +function toBitbucketState(state: "open" | "closed" | "merged" | "all"): string | null { + switch (state) { + case "open": + return "OPEN"; + case "closed": + return "DECLINED"; + case "merged": + return "MERGED"; + case "all": + return null; + } +} + +function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator | null { + const normalized = value.trim().replace(/\.git$/u, ""); + const parts = normalized.split("/").filter((part) => part.length > 0); + if (parts.length < 2) return null; + const workspace = parts.at(-2); + const repoSlug = parts.at(-1); + return workspace && repoSlug ? { workspace, repoSlug } : null; +} + +function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null { + const trimmed = remoteUrl.trim(); + if (trimmed.startsWith("git@")) { + const pathStart = trimmed.indexOf(":"); + return pathStart < 0 ? null : parseBitbucketRepositorySlug(trimmed.slice(pathStart + 1)); + } + + try { + return parseBitbucketRepositorySlug(new URL(trimmed).pathname); + } catch { + return null; + } +} + +function normalizeRepositoryCloneUrls( + raw: Schema.Schema.Type, +): SourceControlRepositoryCloneUrls { + const httpClone = + raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ?? + raw.links.html?.href; + const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href; + + return { + nameWithOwner: raw.full_name, + url: httpClone ?? raw.links.html?.href ?? raw.full_name, + sshUrl: sshClone ?? httpClone ?? raw.full_name, + }; +} + +function authFromConfig( + config: Config.Success, +): SourceControlProviderAuth { + if (Option.isSome(config.accessToken)) { + return { + status: "unknown", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.some("Bitbucket access token is configured."), + }; + } + + if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { + return { + status: "unknown", + account: config.email, + host: Option.some("bitbucket.org"), + detail: Option.some("Bitbucket API token is configured."), + }; + } + + return { + status: "unauthenticated", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.some( + "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + ), + }; +} + +function requestError(operation: string, cause: unknown): BitbucketApiError { + return new BitbucketApiError({ + operation, + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }); +} + +function responseError( + operation: string, + response: HttpClientResponse.HttpClientResponse, +): Effect.Effect { + return response.text.pipe( + Effect.catch(() => Effect.succeed("")), + Effect.flatMap((body) => + Effect.fail( + new BitbucketApiError({ + operation, + status: response.status, + detail: + body.trim().length > 0 + ? `Bitbucket returned HTTP ${response.status}: ${body.trim()}` + : `Bitbucket returned HTTP ${response.status}.`, + }), + ), + ), + ); +} + +export const make = Effect.fn("makeBitbucketApi")(function* () { + const config = yield* BitbucketApiEnvConfig; + const httpClient = yield* HttpClient.HttpClient; + const fileSystem = yield* FileSystem.FileSystem; + const vcsRegistry = yield* VcsDriverRegistry; + + const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; + + const withAuth = (request: HttpClientRequest.HttpClientRequest) => { + if (Option.isSome(config.accessToken)) { + return request.pipe(HttpClientRequest.bearerToken(config.accessToken.value)); + } + if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { + return request.pipe(HttpClientRequest.basicAuth(config.email.value, config.apiToken.value)); + } + return request; + }; + + const decodeResponse = ( + operation: string, + schema: S, + response: HttpClientResponse.HttpClientResponse, + ): Effect.Effect => + HttpClientResponse.matchStatus({ + "2xx": (success) => + HttpClientResponse.schemaBodyJson(schema)(success).pipe( + Effect.mapError( + (cause) => + new BitbucketApiError({ + operation, + detail: "Bitbucket returned invalid JSON for the requested resource.", + cause, + }), + ), + ), + orElse: (failed) => responseError(operation, failed), + })(response); + + const executeJson = ( + operation: string, + request: HttpClientRequest.HttpClientRequest, + schema: S, + ): Effect.Effect => + httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( + Effect.mapError((cause) => requestError(operation, cause)), + Effect.flatMap((response) => decodeResponse(operation, schema, response)), + ); + + const resolveRepository = Effect.fn("BitbucketApi.resolveRepository")(function* (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly repository?: string; + }) { + const fromRepository = + input.repository !== undefined ? parseBitbucketRepositorySlug(input.repository) : null; + if (fromRepository) return fromRepository; + + const fromContext = + input.context?.provider.kind === "bitbucket" + ? parseBitbucketRemoteUrl(input.context.remoteUrl) + : null; + if (fromContext) return fromContext; + + const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe( + Effect.mapError( + (cause) => + new BitbucketApiError({ + operation: "resolveRepository", + detail: `Failed to resolve VCS repository for ${input.cwd}.`, + cause, + }), + ), + ); + const remotes = yield* handle.driver.listRemotes(input.cwd).pipe( + Effect.mapError( + (cause) => + new BitbucketApiError({ + operation: "resolveRepository", + detail: `Failed to list remotes for ${input.cwd}.`, + cause, + }), + ), + ); + + for (const remote of remotes.remotes) { + if (detectSourceControlProviderFromRemoteUrl(remote.url)?.kind !== "bitbucket") continue; + const parsed = parseBitbucketRemoteUrl(remote.url); + if (parsed) return parsed; + } + + return yield* new BitbucketApiError({ + operation: "resolveRepository", + detail: `No Bitbucket repository remote was detected for ${input.cwd}.`, + }); + }); + + const getRepository = (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly repository?: string; + }) => + resolveRepository(input).pipe( + Effect.flatMap((repository) => + executeJson( + "getRepository", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, + ), + ), + RawBitbucketRepositorySchema, + ), + ), + ); + + return BitbucketApi.of({ + probeAuth: executeJson( + "probeAuth", + HttpClientRequest.get(apiUrl("/user")), + BitbucketUserSchema, + ).pipe( + Effect.map((user) => ({ + status: "authenticated" as const, + account: nonEmpty(user.username ?? user.display_name ?? user.account_id), + host: Option.some("bitbucket.org"), + detail: Option.none(), + })), + Effect.catch(() => Effect.succeed(authFromConfig(config))), + ), + listPullRequests: (input) => + resolveRepository(input).pipe( + Effect.flatMap((repository) => { + const state = toBitbucketState(input.state); + const query: Record = { + pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))), + sort: "-updated_on", + q: `source.branch.name = "${sourceBranch(input).replaceAll('"', '\\"')}"`, + }; + if (state !== null) { + query.state = state; + } + + return executeJson( + "listPullRequests", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, + ), + { urlParams: query }, + ), + BitbucketPullRequestListSchema, + ); + }), + Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)), + ), + getPullRequest: (input) => + resolveRepository(input).pipe( + Effect.flatMap((repository) => + executeJson( + "getPullRequest", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(input.reference))}`, + ), + ), + BitbucketPullRequestSchema, + ), + ), + Effect.map(normalizeBitbucketPullRequestRecord), + ), + getRepositoryCloneUrls: (input) => + getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), + createPullRequest: (input) => + Effect.gen(function* () { + const repository = yield* resolveRepository(input); + const description = yield* fileSystem.readFileString(input.bodyFile).pipe( + Effect.mapError( + (cause) => + new BitbucketApiError({ + operation: "createPullRequest", + detail: `Failed to read pull request body file ${input.bodyFile}.`, + cause, + }), + ), + ); + const sourceOwner = sourceWorkspace(input); + const body = { + title: input.title, + description, + source: { + branch: { + name: sourceBranch(input), + }, + ...(sourceOwner + ? { + repository: { + full_name: `${sourceOwner}/${input.source?.repository ?? repository.repoSlug}`, + }, + } + : {}), + }, + destination: { + branch: { + name: input.target?.refName ?? input.baseBranch, + }, + }, + }; + + yield* executeJson( + "createPullRequest", + HttpClientRequest.post( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, + ), + ).pipe(HttpClientRequest.bodyJsonUnsafe(body)), + BitbucketPullRequestSchema, + ); + }), + getDefaultBranch: (input) => + getRepository(input).pipe(Effect.map((repository) => repository.mainbranch?.name ?? null)), + checkoutPullRequest: () => + Effect.fail( + new BitbucketApiError({ + operation: "checkoutPullRequest", + detail: + "Bitbucket Cloud does not provide an official CLI checkout command. Add VCS-level checkout support for Bitbucket pull request refs before enabling this action.", + }), + ), + }); +}); + +export const layer = Layer.effect(BitbucketApi, make()); diff --git a/apps/server/src/sourceControl/BitbucketCli.test.ts b/apps/server/src/sourceControl/BitbucketCli.test.ts deleted file mode 100644 index 477c450e2f..0000000000 --- a/apps/server/src/sourceControl/BitbucketCli.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import { DateTime, Effect, Layer, Option } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { afterEach, describe, vi } from "vitest"; -import type { VcsError } from "@t3tools/contracts"; - -import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; -import * as BitbucketCli from "./BitbucketCli.ts"; - -const processOutput = (stdout: string): VcsProcessOutput => ({ - exitCode: ChildProcessSpawner.ExitCode(0), - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, -}); - -const mockRun = vi.fn<(input: VcsProcessInput) => Effect.Effect>(); - -const layer = BitbucketCli.layer.pipe( - Layer.provide( - Layer.mock(VcsProcess)({ - run: mockRun, - }), - ), -); - -afterEach(() => { - mockRun.mockReset(); -}); - -describe("BitbucketCli.layer", () => { - it.effect("parses pull request view output", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - JSON.stringify({ - id: 42, - title: "Add Bitbucket provider", - state: "OPEN", - updated_on: "2026-01-02T00:00:00.000Z", - links: { - html: { - href: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", - }, - }, - source: { - branch: { name: "feature/source-control" }, - repository: { - full_name: "octocat/t3code", - workspace: { slug: "octocat" }, - }, - }, - destination: { - branch: { name: "main" }, - repository: { - full_name: "pingdotgg/t3code", - workspace: { slug: "pingdotgg" }, - }, - }, - }), - ), - ), - ); - - const bb = yield* BitbucketCli.BitbucketCli; - const result = yield* bb.getPullRequest({ - cwd: "/repo", - reference: "#42", - }); - - assert.deepStrictEqual(result, { - number: 42, - title: "Add Bitbucket provider", - url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", - baseRefName: "main", - headRefName: "feature/source-control", - state: "open", - updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/t3code", - headRepositoryOwnerLogin: "octocat", - }); - assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], { - operation: "BitbucketCli.execute", - command: "bb", - args: ["pr", "view", "42", "--json"], - cwd: "/repo", - timeoutMs: 30_000, - }); - }).pipe(Effect.provide(layer)), - ); - - it.effect("lists pull requests with Bitbucket state and source branch arguments", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - JSON.stringify({ - values: [ - { - id: 7, - title: "Merged work", - state: "MERGED", - links: { - html: { - href: "https://bitbucket.org/pingdotgg/t3code/pull-requests/7", - }, - }, - source: { - branch: { name: "feature/merged" }, - repository: { full_name: "pingdotgg/t3code" }, - }, - destination: { - branch: { name: "main" }, - repository: { full_name: "pingdotgg/t3code" }, - }, - }, - ], - }), - ), - ), - ); - - const bb = yield* BitbucketCli.BitbucketCli; - const result = yield* bb.listPullRequests({ - cwd: "/repo", - headSelector: "origin:feature/merged", - state: "merged", - limit: 10, - }); - - assert.strictEqual(result[0]?.state, "merged"); - assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], { - operation: "BitbucketCli.execute", - command: "bb", - args: [ - "pr", - "list", - "--head", - "feature/merged", - "--state", - "merged", - "--limit", - "10", - "--json", - ], - cwd: "/repo", - timeoutMs: 30_000, - }); - }).pipe(Effect.provide(layer)), - ); - - it.effect("reads repository clone URLs and default branch", () => - Effect.gen(function* () { - const repositoryJson = JSON.stringify({ - full_name: "pingdotgg/t3code", - links: { - html: { href: "https://bitbucket.org/pingdotgg/t3code" }, - clone: [ - { name: "https", href: "https://bitbucket.org/pingdotgg/t3code.git" }, - { name: "ssh", href: "git@bitbucket.org:pingdotgg/t3code.git" }, - ], - }, - mainbranch: { name: "main" }, - }); - mockRun.mockReturnValueOnce(Effect.succeed(processOutput(repositoryJson))); - mockRun.mockReturnValueOnce(Effect.succeed(processOutput(repositoryJson))); - - const bb = yield* BitbucketCli.BitbucketCli; - const cloneUrls = yield* bb.getRepositoryCloneUrls({ - cwd: "/repo", - repository: "pingdotgg/t3code", - }); - const defaultBranch = yield* bb.getDefaultBranch({ cwd: "/repo" }); - - assert.deepStrictEqual(cloneUrls, { - nameWithOwner: "pingdotgg/t3code", - url: "https://bitbucket.org/pingdotgg/t3code.git", - sshUrl: "git@bitbucket.org:pingdotgg/t3code.git", - }); - assert.strictEqual(defaultBranch, "main"); - }).pipe(Effect.provide(layer)), - ); - - it.effect("creates pull requests using provider-neutral branch names", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); - - const bb = yield* BitbucketCli.BitbucketCli; - yield* bb.createPullRequest({ - cwd: "/repo", - baseBranch: "main", - headSelector: "owner:feature/provider", - title: "Provider PR", - bodyFile: "/tmp/body.md", - }); - - assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], { - operation: "BitbucketCli.execute", - command: "bb", - args: [ - "pr", - "create", - "--destination", - "main", - "--source", - "feature/provider", - "--title", - "Provider PR", - "--body-file", - "/tmp/body.md", - ], - cwd: "/repo", - timeoutMs: 30_000, - }); - }).pipe(Effect.provide(layer)), - ); - - it.effect("passes --force when checking out pull requests with force enabled", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); - - const bb = yield* BitbucketCli.BitbucketCli; - yield* bb.checkoutPullRequest({ - cwd: "/repo", - reference: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", - force: true, - }); - - assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], { - operation: "BitbucketCli.execute", - command: "bb", - args: ["pr", "checkout", "42", "--force"], - cwd: "/repo", - timeoutMs: 30_000, - }); - }).pipe(Effect.provide(layer)), - ); -}); diff --git a/apps/server/src/sourceControl/BitbucketCli.ts b/apps/server/src/sourceControl/BitbucketCli.ts deleted file mode 100644 index 459e77d29d..0000000000 --- a/apps/server/src/sourceControl/BitbucketCli.ts +++ /dev/null @@ -1,370 +0,0 @@ -import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; -import { TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts"; - -import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; -import { - decodeBitbucketPullRequestJson, - decodeBitbucketPullRequestListJson, - formatBitbucketJsonDecodeError, - type NormalizedBitbucketPullRequestRecord, -} from "./bitbucketPullRequests.ts"; -import type { SourceControlRefSelector } from "./SourceControlProvider.ts"; - -const DEFAULT_TIMEOUT_MS = 30_000; - -export class BitbucketCliError extends Schema.TaggedErrorClass()( - "BitbucketCliError", - { - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect), - }, -) { - override get message(): string { - return `Bitbucket CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export interface BitbucketRepositoryCloneUrls { - readonly nameWithOwner: string; - readonly url: string; - readonly sshUrl: string; -} - -export interface BitbucketCliShape { - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - }) => Effect.Effect; - - readonly listPullRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly source?: SourceControlRefSelector; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit?: number; - }) => Effect.Effect, BitbucketCliError>; - - readonly getPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - }) => Effect.Effect; - - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly source?: SourceControlRefSelector; - readonly target?: SourceControlRefSelector; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; - - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; -} - -export class BitbucketCli extends Context.Service()( - "t3/source-control/BitbucketCli", -) {} - -function errorText(error: VcsError | unknown): string { - if (typeof error === "object" && error !== null) { - const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; - const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; - const message = "message" in error && typeof error.message === "string" ? error.message : ""; - return [tag, detail, message].filter(Boolean).join("\n"); - } - - return String(error); -} - -function normalizeBitbucketCliError( - operation: "execute", - error: VcsError | unknown, -): BitbucketCliError { - const text = errorText(error); - const lower = text.toLowerCase(); - - if (lower.includes("command not found: bb") || lower.includes("enoent")) { - return new BitbucketCliError({ - operation, - detail: - "Bitbucket CLI (`bb`) is required but not available on PATH. Install a gh-style Bitbucket CLI and retry.", - cause: error, - }); - } - - if ( - lower.includes("bb auth login") || - lower.includes("not logged in") || - lower.includes("authentication failed") || - lower.includes("unauthorized") || - lower.includes("forbidden") - ) { - return new BitbucketCliError({ - operation, - detail: "Bitbucket CLI is not authenticated. Run `bb auth login` and retry.", - cause: error, - }); - } - - if (lower.includes("pull request") && lower.includes("not found")) { - return new BitbucketCliError({ - operation, - detail: "Pull request not found. Check the PR number or URL and try again.", - cause: error, - }); - } - - return new BitbucketCliError({ - operation, - detail: text, - cause: error, - }); -} - -function normalizeChangeRequestId(reference: string): string { - const trimmed = reference.trim().replace(/^#/, ""); - const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec( - trimmed, - ); - return urlMatch?.[1] ?? trimmed; -} - -function normalizeSourceBranch(headSelector: string): string { - const trimmed = headSelector.trim(); - const ownerSelector = /^([^:/\s]+):(.+)$/u.exec(trimmed); - return ownerSelector?.[2]?.trim() ?? trimmed; -} - -function sourceBranch(input: { - readonly headSelector: string; - readonly source?: SourceControlRefSelector; -}): string { - return input.source?.refName ?? normalizeSourceBranch(input.headSelector); -} - -function toBitbucketState(state: "open" | "closed" | "merged" | "all"): string { - switch (state) { - case "open": - return "open"; - case "closed": - return "declined"; - case "merged": - return "merged"; - case "all": - return "all"; - } -} - -const RawBitbucketRepositorySchema = Schema.Struct({ - full_name: TrimmedNonEmptyString, - links: Schema.Struct({ - html: Schema.optional( - Schema.Struct({ - href: TrimmedNonEmptyString, - }), - ), - clone: Schema.optional( - Schema.Array( - Schema.Struct({ - name: TrimmedNonEmptyString, - href: TrimmedNonEmptyString, - }), - ), - ), - }), - mainbranch: Schema.optional( - Schema.NullOr( - Schema.Struct({ - name: TrimmedNonEmptyString, - }), - ), - ), -}); - -function normalizeRepositoryCloneUrls( - raw: Schema.Schema.Type, -): BitbucketRepositoryCloneUrls { - const httpClone = - raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ?? - raw.links.html?.href; - const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href; - - return { - nameWithOwner: raw.full_name, - url: httpClone ?? raw.links.html?.href ?? raw.full_name, - sshUrl: sshClone ?? httpClone ?? raw.full_name, - }; -} - -function decodeBitbucketJson( - raw: string, - schema: S, - operation: "getRepositoryCloneUrls" | "getDefaultBranch", - invalidDetail: string, -): Effect.Effect { - return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( - Effect.mapError( - (error) => - new BitbucketCliError({ - operation, - detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, - cause: error, - }), - ), - ); -} - -export const make = Effect.fn("makeBitbucketCli")(function* () { - const process = yield* VcsProcess; - - const execute: BitbucketCliShape["execute"] = (input) => - process - .run({ - operation: "BitbucketCli.execute", - command: "bb", - args: input.args, - cwd: input.cwd, - timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, - }) - .pipe(Effect.mapError((error) => normalizeBitbucketCliError("execute", error))); - - return BitbucketCli.of({ - execute, - listPullRequests: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - "--head", - sourceBranch(input), - "--state", - toBitbucketState(input.state), - "--limit", - String(input.limit ?? 20), - "--json", - ], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - raw.length === 0 - ? Effect.succeed([]) - : Effect.sync(() => decodeBitbucketPullRequestListJson(raw)).pipe( - Effect.flatMap((decoded) => { - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new BitbucketCliError({ - operation: "listPullRequests", - detail: `Bitbucket CLI returned invalid PR list JSON: ${formatBitbucketJsonDecodeError(decoded.failure)}`, - cause: decoded.failure, - }), - ); - } - - return Effect.succeed(decoded.success); - }), - ), - ), - ), - getPullRequest: (input) => - execute({ - cwd: input.cwd, - args: ["pr", "view", normalizeChangeRequestId(input.reference), "--json"], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - Effect.sync(() => decodeBitbucketPullRequestJson(raw)).pipe( - Effect.flatMap((decoded) => { - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new BitbucketCliError({ - operation: "getPullRequest", - detail: `Bitbucket CLI returned invalid pull request JSON: ${formatBitbucketJsonDecodeError(decoded.failure)}`, - cause: decoded.failure, - }), - ); - } - - return Effect.succeed(decoded.success); - }), - ), - ), - ), - getRepositoryCloneUrls: (input) => - execute({ - cwd: input.cwd, - args: ["repo", "view", input.repository, "--json"], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - decodeBitbucketJson( - raw, - RawBitbucketRepositorySchema, - "getRepositoryCloneUrls", - "Bitbucket CLI returned invalid repository JSON.", - ), - ), - Effect.map(normalizeRepositoryCloneUrls), - ), - createPullRequest: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "create", - "--destination", - input.target?.refName ?? input.baseBranch, - "--source", - sourceBranch(input), - "--title", - input.title, - "--body-file", - input.bodyFile, - ], - }).pipe(Effect.asVoid), - getDefaultBranch: (input) => - execute({ - cwd: input.cwd, - args: ["repo", "view", "--json"], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - decodeBitbucketJson( - raw, - RawBitbucketRepositorySchema, - "getDefaultBranch", - "Bitbucket CLI returned invalid repository JSON.", - ), - ), - Effect.map((repository) => repository.mainbranch?.name ?? null), - ), - checkoutPullRequest: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "checkout", - normalizeChangeRequestId(input.reference), - ...(input.force ? ["--force"] : []), - ], - }).pipe(Effect.asVoid), - }); -}); - -export const layer = Layer.effect(BitbucketCli, make()); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index 25d314bab3..80cc29383b 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -1,12 +1,12 @@ import { assert, it } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; -import { BitbucketCli, type BitbucketCliShape } from "./BitbucketCli.ts"; +import { BitbucketApi, type BitbucketApiShape } from "./BitbucketApi.ts"; import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; -function makeProvider(bitbucket: Partial) { +function makeProvider(bitbucket: Partial) { return BitbucketSourceControlProvider.make().pipe( - Effect.provide(Layer.mock(BitbucketCli)(bitbucket)), + Effect.provide(Layer.mock(BitbucketApi)(bitbucket)), ); } @@ -51,7 +51,7 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", ( it.effect("lists Bitbucket PRs through provider-neutral input names", () => Effect.gen(function* () { - let listInput: Parameters[0] | null = null; + let listInput: Parameters[0] | null = null; const provider = yield* makeProvider({ listPullRequests: (input) => { listInput = input; @@ -77,7 +77,7 @@ it.effect("lists Bitbucket PRs through provider-neutral input names", () => it.effect("creates Bitbucket PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = null; + let createInput: Parameters[0] | null = null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; @@ -107,7 +107,7 @@ it.effect("creates Bitbucket PRs through provider-neutral input names", () => }), ); -it.effect("uses Bitbucket CLI repository detection for default branch lookup", () => +it.effect("uses Bitbucket API repository detection for default branch lookup", () => Effect.gen(function* () { let cwdInput: string | null = null; const provider = yield* makeProvider({ diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index cba5630f42..c72b9b2579 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -1,11 +1,11 @@ import { Effect, Layer, Option } from "effect"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; -import { BitbucketCli, type BitbucketCliError } from "./BitbucketCli.ts"; +import { BitbucketApi, type BitbucketApiError } from "./BitbucketApi.ts"; import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts"; import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts"; -function providerError(operation: string, cause: BitbucketCliError): SourceControlProviderError { +function providerError(operation: string, cause: BitbucketApiError): SourceControlProviderError { return new SourceControlProviderError({ provider: "bitbucket", operation, @@ -51,7 +51,7 @@ function sourceFromInput(input: { } export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () { - const bitbucket = yield* BitbucketCli; + const bitbucket = yield* BitbucketApi; return SourceControlProvider.of({ kind: "bitbucket", @@ -60,6 +60,7 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () return bitbucket .listPullRequests({ cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), headSelector: input.headSelector, ...(source ? { source } : {}), state: input.state, @@ -80,6 +81,7 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () return bitbucket .createPullRequest({ cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), baseBranch: input.baseRefName, headSelector: input.headSelector, ...(source ? { source } : {}), @@ -95,12 +97,16 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), getDefaultBranch: (input) => bitbucket - .getDefaultBranch({ cwd: input.cwd }) + .getDefaultBranch({ + cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), + }) .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), checkoutChangeRequest: (input) => bitbucket .checkoutPullRequest({ cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), reference: input.reference, ...(input.force !== undefined ? { force: input.force } : {}), }) diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 3a2ddf311d..54dec6f554 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -6,6 +6,7 @@ import { VcsProcessSpawnError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { BitbucketApi } from "./BitbucketApi.ts"; import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts"; const processOutput = ( @@ -58,6 +59,18 @@ Logged in to github.com account juliusmarminge (keyring) }, }), ), + Layer.provide( + Layer.mock(BitbucketApi)({ + probeAuth: Effect.succeed({ + status: "unauthenticated", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.some( + "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + ), + }), + }), + ), Layer.provideMerge(NodeServices.layer), ); @@ -109,8 +122,8 @@ Logged in to github.com account juliusmarminge (keyring) { kind: "bitbucket", implemented: true, - status: "missing", - auth: "unknown", + status: "available", + auth: "unauthenticated", account: Option.none(), }, ], @@ -154,13 +167,6 @@ Logged in to gitlab.com as gitlab-user ) { return Effect.succeed(processOutput("azure-user@example.com\n")); } - if (input.command === "bb" && input.args.join(" ") === "auth status") { - return Effect.succeed( - processOutput(`bitbucket.org -Logged in as bitbucket-user -`), - ); - } return Effect.fail( new VcsProcessSpawnError({ operation: input.operation, @@ -172,6 +178,16 @@ Logged in as bitbucket-user }, }), ), + Layer.provide( + Layer.mock(BitbucketApi)({ + probeAuth: Effect.succeed({ + status: "authenticated", + account: Option.some("bitbucket-user"), + host: Option.some("bitbucket.org"), + detail: Option.none(), + }), + }), + ), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index 64e5ef8722..4833bfefbd 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -10,6 +10,7 @@ import { Context, Effect, Layer, Option } from "effect"; import { ServerConfig } from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { BitbucketApi } from "./BitbucketApi.ts"; interface DiscoveryProbe { readonly label: string; @@ -25,12 +26,21 @@ type VcsProbe = DiscoveryProbe & { readonly versionArgs: ReadonlyArray; }; -type ProviderProbe = DiscoveryProbe & { +type CliProviderProbe = DiscoveryProbe & { + readonly type: "cli"; readonly kind: SourceControlProviderKind; readonly authArgs?: ReadonlyArray; readonly parseAuth?: (input: AuthProbeInput) => SourceControlProviderAuth; }; +type ApiProviderProbe = Omit & { + readonly type: "api"; + readonly kind: SourceControlProviderKind; + readonly executable: string; +}; + +type ProviderProbe = CliProviderProbe | ApiProviderProbe; + interface AuthProbeInput { readonly stdout: string; readonly stderr: string; @@ -69,6 +79,7 @@ const VCS_PROBES: ReadonlyArray = [ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray = [ { + type: "cli", kind: "github", label: "GitHub", executable: "gh", @@ -79,6 +90,7 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray = [ installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.", }, { + type: "cli", kind: "gitlab", label: "GitLab", executable: "glab", @@ -90,6 +102,7 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray = [ "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.", }, { + type: "cli", kind: "azure-devops", label: "Azure DevOps", executable: "az", @@ -101,14 +114,13 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray = [ "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.", }, { + type: "api", kind: "bitbucket", label: "Bitbucket", - executable: "bb", - versionArgs: ["--version"], - authArgs: ["auth", "status"], - parseAuth: parseBitbucketAuth, + executable: "Bitbucket REST API", implemented: true, - installHint: "Install a Bitbucket CLI (`bb`) and authenticate it for your Bitbucket workspace.", + installHint: + "Create a Bitbucket API token with pull request/repository scopes, then set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN.", }, ]; @@ -268,37 +280,6 @@ function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth { }); } -function parseBitbucketAuth(input: AuthProbeInput): SourceControlProviderAuth { - const output = combinedAuthOutput(input); - const account = matchFirst(output, [ - /Logged in to .* as\s+([^\s(]+)/iu, - /Logged in as\s+([^\s(]+)/iu, - /account:\s*([^\s(]+)/iu, - /user:\s*([^\s(]+)/iu, - /username:\s*([^\s(]+)/iu, - ]); - const host = parseCliHost(output) ?? "bitbucket.org"; - - if (input.exitCode !== 0) { - return providerAuth({ - status: "unauthenticated", - host, - detail: - firstSafeAuthLine(output) ?? "Authenticate the Bitbucket CLI before enabling Bitbucket.", - }); - } - - if (account) { - return providerAuth({ status: "authenticated", account, host }); - } - - return providerAuth({ - status: "unknown", - host, - detail: firstSafeAuthLine(output) ?? "Bitbucket CLI auth status could not be parsed.", - }); -} - export interface SourceControlDiscoveryShape { readonly discover: Effect.Effect; } @@ -313,6 +294,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const config = yield* ServerConfig; const process = yield* VcsProcess.VcsProcess; + const bitbucketApi = yield* BitbucketApi; const probe = ( input: DiscoveryProbe & { readonly kind: Kind }, @@ -374,54 +356,60 @@ export const layer = Layer.effect( }; const probeProvider = (input: ProviderProbe) => - probe(input).pipe( - Effect.flatMap((item) => { - const executable = input.executable; - const authArgs = input.authArgs; - const parseAuth = input.parseAuth; - - if (!executable || !authArgs || !parseAuth) { - return Effect.succeed({ - ...item, - auth: unknownAuth(input.installHint), - } satisfies SourceControlProviderDiscoveryItem); - } - - if (item.status !== "available") { - return Effect.succeed({ - ...item, - auth: unknownAuth("CLI is not installed."), - } satisfies SourceControlProviderDiscoveryItem); - } - - return process - .run({ - operation: "source-control.discovery.auth", - command: executable, - args: authArgs, - cwd: config.cwd, - allowNonZeroExit: true, - timeoutMs: 5_000, - maxOutputBytes: 8_000, - truncateOutputAtMaxBytes: true, - }) - .pipe( - Effect.map( - (result) => - ({ - ...item, - auth: parseAuth(result), - }) satisfies SourceControlProviderDiscoveryItem, - ), - Effect.catch((cause) => - Effect.succeed({ + input.type === "api" + ? bitbucketApi.probeAuth.pipe( + Effect.map( + (auth) => + ({ + kind: input.kind, + label: input.label, + executable: input.executable, + implemented: input.implemented, + status: "available" as const, + version: Option.none(), + installHint: input.installHint, + detail: Option.none(), + auth, + }) satisfies SourceControlProviderDiscoveryItem, + ), + ) + : probe(input).pipe( + Effect.flatMap((item) => { + if (item.status !== "available") { + return Effect.succeed({ ...item, - auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), - } satisfies SourceControlProviderDiscoveryItem), - ), - ); - }), - ); + auth: unknownAuth("CLI is not installed."), + } satisfies SourceControlProviderDiscoveryItem); + } + + return process + .run({ + operation: "source-control.discovery.auth", + command: input.executable, + args: input.authArgs, + cwd: config.cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + truncateOutputAtMaxBytes: true, + }) + .pipe( + Effect.map( + (result) => + ({ + ...item, + auth: input.parseAuth(result), + }) satisfies SourceControlProviderDiscoveryItem, + ), + Effect.catch((cause) => + Effect.succeed({ + ...item, + auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), + } satisfies SourceControlProviderDiscoveryItem), + ), + ); + }), + ); return SourceControlDiscovery.of({ discover: Effect.all({ diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index bb7008f247..529ffaf289 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -1,7 +1,7 @@ import { assert, it } from "@effect/vitest"; import { DateTime, Effect, Layer, Option } from "effect"; -import { BitbucketCli } from "./BitbucketCli.ts"; +import { BitbucketApi } from "./BitbucketApi.ts"; import { GitHubCli } from "./GitHubCli.ts"; import { GitLabCli } from "./GitLabCli.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; @@ -55,7 +55,7 @@ function makeRegistry(input: { Effect.provide( Layer.mergeAll( registryLayer, - Layer.mock(BitbucketCli)({}), + Layer.mock(BitbucketApi)({}), Layer.mock(GitHubCli)({}), Layer.mock(GitLabCli)({}), ), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index ad9536ecd0..36e63b63ce 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -101,6 +101,49 @@ function selectProviderContext( ); } +function bindProviderContext( + provider: SourceControlProviderShape, + context: SourceControlProviderContext | null, +): SourceControlProviderShape { + if (context === null) { + return provider; + } + + return SourceControlProvider.of({ + kind: provider.kind, + listChangeRequests: (input) => + provider.listChangeRequests({ + ...input, + context: input.context ?? context, + }), + getChangeRequest: (input) => + provider.getChangeRequest({ + ...input, + context: input.context ?? context, + }), + createChangeRequest: (input) => + provider.createChangeRequest({ + ...input, + context: input.context ?? context, + }), + getRepositoryCloneUrls: (input) => + provider.getRepositoryCloneUrls({ + ...input, + context: input.context ?? context, + }), + getDefaultBranch: (input) => + provider.getDefaultBranch({ + ...input, + context: input.context ?? context, + }), + checkoutChangeRequest: (input) => + provider.checkoutChangeRequest({ + ...input, + context: input.context ?? context, + }), + }); +} + export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( function* (registrations: ReadonlyArray) { const vcsRegistry = yield* VcsDriverRegistry; @@ -137,8 +180,9 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit Cache.get(providerContextCache, input.cwd).pipe( Effect.map((context) => { const kind = context?.provider.kind ?? "unknown"; + const provider = providers.get(kind) ?? unsupportedProvider(kind); return { - provider: providers.get(kind) ?? unsupportedProvider(kind), + provider: bindProviderContext(provider, context), context, } satisfies SourceControlProviderHandle; }), diff --git a/apps/server/src/sourceControl/bitbucketPullRequests.ts b/apps/server/src/sourceControl/bitbucketPullRequests.ts index c2b5518104..18326e03f1 100644 --- a/apps/server/src/sourceControl/bitbucketPullRequests.ts +++ b/apps/server/src/sourceControl/bitbucketPullRequests.ts @@ -15,7 +15,7 @@ export interface NormalizedBitbucketPullRequestRecord { readonly headRepositoryOwnerLogin?: string | null; } -const BitbucketRepositoryRefSchema = Schema.Struct({ +export const BitbucketRepositoryRefSchema = Schema.Struct({ full_name: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), workspace: Schema.optional( Schema.NullOr( @@ -26,14 +26,14 @@ const BitbucketRepositoryRefSchema = Schema.Struct({ ), }); -const BitbucketPullRequestBranchSchema = Schema.Struct({ +export const BitbucketPullRequestBranchSchema = Schema.Struct({ repository: Schema.optional(Schema.NullOr(BitbucketRepositoryRefSchema)), branch: Schema.Struct({ name: TrimmedNonEmptyString, }), }); -const BitbucketPullRequestSchema = Schema.Struct({ +export const BitbucketPullRequestSchema = Schema.Struct({ id: PositiveInt, title: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), @@ -47,6 +47,11 @@ const BitbucketPullRequestSchema = Schema.Struct({ destination: BitbucketPullRequestBranchSchema, }); +export const BitbucketPullRequestListSchema = Schema.Struct({ + values: Schema.Array(BitbucketPullRequestSchema), + next: Schema.optional(TrimmedNonEmptyString), +}); + function trimOptionalString(value: string | null | undefined): string | null { const trimmed = value?.trim() ?? ""; return trimmed.length > 0 ? trimmed : null; @@ -72,7 +77,7 @@ function normalizeBitbucketPullRequestState(state: string | null | undefined) { } } -function normalizeBitbucketPullRequestRecord( +export function normalizeBitbucketPullRequestRecord( raw: Schema.Schema.Type, ): NormalizedBitbucketPullRequestRecord { const headRepositoryNameWithOwner = trimOptionalString(raw.source.repository?.full_name); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d7015a54ca..20291bda0f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -56,6 +56,7 @@ import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; import { ServerAuth } from "./auth/Services/ServerAuth.ts"; import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; +import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import { BootstrapCredentialService, @@ -1130,7 +1131,10 @@ export const websocketRpcRouteLayer = Layer.unwrap( makeWsRpcLayer(session.sessionId).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide( - SourceControlDiscoveryLayer.layer.pipe(Layer.provide(VcsProcess.layer)), + SourceControlDiscoveryLayer.layer.pipe( + Layer.provideMerge(BitbucketApi.layer), + Layer.provide(VcsProcess.layer), + ), ), ), ), diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 59257a3de7..72da858092 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -61,7 +61,7 @@ const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { longName: "pull request", pluralLongName: "pull requests", providerLongName: "Bitbucket pull request", - checkoutCommandExample: "bb pr checkout 123", + checkoutCommandExample: "https://bitbucket.org/workspace/repo/pull-requests/42", urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42", }; From 3eeb1e355ec5188a2c8c93984cbba7e4c5dd2bde Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 20:02:04 -0700 Subject: [PATCH 04/24] Address Bitbucket provider review feedback --- .../src/sourceControl/BitbucketApi.test.ts | 30 +++++++++++- apps/server/src/sourceControl/BitbucketApi.ts | 39 +++++++++------ .../BitbucketSourceControlProvider.ts | 20 ++------ .../GitLabSourceControlProvider.ts | 20 ++------ .../sourceControl/SourceControlProvider.ts | 16 ++++++ .../sourceControl/bitbucketPullRequests.ts | 49 +------------------ packages/shared/src/sourceControl.ts | 2 +- 7 files changed, 78 insertions(+), 98 deletions(-) diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index a989c9232b..27e0529e41 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -185,12 +185,40 @@ it.effect("lists pull requests with Bitbucket state and source branch query para assert.deepStrictEqual(request?.urlParams.params, [ ["pagelen", "10"], ["sort", "-updated_on"], - ["q", 'source.branch.name = "feature/merged"'], + ["q", 'source.branch.name = "feature/merged" AND state = "MERGED"'], ["state", "MERGED"], ]); }).pipe(Effect.provide(layer)); }); +it.effect("expands all-state pull request listing instead of relying on Bitbucket defaults", () => { + const { execute, layer } = makeLayer({ + response: () => + Response.json({ + values: [], + }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + yield* bitbucket.listPullRequests({ + cwd: "/repo", + headSelector: "feature/all", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [ + ["pagelen", "10"], + ["sort", "-updated_on"], + [ + "q", + 'source.branch.name = "feature/all" AND (state = "OPEN" OR state = "MERGED" OR state = "DECLINED" OR state = "SUPERSEDED")', + ], + ]); + }).pipe(Effect.provide(layer)); +}); + it.effect("reads repository clone URLs and default branch", () => { const { layer } = makeLayer({ response: () => Response.json(repositoryJson), diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index fe9aad4a4f..641187a1b3 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -17,6 +17,7 @@ import type { SourceControlProviderContext, SourceControlRefSelector, } from "./SourceControlProvider.ts"; +import { parseSourceControlOwnerRef } from "./SourceControlProvider.ts"; import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; @@ -141,9 +142,7 @@ function normalizeChangeRequestId(reference: string): string { } function normalizeSourceBranch(headSelector: string): string { - const trimmed = headSelector.trim(); - const ownerSelector = /^([^:/\s]+):(.+)$/u.exec(trimmed); - return ownerSelector?.[2]?.trim() ?? trimmed; + return parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim(); } function sourceBranch(input: { @@ -158,23 +157,32 @@ function sourceWorkspace(input: { readonly source?: SourceControlRefSelector; }): string | undefined { if (input.source?.owner) return input.source.owner; - const ownerSelector = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); - return ownerSelector?.[1]?.trim(); + return parseSourceControlOwnerRef(input.headSelector)?.owner; } -function toBitbucketState(state: "open" | "closed" | "merged" | "all"): string | null { +function toBitbucketStates(state: "open" | "closed" | "merged" | "all"): ReadonlyArray { switch (state) { case "open": - return "OPEN"; + return ["OPEN"]; case "closed": - return "DECLINED"; + return ["DECLINED", "SUPERSEDED"]; case "merged": - return "MERGED"; + return ["MERGED"]; case "all": - return null; + return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]; } } +function bitbucketQueryString(filters: ReadonlyArray): string { + return filters.join(" AND "); +} + +function bitbucketStateFilter(states: ReadonlyArray): string { + return states.length === 1 + ? `state = "${states[0]}"` + : `(${states.map((state) => `state = "${state}"`).join(" OR ")})`; +} + function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator | null { const normalized = value.trim().replace(/\.git$/u, ""); const parts = normalized.split("/").filter((part) => part.length > 0); @@ -405,14 +413,17 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { listPullRequests: (input) => resolveRepository(input).pipe( Effect.flatMap((repository) => { - const state = toBitbucketState(input.state); + const states = toBitbucketStates(input.state); const query: Record = { pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))), sort: "-updated_on", - q: `source.branch.name = "${sourceBranch(input).replaceAll('"', '\\"')}"`, + q: bitbucketQueryString([ + `source.branch.name = "${sourceBranch(input).replaceAll('"', '\\"')}"`, + bitbucketStateFilter(states), + ]), }; - if (state !== null) { - query.state = state; + if (input.state !== "all" && states.length === 1) { + query.state = states[0] ?? "OPEN"; } return executeJson( diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index c72b9b2579..ec2462ddea 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -2,7 +2,7 @@ import { Effect, Layer, Option } from "effect"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; import { BitbucketApi, type BitbucketApiError } from "./BitbucketApi.ts"; -import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts"; +import { SourceControlProvider, sourceControlRefFromInput } from "./SourceControlProvider.ts"; import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts"; function providerError(operation: string, cause: BitbucketApiError): SourceControlProviderError { @@ -36,27 +36,13 @@ function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeR }; } -function sourceFromInput(input: { - readonly headSelector: string; - readonly source?: SourceControlRefSelector; -}): SourceControlRefSelector | undefined { - if (input.source) { - return input.source; - } - - const match = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); - const owner = match?.[1]?.trim(); - const refName = match?.[2]?.trim(); - return owner && refName ? { owner, refName } : undefined; -} - export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () { const bitbucket = yield* BitbucketApi; return SourceControlProvider.of({ kind: "bitbucket", listChangeRequests: (input) => { - const source = sourceFromInput(input); + const source = sourceControlRefFromInput(input); return bitbucket .listPullRequests({ cwd: input.cwd, @@ -77,7 +63,7 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () Effect.mapError((error) => providerError("getChangeRequest", error)), ), createChangeRequest: (input) => { - const source = sourceFromInput(input); + const source = sourceControlRefFromInput(input); return bitbucket .createPullRequest({ cwd: input.cwd, diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 41c5598e67..c65bbf207b 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -2,7 +2,7 @@ import { Effect, Layer, Option } from "effect"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; import { GitLabCli, type GitLabCliError, type GitLabMergeRequestSummary } from "./GitLabCli.ts"; -import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts"; +import { SourceControlProvider, sourceControlRefFromInput } from "./SourceControlProvider.ts"; function providerError(operation: string, cause: GitLabCliError): SourceControlProviderError { return new SourceControlProviderError({ @@ -35,27 +35,13 @@ function toChangeRequest(summary: GitLabMergeRequestSummary): ChangeRequest { }; } -function sourceFromInput(input: { - readonly headSelector: string; - readonly source?: SourceControlRefSelector; -}): SourceControlRefSelector | undefined { - if (input.source) { - return input.source; - } - - const match = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); - const owner = match?.[1]?.trim(); - const refName = match?.[2]?.trim(); - return owner && refName ? { owner, refName } : undefined; -} - export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { const gitlab = yield* GitLabCli; return SourceControlProvider.of({ kind: "gitlab", listChangeRequests: (input) => { - const source = sourceFromInput(input); + const source = sourceControlRefFromInput(input); return gitlab .listMergeRequests({ cwd: input.cwd, @@ -75,7 +61,7 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { Effect.mapError((error) => providerError("getChangeRequest", error)), ), createChangeRequest: (input) => { - const source = sourceFromInput(input); + const source = sourceControlRefFromInput(input); return gitlab .createMergeRequest({ cwd: input.cwd, diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index ef376eedb4..8f56121097 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -21,6 +21,22 @@ export interface SourceControlRefSelector { readonly repository?: string; } +export function parseSourceControlOwnerRef( + headSelector: string, +): SourceControlRefSelector | undefined { + const match = /^([^:/\s]+):(.+)$/u.exec(headSelector.trim()); + const owner = match?.[1]?.trim(); + const refName = match?.[2]?.trim(); + return owner && refName ? { owner, refName } : undefined; +} + +export function sourceControlRefFromInput(input: { + readonly headSelector: string; + readonly source?: SourceControlRefSelector; +}): SourceControlRefSelector | undefined { + return input.source ?? parseSourceControlOwnerRef(input.headSelector); +} + export interface SourceControlProviderShape { readonly kind: SourceControlProviderKind; readonly listChangeRequests: (input: { diff --git a/apps/server/src/sourceControl/bitbucketPullRequests.ts b/apps/server/src/sourceControl/bitbucketPullRequests.ts index 18326e03f1..5313eaba97 100644 --- a/apps/server/src/sourceControl/bitbucketPullRequests.ts +++ b/apps/server/src/sourceControl/bitbucketPullRequests.ts @@ -1,6 +1,5 @@ -import { Cause, DateTime, Exit, Option, Result, Schema } from "effect"; +import { DateTime, Option, Schema } from "effect"; import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; -import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; export interface NormalizedBitbucketPullRequestRecord { readonly number: number; @@ -103,49 +102,3 @@ export function normalizeBitbucketPullRequestRecord( ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), }; } - -const decodeBitbucketPullRequestList = decodeJsonResult(Schema.Unknown); -const decodeBitbucketPullRequest = decodeJsonResult(BitbucketPullRequestSchema); -const decodeBitbucketPullRequestEntry = Schema.decodeUnknownExit(BitbucketPullRequestSchema); - -export const formatBitbucketJsonDecodeError = formatSchemaError; - -export function decodeBitbucketPullRequestListJson( - raw: string, -): Result.Result< - ReadonlyArray, - Cause.Cause -> { - const result = decodeBitbucketPullRequestList(raw); - if (Result.isFailure(result)) { - return Result.fail(result.failure); - } - - const entries: ReadonlyArray = Array.isArray(result.success) - ? result.success - : typeof result.success === "object" && - result.success !== null && - "values" in result.success && - Array.isArray(result.success.values) - ? result.success.values - : []; - const pullRequests: NormalizedBitbucketPullRequestRecord[] = []; - for (const entry of entries) { - const decodedEntry = decodeBitbucketPullRequestEntry(entry); - if (Exit.isFailure(decodedEntry)) { - continue; - } - pullRequests.push(normalizeBitbucketPullRequestRecord(decodedEntry.value)); - } - return Result.succeed(pullRequests); -} - -export function decodeBitbucketPullRequestJson( - raw: string, -): Result.Result> { - const result = decodeBitbucketPullRequest(raw); - if (Result.isSuccess(result)) { - return Result.succeed(normalizeBitbucketPullRequestRecord(result.success)); - } - return Result.fail(result.failure); -} diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 72da858092..0d7139a41a 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -61,7 +61,7 @@ const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { longName: "pull request", pluralLongName: "pull requests", providerLongName: "Bitbucket pull request", - checkoutCommandExample: "https://bitbucket.org/workspace/repo/pull-requests/42", + checkoutCommandExample: "Not available through an official Bitbucket CLI", urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42", }; From ea073be59403c19b368c98adff7002d867c98d7a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 20:44:39 -0700 Subject: [PATCH 05/24] Implement Bitbucket PR checkout --- .../src/sourceControl/BitbucketApi.test.ts | 144 ++++++++++++- apps/server/src/sourceControl/BitbucketApi.ts | 190 ++++++++++++++++-- 2 files changed, 302 insertions(+), 32 deletions(-) diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 27e0529e41..bca61366af 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -5,6 +5,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import { vi } from "vitest"; import * as BitbucketApi from "./BitbucketApi.ts"; +import { GitVcsDriver, type GitVcsDriverShape } from "../vcs/GitVcsDriver.ts"; import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; import type { VcsDriverShape } from "../vcs/VcsDriver.ts"; @@ -48,10 +49,35 @@ const repositoryJson = { function makeLayer(input: { readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; + readonly git?: Partial; }) { const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), ); + const gitMock = { + readConfigValue: vi.fn(() => + Effect.succeed("git@bitbucket.org:pingdotgg/t3code.git"), + ), + resolvePrimaryRemoteName: vi.fn(() => + Effect.succeed("origin"), + ), + ensureRemote: vi.fn(() => Effect.succeed("octocat")), + fetchRemoteBranch: vi.fn(() => Effect.void), + fetchRemoteTrackingBranch: vi.fn( + () => Effect.void, + ), + setBranchUpstream: vi.fn(() => Effect.void), + switchRef: vi.fn((request) => + Effect.succeed({ refName: request.refName }), + ), + listLocalBranchNames: vi.fn(() => + Effect.succeed([]), + ), + }; + const git = { + ...gitMock, + ...input.git, + } satisfies Partial; const driver = { listRemotes: () => @@ -98,6 +124,7 @@ function makeLayer(input: { }), }), ), + Layer.provide(Layer.mock(GitVcsDriver)(git)), Layer.provide( ConfigProvider.layer( ConfigProvider.fromEnv({ @@ -112,7 +139,7 @@ function makeLayer(input: { Layer.provideMerge(NodeServices.layer), ); - return { execute, layer }; + return { execute, git: gitMock, layer }; } it.effect("parses pull request responses from the Bitbucket REST API", () => { @@ -301,20 +328,115 @@ it.effect("reports auth status through the Bitbucket REST /user endpoint", () => }).pipe(Effect.provide(layer)); }); -it.effect("does not pretend pull request checkout is supported by a non-existent CLI", () => { - const { layer } = makeLayer({ - response: () => Response.json({}), +it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { + const { git, layer } = makeLayer({ + response: () => + Response.json({ + ...bitbucketPullRequest, + source: { + branch: { name: "feature/source-control" }, + repository: { + full_name: "pingdotgg/t3code", + workspace: { slug: "pingdotgg" }, + }, + }, + }), }); return Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; - const result = yield* Effect.exit( - bitbucket.checkoutPullRequest({ - cwd: "/repo", - reference: "42", - }), - ); + yield* bitbucket.checkoutPullRequest({ + cwd: "/repo", + context: { + provider: { + kind: "bitbucket", + name: "Bitbucket", + baseUrl: "https://bitbucket.org", + }, + remoteName: "origin", + remoteUrl: "git@bitbucket.org:pingdotgg/t3code.git", + }, + reference: "42", + force: true, + }); - assert.strictEqual(result._tag, "Failure"); + assert.strictEqual(git.ensureRemote.mock.calls.length, 0); + assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], { + cwd: "/repo", + remoteName: "origin", + remoteBranch: "feature/source-control", + localBranch: "feature/source-control", + }); + assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], { + cwd: "/repo", + branch: "feature/source-control", + remoteName: "origin", + remoteBranch: "feature/source-control", + }); + assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], { + cwd: "/repo", + refName: "feature/source-control", + }); + }).pipe(Effect.provide(layer)); +}); + +it.effect("checks out fork pull requests through an ensured fork remote", () => { + const { git, layer } = makeLayer({ + response: (request) => { + if (request.url.endsWith("/repositories/octocat/t3code")) { + return Response.json({ + ...repositoryJson, + full_name: "octocat/t3code", + links: { + html: { href: "https://bitbucket.org/octocat/t3code" }, + clone: [ + { name: "https", href: "https://bitbucket.org/octocat/t3code.git" }, + { name: "ssh", href: "git@bitbucket.org:octocat/t3code.git" }, + ], + }, + }); + } + return Response.json({ + ...bitbucketPullRequest, + source: { + branch: { name: "main" }, + repository: { + full_name: "octocat/t3code", + workspace: { slug: "octocat" }, + }, + }, + }); + }, + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + yield* bitbucket.checkoutPullRequest({ + cwd: "/repo", + reference: "42", + force: true, + }); + + assert.deepStrictEqual(git.ensureRemote.mock.calls[0]?.[0], { + cwd: "/repo", + preferredName: "octocat", + url: "git@bitbucket.org:octocat/t3code.git", + }); + assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], { + cwd: "/repo", + remoteName: "octocat", + remoteBranch: "main", + localBranch: "t3code/pr-42/main", + }); + assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], { + cwd: "/repo", + branch: "t3code/pr-42/main", + remoteName: "octocat", + remoteBranch: "main", + }); + assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], { + cwd: "/repo", + refName: "t3code/pr-42/main", + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 641187a1b3..8a96532939 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -5,6 +5,7 @@ import { type SourceControlRepositoryCloneUrls, } from "@t3tools/contracts"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { sanitizeBranchFragment } from "@t3tools/shared/git"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import { @@ -18,6 +19,7 @@ import type { SourceControlRefSelector, } from "./SourceControlProvider.ts"; import { parseSourceControlOwnerRef } from "./SourceControlProvider.ts"; +import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; @@ -221,6 +223,43 @@ function normalizeRepositoryCloneUrls( }; } +function shouldPreferSshRemote(originRemoteUrl: string | null): boolean { + const trimmed = originRemoteUrl?.trim() ?? ""; + return trimmed.startsWith("git@") || trimmed.startsWith("ssh://"); +} + +function selectCloneUrl(input: { + readonly cloneUrls: SourceControlRepositoryCloneUrls; + readonly originRemoteUrl: string | null; +}): string { + return shouldPreferSshRemote(input.originRemoteUrl) + ? input.cloneUrls.sshUrl + : input.cloneUrls.url; +} + +function checkoutBranchName(input: { + readonly pullRequestId: number; + readonly headBranch: string; + readonly isCrossRepository: boolean; +}): string { + if (!input.isCrossRepository) { + return input.headBranch; + } + + return `t3code/pr-${input.pullRequestId}/${sanitizeBranchFragment(input.headBranch)}`; +} + +function repositoryNameWithOwner( + repository: Schema.Schema.Type["source"]["repository"], +): string | null { + const fullName = repository?.full_name?.trim() ?? ""; + return fullName.length > 0 ? fullName : null; +} + +function repositoryOwnerName(repositoryName: string): string { + return repositoryName.split("/")[0]?.trim() || "bitbucket"; +} + function authFromConfig( config: Config.Success, ): SourceControlProviderAuth { @@ -260,6 +299,10 @@ function requestError(operation: string, cause: unknown): BitbucketApiError { }); } +function isBitbucketApiError(cause: unknown): cause is BitbucketApiError { + return Schema.is(BitbucketApiError)(cause); +} + function responseError( operation: string, response: HttpClientResponse.HttpClientResponse, @@ -285,6 +328,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { const config = yield* BitbucketApiEnvConfig; const httpClient = yield* HttpClient.HttpClient; const fileSystem = yield* FileSystem.FileSystem; + const git = yield* GitVcsDriver; const vcsRegistry = yield* VcsDriverRegistry; const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; @@ -396,6 +440,69 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { ), ); + const getRawPullRequestFromRepository = ( + repository: BitbucketRepositoryLocator, + reference: string, + ) => + executeJson( + "getPullRequest", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`, + ), + ), + BitbucketPullRequestSchema, + ); + + const getRawPullRequest = (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + }) => + resolveRepository(input).pipe( + Effect.flatMap((repository) => getRawPullRequestFromRepository(repository, input.reference)), + ); + + const readConfigValueNullable = (cwd: string, key: string) => + git.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); + + const resolveCheckoutRemote = Effect.fn("BitbucketApi.resolveCheckoutRemote")(function* (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly destinationRepository: BitbucketRepositoryLocator; + readonly sourceRepositoryName: string; + readonly isCrossRepository: boolean; + }) { + if ( + input.context?.provider.kind === "bitbucket" && + !input.isCrossRepository && + parseBitbucketRemoteUrl(input.context.remoteUrl) !== null + ) { + return input.context.remoteName; + } + + if (!input.isCrossRepository) { + const remoteName = yield* git + .resolvePrimaryRemoteName(input.cwd) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (remoteName) return remoteName; + } + + const cloneUrls = yield* getRepository({ + cwd: input.cwd, + repository: input.sourceRepositoryName, + ...(input.context ? { context: input.context } : {}), + }).pipe(Effect.map(normalizeRepositoryCloneUrls)); + const originRemoteUrl = yield* readConfigValueNullable(input.cwd, "remote.origin.url"); + return yield* git.ensureRemote({ + cwd: input.cwd, + preferredName: input.isCrossRepository + ? repositoryOwnerName(input.sourceRepositoryName) + : input.destinationRepository.workspace, + url: selectCloneUrl({ cloneUrls, originRemoteUrl }), + }); + }); + return BitbucketApi.of({ probeAuth: executeJson( "probeAuth", @@ -440,20 +547,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)), ), getPullRequest: (input) => - resolveRepository(input).pipe( - Effect.flatMap((repository) => - executeJson( - "getPullRequest", - HttpClientRequest.get( - apiUrl( - `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(input.reference))}`, - ), - ), - BitbucketPullRequestSchema, - ), - ), - Effect.map(normalizeBitbucketPullRequestRecord), - ), + getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord)), getRepositoryCloneUrls: (input) => getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), createPullRequest: (input) => @@ -504,13 +598,67 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { }), getDefaultBranch: (input) => getRepository(input).pipe(Effect.map((repository) => repository.mainbranch?.name ?? null)), - checkoutPullRequest: () => - Effect.fail( - new BitbucketApiError({ - operation: "checkoutPullRequest", - detail: - "Bitbucket Cloud does not provide an official CLI checkout command. Add VCS-level checkout support for Bitbucket pull request refs before enabling this action.", - }), + checkoutPullRequest: (input) => + Effect.gen(function* () { + const destinationRepository = yield* resolveRepository(input); + const pullRequest = yield* getRawPullRequestFromRepository( + destinationRepository, + input.reference, + ); + const destinationRepositoryName = + repositoryNameWithOwner(pullRequest.destination.repository) ?? + `${destinationRepository.workspace}/${destinationRepository.repoSlug}`; + const sourceRepositoryName = + repositoryNameWithOwner(pullRequest.source.repository) ?? destinationRepositoryName; + const isCrossRepository = sourceRepositoryName !== destinationRepositoryName; + const remoteName = yield* resolveCheckoutRemote({ + cwd: input.cwd, + destinationRepository, + sourceRepositoryName, + isCrossRepository, + ...(input.context ? { context: input.context } : {}), + }); + const remoteBranch = pullRequest.source.branch.name; + const localBranch = checkoutBranchName({ + pullRequestId: pullRequest.id, + headBranch: remoteBranch, + isCrossRepository, + }); + const localBranchNames = yield* git.listLocalBranchNames(input.cwd); + const localBranchExists = localBranchNames.includes(localBranch); + + if (input.force === true || !localBranchExists) { + yield* git.fetchRemoteBranch({ + cwd: input.cwd, + remoteName, + remoteBranch, + localBranch, + }); + } else { + yield* git.fetchRemoteTrackingBranch({ + cwd: input.cwd, + remoteName, + remoteBranch, + }); + } + + yield* git.setBranchUpstream({ + cwd: input.cwd, + branch: localBranch, + remoteName, + remoteBranch, + }); + yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); + }).pipe( + Effect.mapError((cause) => + isBitbucketApiError(cause) + ? cause + : new BitbucketApiError({ + operation: "checkoutPullRequest", + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + ), ), }); }); From c72120b9492c370fa4646064fca96573eb87756d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 22:12:59 -0700 Subject: [PATCH 06/24] Document Bitbucket checkout Git coupling --- apps/server/src/sourceControl/BitbucketApi.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 8a96532939..a2bd0ab379 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -598,6 +598,12 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { }), getDefaultBranch: (input) => getRepository(input).pipe(Effect.map((repository) => repository.mainbranch?.name ?? null)), + // Bitbucket Cloud pull requests are Git-backed and Bitbucket does not provide + // an official checkout CLI. This provider-local path uses GitVcsDriver as a + // narrow escape hatch to materialize Bitbucket PR refs. Do not generalize this + // as the source-control provider model: if we support non-Git-compatible + // hosting providers or native JJ/Sapling checkout flows, move this into a + // VCS-specific change-request checkout capability. checkoutPullRequest: (input) => Effect.gen(function* () { const destinationRepository = yield* resolveRepository(input); From 7a5382e27a4f9f10338acb57f9887be4e9bdc648 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 3 May 2026 00:30:03 -0700 Subject: [PATCH 07/24] Move source control discovery into provider registry --- apps/server/src/git/GitManager.test.ts | 1 + apps/server/src/server.ts | 1 + .../AzureDevOpsSourceControlProvider.ts | 42 +++ .../BitbucketSourceControlProvider.ts | 16 + .../GitHubSourceControlProvider.ts | 48 +++ .../GitLabSourceControlProvider.ts | 50 +++ .../SourceControlDiscovery.test.ts | 189 ++++++----- .../sourceControl/SourceControlDiscovery.ts | 297 +----------------- .../SourceControlProviderDiscovery.ts | 243 ++++++++++++++ .../SourceControlProviderRegistry.test.ts | 7 + .../SourceControlProviderRegistry.ts | 36 ++- apps/server/src/ws.ts | 16 +- 12 files changed, 571 insertions(+), 375 deletions(-) create mode 100644 apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts create mode 100644 apps/server/src/sourceControl/SourceControlProviderDiscovery.ts diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 9c47e8099b..b5c9c0338f 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -655,6 +655,7 @@ function makeManager(input?: { get: () => Effect.succeed(provider), resolveHandle: () => Effect.succeed({ provider, context: null }), resolve: () => Effect.succeed(provider), + discover: Effect.succeed([]), }), ), Effect.provide(Layer.succeed(GitHubCli, gitHubCli)), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1d579c7d03..e7ca1db920 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -235,6 +235,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), + Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts new file mode 100644 index 0000000000..21d80c2d46 --- /dev/null +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -0,0 +1,42 @@ +import { + combinedAuthOutput, + firstSafeAuthLine, + providerAuth, + type SourceControlAuthProbeInput, + type SourceControlCliDiscoverySpec, +} from "./SourceControlProviderDiscovery.ts"; + +function parseAzureAuth(input: SourceControlAuthProbeInput) { + const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); + + if (input.exitCode !== 0) { + return providerAuth({ + status: "unauthenticated", + detail: + firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.", + }); + } + + if (account && account.length > 0) { + return providerAuth({ status: "authenticated", account, host: "dev.azure.com" }); + } + + return providerAuth({ + status: "unknown", + host: "dev.azure.com", + detail: "Azure CLI account status could not be parsed.", + }); +} + +export const discovery = { + type: "cli", + kind: "azure-devops", + label: "Azure DevOps", + executable: "az", + versionArgs: ["--version"], + authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], + parseAuth: parseAzureAuth, + implemented: false, + installHint: + "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.", +} satisfies SourceControlCliDiscoverySpec; diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index ec2462ddea..17daf118b8 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -3,6 +3,7 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac import { BitbucketApi, type BitbucketApiError } from "./BitbucketApi.ts"; import { SourceControlProvider, sourceControlRefFromInput } from "./SourceControlProvider.ts"; +import type { SourceControlApiDiscoverySpec } from "./SourceControlProviderDiscovery.ts"; import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts"; function providerError(operation: string, cause: BitbucketApiError): SourceControlProviderError { @@ -101,3 +102,18 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () }); export const layer = Layer.effect(SourceControlProvider, make()); + +export const makeDiscovery = Effect.fn("makeBitbucketSourceControlProviderDiscovery")(function* () { + const bitbucket = yield* BitbucketApi; + + return { + type: "api", + kind: "bitbucket", + label: "Bitbucket", + executable: "Bitbucket REST API", + implemented: true, + installHint: + "Create a Bitbucket API token with pull request/repository scopes, then set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN.", + probeAuth: bitbucket.probeAuth, + } satisfies SourceControlApiDiscoverySpec; +}); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b893e70f7b..7dbef53da7 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -8,6 +8,15 @@ import { import { GitHubCli, type GitHubCliError, type GitHubPullRequestSummary } from "./GitHubCli.ts"; import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts"; import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts"; +import { + combinedAuthOutput, + firstSafeAuthLine, + matchFirst, + parseCliHost, + providerAuth, + type SourceControlAuthProbeInput, + type SourceControlCliDiscoverySpec, +} from "./SourceControlProviderDiscovery.ts"; function providerError(operation: string, cause: GitHubCliError): SourceControlProviderError { return new SourceControlProviderError({ @@ -40,6 +49,45 @@ function toChangeRequest(summary: GitHubPullRequestSummary): ChangeRequest { }; } +function parseGitHubAuth(input: SourceControlAuthProbeInput) { + const output = combinedAuthOutput(input); + const account = matchFirst(output, [ + /Logged in to .* account\s+([^\s(]+)/iu, + /Logged in to .* as\s+([^\s(]+)/iu, + ]); + const host = parseCliHost(output); + + if (input.exitCode !== 0) { + return providerAuth({ + status: "unauthenticated", + host, + detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", + }); + } + + if (account) { + return providerAuth({ status: "authenticated", account, host }); + } + + return providerAuth({ + status: "unknown", + host, + detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", + }); +} + +export const discovery = { + type: "cli", + kind: "github", + label: "GitHub", + executable: "gh", + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: parseGitHubAuth, + implemented: true, + installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.", +} satisfies SourceControlCliDiscoverySpec; + export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { const github = yield* GitHubCli; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index c65bbf207b..bf5d28b3ae 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -3,6 +3,15 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac import { GitLabCli, type GitLabCliError, type GitLabMergeRequestSummary } from "./GitLabCli.ts"; import { SourceControlProvider, sourceControlRefFromInput } from "./SourceControlProvider.ts"; +import { + combinedAuthOutput, + firstSafeAuthLine, + matchFirst, + parseCliHost, + providerAuth, + type SourceControlAuthProbeInput, + type SourceControlCliDiscoverySpec, +} from "./SourceControlProviderDiscovery.ts"; function providerError(operation: string, cause: GitLabCliError): SourceControlProviderError { return new SourceControlProviderError({ @@ -35,6 +44,47 @@ function toChangeRequest(summary: GitLabMergeRequestSummary): ChangeRequest { }; } +function parseGitLabAuth(input: SourceControlAuthProbeInput) { + const output = combinedAuthOutput(input); + const account = matchFirst(output, [ + /Logged in to .* as\s+([^\s(]+)/iu, + /Logged in to .* account\s+([^\s(]+)/iu, + /account:\s*([^\s(]+)/iu, + ]); + const host = parseCliHost(output); + + if (input.exitCode !== 0) { + return providerAuth({ + status: "unauthenticated", + host, + detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.", + }); + } + + if (account) { + return providerAuth({ status: "authenticated", account, host }); + } + + return providerAuth({ + status: "unknown", + host, + detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.", + }); +} + +export const discovery = { + type: "cli", + kind: "gitlab", + label: "GitLab", + executable: "glab", + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: parseGitLabAuth, + implemented: true, + installHint: + "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.", +} satisfies SourceControlCliDiscoverySpec; + export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { const gitlab = yield* GitLabCli; diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 54dec6f554..3eb40708e2 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -6,8 +6,31 @@ import { VcsProcessSpawnError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { BitbucketApi } from "./BitbucketApi.ts"; +import { BitbucketApi, type BitbucketApiShape } from "./BitbucketApi.ts"; +import { GitHubCli } from "./GitHubCli.ts"; +import { GitLabCli } from "./GitLabCli.ts"; import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts"; +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; +import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; + +const sourceControlProviderRegistryTestLayer = (input: { + readonly bitbucket: Partial; + readonly process: Partial; +}) => + SourceControlProviderRegistry.layer.pipe( + Layer.provide( + Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( + Layer.provide(NodeServices.layer), + ), + Layer.mock(BitbucketApi)(input.bitbucket), + Layer.mock(GitHubCli)({}), + Layer.mock(GitLabCli)({}), + Layer.mock(VcsDriverRegistry)({}), + Layer.mock(VcsProcess.VcsProcess)(input.process), + ), + ), + ); const processOutput = ( stdout: string, @@ -24,51 +47,53 @@ const processOutput = ( }); it.effect("reports implemented tools separately from locally available CLIs", () => { - const testLayer = layer.pipe( - Layer.provide( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), - ), - Layer.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - if (input.command === "git") { - return Effect.succeed(processOutput("git version 2.51.0\n")); - } - if (input.command === "gh" && input.args[0] === "--version") { - return Effect.succeed(processOutput("gh version 2.83.0\n")); - } - if (input.command === "gh" && input.args.join(" ") === "auth status") { - return Effect.succeed( - processOutput(`github.com + const processMock = { + run: (input: VcsProcess.VcsProcessInput) => { + if (input.command === "git") { + return Effect.succeed(processOutput("git version 2.51.0\n")); + } + if (input.command === "gh" && input.args[0] === "--version") { + return Effect.succeed(processOutput("gh version 2.83.0\n")); + } + if (input.command === "gh" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`github.com Logged in to github.com account juliusmarminge (keyring) - Active account: true - Git operations protocol: ssh - Token: gho_************************************ - Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo' `), - ); - } - return Effect.fail( - new VcsProcessSpawnError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - cause: new Error(`${input.command} not found`), - }), - ); - }, - }), + ); + } + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error(`${input.command} not found`), + }), + ); + }, + } satisfies Partial; + const testLayer = layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), ), + Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), Layer.provide( - Layer.mock(BitbucketApi)({ - probeAuth: Effect.succeed({ - status: "unauthenticated", - account: Option.none(), - host: Option.some("bitbucket.org"), - detail: Option.some( - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", - ), - }), + sourceControlProviderRegistryTestLayer({ + process: processMock, + bitbucket: { + probeAuth: Effect.succeed({ + status: "unauthenticated", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.some( + "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + ), + }), + }, }), ), Layer.provideMerge(NodeServices.layer), @@ -135,57 +160,59 @@ Logged in to github.com account juliusmarminge (keyring) }); it.effect("probes provider authentication without exposing token details", () => { - const testLayer = layer.pipe( - Layer.provide( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), - ), - Layer.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - if (input.args[0] === "--version") { - return Effect.succeed(processOutput(`${input.command} version test\n`)); - } - if (input.command === "gh" && input.args.join(" ") === "auth status") { - return Effect.succeed( - processOutput(`github.com + const processMock = { + run: (input: VcsProcess.VcsProcessInput) => { + if (input.args[0] === "--version") { + return Effect.succeed(processOutput(`${input.command} version test\n`)); + } + if (input.command === "gh" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`github.com Logged in to github.com account octocat (keyring) - Token: gho_************************************ - Token scopes: 'repo' `), - ); - } - if (input.command === "glab" && input.args.join(" ") === "auth status") { - return Effect.succeed( - processOutput(`gitlab.com + ); + } + if (input.command === "glab" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`gitlab.com Logged in to gitlab.com as gitlab-user `), - ); - } - if ( - input.command === "az" && - input.args.join(" ") === "account show --query user.name -o tsv" - ) { - return Effect.succeed(processOutput("azure-user@example.com\n")); - } - return Effect.fail( - new VcsProcessSpawnError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - cause: new Error(`${input.command} not found`), - }), - ); - }, - }), + ); + } + if ( + input.command === "az" && + input.args.join(" ") === "account show --query user.name -o tsv" + ) { + return Effect.succeed(processOutput("azure-user@example.com\n")); + } + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error(`${input.command} not found`), + }), + ); + }, + } satisfies Partial; + const testLayer = layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), ), + Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), Layer.provide( - Layer.mock(BitbucketApi)({ - probeAuth: Effect.succeed({ - status: "authenticated", - account: Option.some("bitbucket-user"), - host: Option.some("bitbucket.org"), - detail: Option.none(), - }), + sourceControlProviderRegistryTestLayer({ + process: processMock, + bitbucket: { + probeAuth: Effect.succeed({ + status: "authenticated", + account: Option.some("bitbucket-user"), + host: Option.some("bitbucket.org"), + detail: Option.none(), + }), + }, }), ), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index 4833bfefbd..fa01023de5 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -1,8 +1,5 @@ import { - type SourceControlProviderAuth, type SourceControlDiscoveryResult, - type SourceControlProviderDiscoveryItem, - type SourceControlProviderKind, type VcsDiscoveryItem, type VcsDriverKind, } from "@t3tools/contracts"; @@ -10,7 +7,8 @@ import { Context, Effect, Layer, Option } from "effect"; import { ServerConfig } from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { BitbucketApi } from "./BitbucketApi.ts"; +import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; +import { detailFromCause, firstNonEmptyLine } from "./SourceControlProviderDiscovery.ts"; interface DiscoveryProbe { readonly label: string; @@ -26,27 +24,6 @@ type VcsProbe = DiscoveryProbe & { readonly versionArgs: ReadonlyArray; }; -type CliProviderProbe = DiscoveryProbe & { - readonly type: "cli"; - readonly kind: SourceControlProviderKind; - readonly authArgs?: ReadonlyArray; - readonly parseAuth?: (input: AuthProbeInput) => SourceControlProviderAuth; -}; - -type ApiProviderProbe = Omit & { - readonly type: "api"; - readonly kind: SourceControlProviderKind; - readonly executable: string; -}; - -type ProviderProbe = CliProviderProbe | ApiProviderProbe; - -interface AuthProbeInput { - readonly stdout: string; - readonly stderr: string; - readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"]; -} - interface DiscoveryProbeResult { readonly kind: Kind; readonly label: string; @@ -77,209 +54,6 @@ const VCS_PROBES: ReadonlyArray = [ }, ]; -const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray = [ - { - type: "cli", - kind: "github", - label: "GitHub", - executable: "gh", - versionArgs: ["--version"], - authArgs: ["auth", "status"], - parseAuth: parseGitHubAuth, - implemented: true, - installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.", - }, - { - type: "cli", - kind: "gitlab", - label: "GitLab", - executable: "glab", - versionArgs: ["--version"], - authArgs: ["auth", "status"], - parseAuth: parseGitLabAuth, - implemented: true, - installHint: - "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.", - }, - { - type: "cli", - kind: "azure-devops", - label: "Azure DevOps", - executable: "az", - versionArgs: ["--version"], - authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], - parseAuth: parseAzureAuth, - implemented: false, - installHint: - "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.", - }, - { - type: "api", - kind: "bitbucket", - label: "Bitbucket", - executable: "Bitbucket REST API", - implemented: true, - installHint: - "Create a Bitbucket API token with pull request/repository scopes, then set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN.", - }, -]; - -function firstNonEmptyLine(text: string): Option.Option { - const line = text - .split(/\r?\n/) - .map((entry) => entry.trim()) - .find((entry) => entry.length > 0); - return line === undefined ? Option.none() : Option.some(line); -} - -function detailFromCause(cause: unknown): Option.Option { - if (cause instanceof Error && cause.message.trim().length > 0) { - return Option.some(cause.message.trim()); - } - return Option.none(); -} - -function authAccount(account: string | undefined): Option.Option { - const trimmed = account?.trim(); - return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); -} - -function authHost(host: string | undefined): Option.Option { - const trimmed = host?.trim(); - return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); -} - -function authDetail(detail: string | undefined): Option.Option { - const trimmed = detail?.trim(); - return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); -} - -function providerAuth(input: { - readonly status: SourceControlProviderAuth["status"]; - readonly account?: string | undefined; - readonly host?: string | undefined; - readonly detail?: string | undefined; -}): SourceControlProviderAuth { - return { - status: input.status, - account: authAccount(input.account), - host: authHost(input.host), - detail: authDetail(input.detail), - }; -} - -function unknownAuth(detail?: string): SourceControlProviderAuth { - return providerAuth({ status: "unknown", detail }); -} - -function combinedAuthOutput(input: AuthProbeInput): string { - return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n"); -} - -function sanitizedAuthLines(text: string): ReadonlyArray { - return text - .split(/\r?\n/) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - .filter((entry) => !/^[-\s]*token(?:\s+scopes?)?:/iu.test(entry)); -} - -function firstSafeAuthLine(text: string): string | undefined { - return sanitizedAuthLines(text)[0]; -} - -function parseCliHost(text: string): string | undefined { - return sanitizedAuthLines(text) - .map((line) => line.replace(/^[^a-z0-9]+/iu, "")) - .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line)); -} - -function matchFirst(text: string, patterns: ReadonlyArray): string | undefined { - for (const pattern of patterns) { - const match = pattern.exec(text); - const value = match?.[1]?.trim(); - if (value && value.length > 0) return value; - } - return undefined; -} - -function parseGitHubAuth(input: AuthProbeInput): SourceControlProviderAuth { - const output = combinedAuthOutput(input); - const account = matchFirst(output, [ - /Logged in to .* account\s+([^\s(]+)/iu, - /Logged in to .* as\s+([^\s(]+)/iu, - ]); - const host = parseCliHost(output); - - if (input.exitCode !== 0) { - return providerAuth({ - status: "unauthenticated", - host, - detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", - }); - } - - if (account) { - return providerAuth({ status: "authenticated", account, host }); - } - - return providerAuth({ - status: "unknown", - host, - detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", - }); -} - -function parseGitLabAuth(input: AuthProbeInput): SourceControlProviderAuth { - const output = combinedAuthOutput(input); - const account = matchFirst(output, [ - /Logged in to .* as\s+([^\s(]+)/iu, - /Logged in to .* account\s+([^\s(]+)/iu, - /account:\s*([^\s(]+)/iu, - ]); - const host = parseCliHost(output); - - if (input.exitCode !== 0) { - return providerAuth({ - status: "unauthenticated", - host, - detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.", - }); - } - - if (account) { - return providerAuth({ status: "authenticated", account, host }); - } - - return providerAuth({ - status: "unknown", - host, - detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.", - }); -} - -function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth { - const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); - - if (input.exitCode !== 0) { - return providerAuth({ - status: "unauthenticated", - detail: - firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.", - }); - } - - if (account && account.length > 0) { - return providerAuth({ status: "authenticated", account, host: "dev.azure.com" }); - } - - return providerAuth({ - status: "unknown", - host: "dev.azure.com", - detail: "Azure CLI account status could not be parsed.", - }); -} - export interface SourceControlDiscoveryShape { readonly discover: Effect.Effect; } @@ -294,9 +68,9 @@ export const layer = Layer.effect( Effect.gen(function* () { const config = yield* ServerConfig; const process = yield* VcsProcess.VcsProcess; - const bitbucketApi = yield* BitbucketApi; + const sourceControlProviders = yield* SourceControlProviderRegistry; - const probe = ( + const probe = ( input: DiscoveryProbe & { readonly kind: Kind }, ): Effect.Effect> => { const executable = input.executable; @@ -355,74 +129,13 @@ export const layer = Layer.effect( ); }; - const probeProvider = (input: ProviderProbe) => - input.type === "api" - ? bitbucketApi.probeAuth.pipe( - Effect.map( - (auth) => - ({ - kind: input.kind, - label: input.label, - executable: input.executable, - implemented: input.implemented, - status: "available" as const, - version: Option.none(), - installHint: input.installHint, - detail: Option.none(), - auth, - }) satisfies SourceControlProviderDiscoveryItem, - ), - ) - : probe(input).pipe( - Effect.flatMap((item) => { - if (item.status !== "available") { - return Effect.succeed({ - ...item, - auth: unknownAuth("CLI is not installed."), - } satisfies SourceControlProviderDiscoveryItem); - } - - return process - .run({ - operation: "source-control.discovery.auth", - command: input.executable, - args: input.authArgs, - cwd: config.cwd, - allowNonZeroExit: true, - timeoutMs: 5_000, - maxOutputBytes: 8_000, - truncateOutputAtMaxBytes: true, - }) - .pipe( - Effect.map( - (result) => - ({ - ...item, - auth: input.parseAuth(result), - }) satisfies SourceControlProviderDiscoveryItem, - ), - Effect.catch((cause) => - Effect.succeed({ - ...item, - auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), - } satisfies SourceControlProviderDiscoveryItem), - ), - ); - }), - ); - return SourceControlDiscovery.of({ discover: Effect.all({ versionControlSystems: Effect.all( VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray>, { concurrency: "unbounded" }, ), - sourceControlProviders: Effect.all( - SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probeProvider(entry)) as ReadonlyArray< - Effect.Effect - >, - { concurrency: "unbounded" }, - ), + sourceControlProviders: sourceControlProviders.discover, }), }); }), diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts new file mode 100644 index 0000000000..5182812489 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -0,0 +1,243 @@ +import type { + SourceControlProviderAuth, + SourceControlProviderDiscoveryItem, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import { Effect, Option } from "effect"; + +import type * as VcsProcess from "../vcs/VcsProcess.ts"; + +export interface SourceControlAuthProbeInput { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"]; +} + +interface SourceControlDiscoverySpecBase { + readonly kind: SourceControlProviderKind; + readonly label: string; + readonly executable: string; + readonly implemented: boolean; + readonly installHint: string; +} + +export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { + readonly type: "cli"; + readonly versionArgs: ReadonlyArray; + readonly authArgs: ReadonlyArray; + readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; +}; + +export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & { + readonly type: "api"; + readonly probeAuth: Effect.Effect; +}; + +export type SourceControlProviderDiscoverySpec = + | SourceControlCliDiscoverySpec + | SourceControlApiDiscoverySpec; + +interface DiscoveryProbeResult { + readonly kind: SourceControlProviderKind; + readonly label: string; + readonly executable: string; + readonly implemented: boolean; + readonly status: "available" | "missing"; + readonly version: Option.Option; + readonly installHint: string; + readonly detail: Option.Option; +} + +export function firstNonEmptyLine(text: string): Option.Option { + const line = text + .split(/\r?\n/) + .map((entry) => entry.trim()) + .find((entry) => entry.length > 0); + return line === undefined ? Option.none() : Option.some(line); +} + +export function detailFromCause(cause: unknown): Option.Option { + if (cause instanceof Error && cause.message.trim().length > 0) { + return Option.some(cause.message.trim()); + } + return Option.none(); +} + +function authAccount(account: string | undefined): Option.Option { + const trimmed = account?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +function authHost(host: string | undefined): Option.Option { + const trimmed = host?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +function authDetail(detail: string | undefined): Option.Option { + const trimmed = detail?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +export function providerAuth(input: { + readonly status: SourceControlProviderAuth["status"]; + readonly account?: string | undefined; + readonly host?: string | undefined; + readonly detail?: string | undefined; +}): SourceControlProviderAuth { + return { + status: input.status, + account: authAccount(input.account), + host: authHost(input.host), + detail: authDetail(input.detail), + }; +} + +export function unknownAuth(detail?: string): SourceControlProviderAuth { + return providerAuth({ status: "unknown", detail }); +} + +export function combinedAuthOutput(input: SourceControlAuthProbeInput): string { + return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n"); +} + +function sanitizedAuthLines(text: string): ReadonlyArray { + return text + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .filter((entry) => !/^[-\s]*token(?:\s+scopes?)?:/iu.test(entry)); +} + +export function firstSafeAuthLine(text: string): string | undefined { + return sanitizedAuthLines(text)[0]; +} + +export function parseCliHost(text: string): string | undefined { + return sanitizedAuthLines(text) + .map((line) => line.replace(/^[^a-z0-9]+/iu, "")) + .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line)); +} + +export function matchFirst(text: string, patterns: ReadonlyArray): string | undefined { + for (const pattern of patterns) { + const match = pattern.exec(text); + const value = match?.[1]?.trim(); + if (value && value.length > 0) return value; + } + return undefined; +} + +function probeCli(input: { + readonly spec: SourceControlCliDiscoverySpec; + readonly process: VcsProcess.VcsProcessShape; + readonly cwd: string; +}): Effect.Effect { + return input.process + .run({ + operation: "source-control.discovery.probe", + command: input.spec.executable, + args: input.spec.versionArgs, + cwd: input.cwd, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + truncateOutputAtMaxBytes: true, + }) + .pipe( + Effect.map( + (result) => + ({ + kind: input.spec.kind, + label: input.spec.label, + executable: input.spec.executable, + implemented: input.spec.implemented, + status: "available" as const, + version: Option.orElse(firstNonEmptyLine(result.stdout), () => + firstNonEmptyLine(result.stderr), + ), + installHint: input.spec.installHint, + detail: Option.none(), + }) satisfies DiscoveryProbeResult, + ), + Effect.catch((cause) => + Effect.succeed({ + kind: input.spec.kind, + label: input.spec.label, + executable: input.spec.executable, + implemented: input.spec.implemented, + status: "missing" as const, + version: Option.none(), + installHint: input.spec.installHint, + detail: detailFromCause(cause), + } satisfies DiscoveryProbeResult), + ), + ); +} + +export function probeSourceControlProvider(input: { + readonly spec: SourceControlProviderDiscoverySpec; + readonly process: VcsProcess.VcsProcessShape; + readonly cwd: string; +}): Effect.Effect { + if (input.spec.type === "api") { + return input.spec.probeAuth.pipe( + Effect.map( + (auth) => + ({ + kind: input.spec.kind, + label: input.spec.label, + executable: input.spec.executable, + implemented: input.spec.implemented, + status: "available" as const, + version: Option.none(), + installHint: input.spec.installHint, + detail: Option.none(), + auth, + }) satisfies SourceControlProviderDiscoveryItem, + ), + ); + } + + const spec = input.spec; + + return probeCli({ + spec, + process: input.process, + cwd: input.cwd, + }).pipe( + Effect.flatMap((item) => { + if (item.status !== "available") { + return Effect.succeed({ + ...item, + auth: unknownAuth("CLI is not installed."), + } satisfies SourceControlProviderDiscoveryItem); + } + + return input.process + .run({ + operation: "source-control.discovery.auth", + command: spec.executable, + args: spec.authArgs, + cwd: input.cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + truncateOutputAtMaxBytes: true, + }) + .pipe( + Effect.map( + (result) => + ({ + ...item, + auth: spec.parseAuth(result), + }) satisfies SourceControlProviderDiscoveryItem, + ), + Effect.catch((cause) => + Effect.succeed({ + ...item, + auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), + } satisfies SourceControlProviderDiscoveryItem), + ), + ); + }), + ); +} diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 529ffaf289..0299142e2e 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -1,10 +1,13 @@ import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { DateTime, Effect, Layer, Option } from "effect"; import { BitbucketApi } from "./BitbucketApi.ts"; import { GitHubCli } from "./GitHubCli.ts"; import { GitLabCli } from "./GitLabCli.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; +import { ServerConfig } from "../config.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; import type { VcsDriverShape } from "../vcs/VcsDriver.ts"; @@ -58,6 +61,10 @@ function makeRegistry(input: { Layer.mock(BitbucketApi)({}), Layer.mock(GitHubCli)({}), Layer.mock(GitLabCli)({}), + Layer.mock(VcsProcess.VcsProcess)({}), + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( + Layer.provide(NodeServices.layer), + ), ), ), ); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 36e63b63ce..fcfb874a7e 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -1,17 +1,27 @@ import { Cache, Context, Duration, Effect, Exit, Layer } from "effect"; -import { SourceControlProviderError } from "@t3tools/contracts"; +import { + SourceControlProviderError, + type SourceControlProviderDiscoveryItem, +} from "@t3tools/contracts"; import type { SourceControlProviderKind } from "@t3tools/contracts"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; +import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; import { SourceControlProvider, type SourceControlProviderContext, type SourceControlProviderShape, } from "./SourceControlProvider.ts"; +import { + probeSourceControlProvider, + type SourceControlProviderDiscoverySpec, +} from "./SourceControlProviderDiscovery.ts"; import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; +import { ServerConfig } from "../config.ts"; import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); @@ -19,6 +29,7 @@ const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); export interface SourceControlProviderRegistration { readonly kind: SourceControlProviderKind; readonly provider: SourceControlProviderShape; + readonly discovery: SourceControlProviderDiscoverySpec; } export interface SourceControlProviderHandle { @@ -36,6 +47,7 @@ export interface SourceControlProviderRegistryShape { readonly resolve: (input: { readonly cwd: string; }) => Effect.Effect; + readonly discover: Effect.Effect>; } export class SourceControlProviderRegistry extends Context.Service< @@ -146,10 +158,13 @@ function bindProviderContext( export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( function* (registrations: ReadonlyArray) { + const config = yield* ServerConfig; + const process = yield* VcsProcess.VcsProcess; const vcsRegistry = yield* VcsDriverRegistry; const providers = new Map( registrations.map((registration) => [registration.kind, registration.provider]), ); + const discoverySpecs = registrations.map((registration) => registration.discovery); const get: SourceControlProviderRegistryShape["get"] = (kind) => Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); @@ -192,6 +207,16 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit get, resolveHandle, resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), + discover: Effect.all( + discoverySpecs.map((spec) => + probeSourceControlProvider({ + spec, + process, + cwd: config.cwd, + }), + ), + { concurrency: "unbounded" }, + ), }); }, ); @@ -200,18 +225,27 @@ export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () const github = yield* GitHubSourceControlProvider.make(); const gitlab = yield* GitLabSourceControlProvider.make(); const bitbucket = yield* BitbucketSourceControlProvider.make(); + const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery(); return yield* makeWithProviders([ { kind: "github", provider: github, + discovery: GitHubSourceControlProvider.discovery, }, { kind: "gitlab", provider: gitlab, + discovery: GitLabSourceControlProvider.discovery, + }, + { + kind: "azure-devops", + provider: unsupportedProvider("azure-devops"), + discovery: AzureDevOpsSourceControlProvider.discovery, }, { kind: "bitbucket", provider: bitbucket, + discovery: bitbucketDiscovery, }, ]); }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 20291bda0f..c6045797a6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -57,6 +57,11 @@ import { ServerAuth } from "./auth/Services/ServerAuth.ts"; import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; +import * as GitHubCli from "./sourceControl/GitHubCli.ts"; +import * as GitLabCli from "./sourceControl/GitLabCli.ts"; +import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; +import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; +import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import { BootstrapCredentialService, @@ -1132,7 +1137,16 @@ export const websocketRpcRouteLayer = Layer.unwrap( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide( SourceControlDiscoveryLayer.layer.pipe( - Layer.provideMerge(BitbucketApi.layer), + Layer.provide( + SourceControlProviderRegistry.layer.pipe( + Layer.provide( + Layer.mergeAll(BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer), + ), + Layer.provide( + VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)), + ), + ), + ), Layer.provide(VcsProcess.layer), ), ), From cb6dd2017bee8fa17e8cdd4eefb8ca4edefddd1f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 3 May 2026 00:31:26 -0700 Subject: [PATCH 08/24] Document source control provider setup --- docs/source-control-providers.md | 200 +++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/source-control-providers.md diff --git a/docs/source-control-providers.md b/docs/source-control-providers.md new file mode 100644 index 0000000000..36dd7234ec --- /dev/null +++ b/docs/source-control-providers.md @@ -0,0 +1,200 @@ +# Source Control Providers + +T3 Code can connect your local projects to source control hosting providers so you can work with +pull requests and merge requests from inside the app. + +This guide covers the providers currently supported by T3 Code: + +- GitHub +- GitLab +- Bitbucket + +## What Provider Support Enables + +When a provider is available and authenticated, T3 Code can use it for source-control actions such +as: + +- detecting the provider for the current project +- showing whether the required provider tools are installed +- showing whether you are signed in +- finding pull requests or merge requests for the current branch +- creating pull requests or merge requests +- opening the current pull request or merge request in your browser +- checking out an existing pull request or merge request locally + +The exact wording may differ by provider. GitHub and Bitbucket call these pull requests. GitLab +calls them merge requests. + +## Check Provider Status + +Open Settings, then Source Control. + +T3 Code shows each provider with: + +- whether the required tool or credentials are available +- whether T3 Code can detect an authenticated account +- the account or host when the provider reports one +- setup hints when something is missing + +After changing authentication in your terminal, refresh the Source Control settings page or restart +T3 Code. + +## GitHub + +GitHub support uses the GitHub CLI. + +### Requirements + +Install GitHub CLI: + +```bash +brew install gh +``` + +Or use the installer from: + + + +### Sign In + +Run: + +```bash +gh auth login +``` + +Follow the prompts and choose the GitHub account you want T3 Code to use. + +To verify the login: + +```bash +gh auth status +``` + +T3 Code reads the GitHub CLI login status and shows the signed-in account in Source Control +settings. + +### Notes + +Use the same GitHub account that has access to the repositories you work with in T3 Code. If you use +SSH remotes, make sure your GitHub SSH key is set up as well. + +## GitLab + +GitLab support uses the GitLab CLI. + +### Requirements + +Install GitLab CLI: + +```bash +brew install glab +``` + +Or use the installer from: + + + +### Sign In + +Run: + +```bash +glab auth login +``` + +Follow the prompts for your GitLab host and account. + +To verify the login: + +```bash +glab auth status +``` + +T3 Code reads the GitLab CLI login status and shows the signed-in account in Source Control +settings. + +### Notes + +If your team uses a self-managed GitLab instance, authenticate `glab` against that host. T3 Code +uses your repository remote to determine which provider should handle a project. + +## Bitbucket + +Bitbucket support uses the Bitbucket Cloud REST API. + +Bitbucket does not have an official general-purpose CLI like GitHub CLI or GitLab CLI, so T3 Code +uses environment variables for authentication. + +### Requirements + +Create a Bitbucket API token for your Atlassian account. + +The token should include the Bitbucket scopes needed for the actions you want to use: + +- read access to your Bitbucket account +- read access to pull requests +- write access to pull requests + +If you want to push commits over HTTPS, your Git credentials also need write access to the +repository. Many users prefer SSH for Git push and pull. + +### Sign In + +Expose these environment variables in the shell that starts T3 Code: + +```bash +export T3CODE_BITBUCKET_EMAIL="you@example.com" +export T3CODE_BITBUCKET_API_TOKEN="your-api-token" +``` + +Use your Atlassian account email for `T3CODE_BITBUCKET_EMAIL`. + +If you normally start T3 Code from a terminal, put those exports in your shell profile, such as +`~/.zshrc`. + +If you start T3 Code from a desktop launcher, make sure the launcher receives those environment +variables too. + +To verify the token manually: + +```bash +curl -u "$T3CODE_BITBUCKET_EMAIL:$T3CODE_BITBUCKET_API_TOKEN" \ + -H "Accept: application/json" \ + "https://api.bitbucket.org/2.0/user" +``` + +T3 Code uses the same credentials to check your Bitbucket sign-in status. + +### Notes + +Bitbucket workspace billing and repository permissions can affect whether Git pushes are allowed. +If pull request creation works but pushing fails, check the repository permissions, workspace plan, +and whether your Git remote uses HTTPS credentials or SSH. + +## Version Control Requirements + +Source control providers work with your local version control setup. + +Today, Git is the supported local version control system for provider actions. Make sure Git is +installed: + +```bash +git --version +``` + +T3 Code can also detect Jujutsu installations in Source Control settings, but provider workflows for +Jujutsu are still being built. + +## Troubleshooting + +If a provider shows as unavailable: + +1. Install the required CLI or configure the required environment variables. +2. Authenticate in your terminal. +3. Restart T3 Code or refresh Source Control settings. +4. Check that the current project's remote URL points to the provider you expect. +5. Confirm your account has access to the repository. + +If provider actions work but Git push or checkout fails, verify your Git remote and credentials +separately with normal Git commands. From 69701a5c267f6116dc50536147efcba313f0e1e7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 3 May 2026 00:45:17 -0700 Subject: [PATCH 09/24] Fix Bitbucket closed PR state filtering --- .../src/sourceControl/BitbucketApi.test.ts | 34 +++++++++++++++++++ apps/server/src/sourceControl/BitbucketApi.ts | 6 ++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index bca61366af..363719ff8c 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -218,6 +218,36 @@ it.effect("lists pull requests with Bitbucket state and source branch query para }).pipe(Effect.provide(layer)); }); +it.effect("lists closed pull requests with both closed Bitbucket states", () => { + const { execute, layer } = makeLayer({ + response: () => + Response.json({ + values: [], + }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + yield* bitbucket.listPullRequests({ + cwd: "/repo", + headSelector: "feature/closed", + state: "closed", + limit: 10, + }); + + assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [ + ["pagelen", "10"], + ["sort", "-updated_on"], + [ + "q", + 'source.branch.name = "feature/closed" AND (state = "DECLINED" OR state = "SUPERSEDED")', + ], + ["state", "DECLINED"], + ["state", "SUPERSEDED"], + ]); + }).pipe(Effect.provide(layer)); +}); + it.effect("expands all-state pull request listing instead of relying on Bitbucket defaults", () => { const { execute, layer } = makeLayer({ response: () => @@ -242,6 +272,10 @@ it.effect("expands all-state pull request listing instead of relying on Bitbucke "q", 'source.branch.name = "feature/all" AND (state = "OPEN" OR state = "MERGED" OR state = "DECLINED" OR state = "SUPERSEDED")', ], + ["state", "OPEN"], + ["state", "MERGED"], + ["state", "DECLINED"], + ["state", "SUPERSEDED"], ]); }).pipe(Effect.provide(layer)); }); diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index a2bd0ab379..e83ad38bf8 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -521,17 +521,15 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { resolveRepository(input).pipe( Effect.flatMap((repository) => { const states = toBitbucketStates(input.state); - const query: Record = { + const query: Record> = { pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))), sort: "-updated_on", q: bitbucketQueryString([ `source.branch.name = "${sourceBranch(input).replaceAll('"', '\\"')}"`, bitbucketStateFilter(states), ]), + state: states, }; - if (input.state !== "all" && states.length === 1) { - query.state = states[0] ?? "OPEN"; - } return executeJson( "listPullRequests", From 7e848b530592316fd487c28fb677df71c5311a3e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 3 May 2026 11:23:44 -0700 Subject: [PATCH 10/24] Make checkout command examples optional --- packages/shared/src/sourceControl.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 0d7139a41a..24ff3a0da8 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -7,7 +7,7 @@ export interface ChangeRequestPresentation { readonly longName: string; readonly pluralLongName: string; readonly providerLongName: string; - readonly checkoutCommandExample: string; + readonly checkoutCommandExample?: string; readonly urlExample: string; } @@ -61,7 +61,6 @@ const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { longName: "pull request", pluralLongName: "pull requests", providerLongName: "Bitbucket pull request", - checkoutCommandExample: "Not available through an official Bitbucket CLI", urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42", }; @@ -72,7 +71,6 @@ const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { longName: "change request", pluralLongName: "change requests", providerLongName: "change request", - checkoutCommandExample: "123", urlExample: "#42", }; From 35897a4468433f8954446b086081ac2b223e4a0f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 3 May 2026 11:37:38 -0700 Subject: [PATCH 11/24] Provide Git VCS driver for websocket source control --- apps/server/src/ws.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c6045797a6..67df52ddc2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -60,6 +60,7 @@ import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; +import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; @@ -1142,6 +1143,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( Layer.provide( Layer.mergeAll(BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer), ), + Layer.provideMerge(GitVcsDriver.layer), Layer.provide( VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)), ), From 9f10d141b45f81743ce64e5ee253a583d1cc78c9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 3 May 2026 18:19:30 -0700 Subject: [PATCH 12/24] Support Bitbucket repository publishing --- apps/server/src/server.ts | 1 + .../src/sourceControl/BitbucketApi.test.ts | 32 +++++++++++++++ apps/server/src/sourceControl/BitbucketApi.ts | 41 +++++++++++++++++++ .../BitbucketSourceControlProvider.ts | 4 ++ .../SourceControlDiscovery.test.ts | 8 ++-- .../SourceControlProviderRegistry.ts | 1 + 6 files changed, 83 insertions(+), 4 deletions(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e7ca1db920..98805c7f3e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -165,6 +165,7 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe( Layer.provide(Layer.mergeAll(BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer)), + Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(VcsDriverRegistryLayerLive), ); diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 363719ff8c..69c7bcea55 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -302,6 +302,38 @@ it.effect("reads repository clone URLs and default branch", () => { }).pipe(Effect.provide(layer)); }); +it.effect("creates repositories through the Bitbucket REST API", () => { + const { execute, layer } = makeLayer({ + response: () => Response.json(repositoryJson), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const cloneUrls = yield* bitbucket.createRepository({ + cwd: "/repo", + repository: "pingdotgg/t3code", + visibility: "private", + }); + + assert.deepStrictEqual(cloneUrls, { + nameWithOwner: "pingdotgg/t3code", + url: "https://bitbucket.org/pingdotgg/t3code.git", + sshUrl: "git@bitbucket.org:pingdotgg/t3code.git", + }); + + const request = execute.mock.calls[0]?.[0]; + assert.strictEqual(request?.url, "https://api.test.local/2.0/repositories/pingdotgg/t3code"); + assert.strictEqual(request?.method, "POST"); + assert.ok(request); + const rawBody = (request.body as { readonly body?: Uint8Array }).body; + assert.ok(rawBody); + assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), { + scm: "git", + is_private: true, + }); + }).pipe(Effect.provide(layer)); +}); + it.effect("creates pull requests using the official REST payload shape", () => { const { execute, layer } = makeLayer({ response: () => Response.json(bitbucketPullRequest), diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index e83ad38bf8..1ea93678fc 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -3,6 +3,7 @@ import { TrimmedNonEmptyString, type SourceControlProviderAuth, type SourceControlRepositoryCloneUrls, + type SourceControlRepositoryVisibility, } from "@t3tools/contracts"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { sanitizeBranchFragment } from "@t3tools/shared/git"; @@ -104,6 +105,11 @@ export interface BitbucketApiShape { readonly context?: SourceControlProviderContext; readonly repository: string; }) => Effect.Effect; + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; readonly createPullRequest: (input: { readonly cwd: string; readonly context?: SourceControlProviderContext; @@ -194,6 +200,21 @@ function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator return workspace && repoSlug ? { workspace, repoSlug } : null; } +function requireRepositoryLocator( + operation: string, + repository: string, +): Effect.Effect { + const locator = parseBitbucketRepositorySlug(repository); + return locator + ? Effect.succeed(locator) + : Effect.fail( + new BitbucketApiError({ + operation, + detail: "Bitbucket repositories must be specified as workspace/repository.", + }), + ); +} + function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null { const trimmed = remoteUrl.trim(); if (trimmed.startsWith("git@")) { @@ -548,6 +569,26 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord)), getRepositoryCloneUrls: (input) => getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), + createRepository: (input) => + requireRepositoryLocator("createRepository", input.repository).pipe( + Effect.flatMap((repository) => + executeJson( + "createRepository", + HttpClientRequest.post( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, + ), + ).pipe( + HttpClientRequest.bodyJsonUnsafe({ + scm: "git", + is_private: input.visibility === "private", + }), + ), + RawBitbucketRepositorySchema, + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ), createPullRequest: (input) => Effect.gen(function* () { const repository = yield* resolveRepository(input); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index 17daf118b8..56b0f1437f 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -82,6 +82,10 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () bitbucket .getRepositoryCloneUrls(input) .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + createRepository: (input) => + bitbucket + .createRepository(input) + .pipe(Effect.mapError((error) => providerError("createRepository", error))), getDefaultBranch: (input) => bitbucket .getDefaultBranch({ diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 3eb40708e2..e304a3f727 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -155,7 +155,7 @@ Logged in to github.com account juliusmarminge (keyring) ); const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); assert.ok(bitbucket); - assert.strictEqual("executable" in bitbucket, false); + assert.strictEqual(bitbucket.executable, "Bitbucket REST API"); }).pipe(Effect.provide(testLayer)); }); @@ -250,9 +250,9 @@ Logged in to gitlab.com as gitlab-user }, { kind: "bitbucket", - auth: "unknown", - account: Option.none(), - detail: Option.some("Bitbucket provider support is not available yet."), + auth: "authenticated", + account: Option.some("bitbucket-user"), + detail: Option.none(), }, ], ); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index fcfb874a7e..9824e16463 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -143,6 +143,7 @@ function bindProviderContext( ...input, context: input.context ?? context, }), + createRepository: (input) => provider.createRepository(input), getDefaultBranch: (input) => provider.getDefaultBranch({ ...input, From 3d3960f4955fa3663be76c018c757656f5e17d3b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 3 May 2026 21:35:19 -0700 Subject: [PATCH 13/24] feat(scm): Azure Devops (#2463) Co-authored-by: Julius Marminge --- apps/server/src/server.ts | 5 +- .../src/sourceControl/AzureDevOpsCli.test.ts | 289 ++++++++++++ .../src/sourceControl/AzureDevOpsCli.ts | 439 ++++++++++++++++++ .../AzureDevOpsSourceControlProvider.test.ts | 90 ++++ .../AzureDevOpsSourceControlProvider.ts | 116 ++++- .../SourceControlDiscovery.test.ts | 4 +- .../SourceControlProviderRegistry.test.ts | 14 + .../SourceControlProviderRegistry.ts | 3 +- .../sourceControl/azureDevOpsPullRequests.ts | 106 +++++ apps/server/src/ws.ts | 8 +- apps/web/src/pullRequestReference.test.ts | 4 + apps/web/src/pullRequestReference.ts | 10 +- docs/source-control-providers.md | 52 +++ 13 files changed, 1132 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/sourceControl/AzureDevOpsCli.test.ts create mode 100644 apps/server/src/sourceControl/AzureDevOpsCli.ts create mode 100644 apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts create mode 100644 apps/server/src/sourceControl/azureDevOpsPullRequests.ts diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 98805c7f3e..23316ec5a9 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -25,6 +25,7 @@ import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReap import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; +import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; @@ -164,7 +165,9 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( ); const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe( - Layer.provide(Layer.mergeAll(BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer)), + Layer.provide( + Layer.mergeAll(AzureDevOpsCli.layer, BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer), + ), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(VcsDriverRegistryLayerLive), ); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts new file mode 100644 index 0000000000..500ec7bc6a --- /dev/null +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -0,0 +1,289 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { afterEach, describe, expect, vi } from "vitest"; +import type { VcsError } from "@t3tools/contracts"; + +import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; + +const processOutput = (stdout: string): VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const mockRun = vi.fn<(input: VcsProcessInput) => Effect.Effect>(); + +const supportLayer = Layer.mergeAll( + Layer.mock(VcsProcess)({ + run: mockRun, + }), + NodeServices.layer, +); +const layer = Layer.mergeAll(AzureDevOpsCli.layer.pipe(Layer.provide(supportLayer)), supportLayer); + +afterEach(() => { + mockRun.mockReset(); +}); + +describe("AzureDevOpsCli.layer", () => { + it.effect("parses pull request view output", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + pullRequestId: 42, + title: "Add Azure provider", + sourceRefName: "refs/heads/feature/source-control", + targetRefName: "refs/heads/main", + status: "active", + creationDate: "2026-01-02T00:00:00.000Z", + closedDate: null, + _links: { + web: { + href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + }, + }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getPullRequest({ + cwd: "/repo", + reference: "#42", + }); + + assert.strictEqual(result.number, 42); + assert.strictEqual(result.title, "Add Azure provider"); + assert.strictEqual(result.baseRefName, "main"); + assert.strictEqual(result.headRefName, "feature/source-control"); + assert.strictEqual(result.state, "open"); + assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag); + assert.deepStrictEqual(mockRun.mock.calls.at(-1)?.[0], { + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "show", + "--detect", + "true", + "--id", + "42", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("lists pull requests with Azure status and source branch arguments", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify([ + { + pullRequestId: 7, + title: "Merged work", + sourceRefName: "refs/heads/feature/merged", + targetRefName: "refs/heads/main", + status: "completed", + closedDate: "2026-01-03T00:00:00.000Z", + _links: { + web: { + href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/7", + }, + }, + }, + ]), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.listPullRequests({ + cwd: "/repo", + headSelector: "origin:feature/merged", + state: "merged", + limit: 10, + }); + + assert.strictEqual(result[0]?.state, "merged"); + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "list", + "--detect", + "true", + "--source-branch", + "feature/merged", + "--status", + "completed", + "--top", + "10", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reads repository clone URLs", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + name: "repo", + webUrl: "https://dev.azure.com/acme/project/_git/repo", + remoteUrl: "https://dev.azure.com/acme/project/_git/repo", + sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", + project: { + name: "project", + }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "repo", + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "project/repo", + url: "https://dev.azure.com/acme/project/_git/repo", + sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("creates repositories through Azure Repos", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + name: "repo", + webUrl: "https://dev.azure.com/acme/project/_git/repo", + remoteUrl: "https://dev.azure.com/acme/project/_git/repo", + sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", + project: { + name: "project", + }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.createRepository({ + cwd: "/repo", + repository: "project/repo", + visibility: "private", + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "project/repo", + url: "https://dev.azure.com/acme/project/_git/repo", + sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", + }); + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "create", + "--detect", + "true", + "--name", + "repo", + "--project", + "project", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("creates pull requests using the body file as the Azure description", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const bodyFile = `/tmp/t3code-azure-devops-cli-${Date.now()}.md`; + yield* fileSystem.writeFileString(bodyFile, "Generated body"); + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "feature/provider", + title: "Provider PR", + bodyFile, + }); + + expect(mockRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "az", + cwd: "/repo", + args: expect.arrayContaining(["--description", `@${bodyFile}`]), + }), + ); + expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--output"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("does not force JSON output on checkout side-effect commands", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.checkoutPullRequest({ + cwd: "/repo", + reference: "42", + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "checkout", + "--only-show-errors", + "--detect", + "true", + "--id", + "42", + "--remote-name", + "origin", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts new file mode 100644 index 0000000000..0699b94c88 --- /dev/null +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -0,0 +1,439 @@ +import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; +import { + TrimmedNonEmptyString, + type SourceControlRepositoryVisibility, + type VcsError, +} from "@t3tools/contracts"; + +import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; +import { + decodeAzureDevOpsPullRequestJson, + decodeAzureDevOpsPullRequestListJson, + formatAzureDevOpsJsonDecodeError, + type NormalizedAzureDevOpsPullRequestRecord, +} from "./azureDevOpsPullRequests.ts"; +import type { SourceControlRefSelector } from "./SourceControlProvider.ts"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +export class AzureDevOpsCliError extends Schema.TaggedErrorClass()( + "AzureDevOpsCliError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export interface AzureDevOpsRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; +} + +export interface AzureDevOpsCliShape { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly source?: SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, AzureDevOpsCliError>; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlRefSelector; + readonly target?: SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly remoteName?: string; + }) => Effect.Effect; +} + +export class AzureDevOpsCli extends Context.Service()( + "t3/source-control/AzureDevOpsCli", +) {} + +function errorText(error: VcsError | unknown): string { + if (typeof error === "object" && error !== null) { + const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; + const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; + const message = "message" in error && typeof error.message === "string" ? error.message : ""; + return [tag, detail, message].filter(Boolean).join("\n"); + } + + return String(error); +} + +function normalizeAzureDevOpsCliError( + operation: "execute", + error: VcsError | unknown, +): AzureDevOpsCliError { + const text = errorText(error); + const lower = text.toLowerCase(); + + if (lower.includes("command not found: az") || lower.includes("enoent")) { + return new AzureDevOpsCliError({ + operation, + detail: + "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH.", + cause: error, + }); + } + + if ( + lower.includes("az devops login") || + lower.includes("please run az login") || + lower.includes("not logged in") || + lower.includes("authentication failed") || + lower.includes("unauthorized") + ) { + return new AzureDevOpsCliError({ + operation, + detail: "Azure DevOps CLI is not authenticated. Run `az devops login` and retry.", + cause: error, + }); + } + + if ( + lower.includes("pull request") && + (lower.includes("not found") || lower.includes("does not exist")) + ) { + return new AzureDevOpsCliError({ + operation, + detail: "Pull request not found. Check the PR number or URL and try again.", + cause: error, + }); + } + + return new AzureDevOpsCliError({ + operation, + detail: text, + cause: error, + }); +} + +function normalizeChangeRequestId(reference: string): string { + const trimmed = reference.trim().replace(/^#/, ""); + const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed); + return urlMatch?.[1] ?? trimmed; +} + +function normalizeSourceBranch(headSelector: string): string { + const trimmed = headSelector.trim(); + const ownerSelector = /^([^:/\s]+):(.+)$/u.exec(trimmed); + return ownerSelector?.[2]?.trim() ?? trimmed; +} + +function sourceBranch(input: { + readonly headSelector: string; + readonly source?: SourceControlRefSelector; +}): string { + return input.source?.refName ?? normalizeSourceBranch(input.headSelector); +} + +function toAzureStatus(state: "open" | "closed" | "merged" | "all"): string { + switch (state) { + case "open": + return "active"; + case "closed": + return "abandoned"; + case "merged": + return "completed"; + case "all": + return "all"; + } +} + +const RawAzureDevOpsRepositorySchema = Schema.Struct({ + name: TrimmedNonEmptyString, + webUrl: TrimmedNonEmptyString, + remoteUrl: TrimmedNonEmptyString, + sshUrl: TrimmedNonEmptyString, + project: Schema.optional( + Schema.Struct({ + name: TrimmedNonEmptyString, + }), + ), + defaultBranch: Schema.optional(Schema.NullOr(Schema.String)), +}); + +function normalizeDefaultBranch(value: string | null | undefined): string | null { + const trimmed = value?.trim().replace(/^refs\/heads\//, "") ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeRepositoryCloneUrls( + raw: Schema.Schema.Type, +): AzureDevOpsRepositoryCloneUrls { + const projectName = raw.project?.name.trim(); + return { + nameWithOwner: projectName ? `${projectName}/${raw.name}` : raw.name, + url: raw.remoteUrl, + sshUrl: raw.sshUrl, + }; +} + +function parseRepositorySpecifier(repository: string): { + readonly project: string | null; + readonly name: string; +} { + const parts = repository + .split("/") + .map((part) => part.trim()) + .filter((part) => part.length > 0); + return { + project: parts.length > 1 ? (parts.at(-2) ?? null) : null, + name: parts.at(-1) ?? repository.trim(), + }; +} + +function decodeAzureDevOpsJson( + raw: string, + schema: S, + operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", + invalidDetail: string, +): Effect.Effect { + return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError( + (error) => + new AzureDevOpsCliError({ + operation, + detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, + cause: error, + }), + ), + ); +} + +export const make = Effect.fn("makeAzureDevOpsCli")(function* () { + const process = yield* VcsProcess; + + const execute: AzureDevOpsCliShape["execute"] = (input) => + process + .run({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: input.args, + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }) + .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error))); + + const executeJson = (input: Parameters[0]) => + execute({ + ...input, + args: [...input.args, "--only-show-errors", "--output", "json"], + }); + + return AzureDevOpsCli.of({ + execute, + listPullRequests: (input) => + executeJson({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "list", + "--detect", + "true", + "--source-branch", + sourceBranch(input), + "--status", + toAzureStatus(input.state), + "--top", + String(input.limit ?? 20), + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + raw.length === 0 + ? Effect.succeed([]) + : Effect.sync(() => decodeAzureDevOpsPullRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new AzureDevOpsCliError({ + operation: "listPullRequests", + detail: `Azure DevOps CLI returned invalid PR list JSON: ${formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed(decoded.success); + }), + ), + ), + ), + getPullRequest: (input) => + executeJson({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "show", + "--detect", + "true", + "--id", + normalizeChangeRequestId(input.reference), + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + Effect.sync(() => decodeAzureDevOpsPullRequestJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new AzureDevOpsCliError({ + operation: "getPullRequest", + detail: `Azure DevOps CLI returned invalid pull request JSON: ${formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed(decoded.success); + }), + ), + ), + ), + getRepositoryCloneUrls: (input) => + executeJson({ + cwd: input.cwd, + args: ["repos", "show", "--detect", "true", "--repository", input.repository], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeAzureDevOpsJson( + raw, + RawAzureDevOpsRepositorySchema, + "getRepositoryCloneUrls", + "Azure DevOps CLI returned invalid repository JSON.", + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ), + createRepository: (input) => { + const repository = parseRepositorySpecifier(input.repository); + // Azure Repos access is governed by project/organization permissions. + // `az repos create` does not expose a per-repository visibility flag, so + // the generic source-control visibility input is intentionally not + // translated into CLI args for this provider. + return executeJson({ + cwd: input.cwd, + args: [ + "repos", + "create", + "--detect", + "true", + "--name", + repository.name, + ...(repository.project ? ["--project", repository.project] : []), + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeAzureDevOpsJson( + raw, + RawAzureDevOpsRepositorySchema, + "createRepository", + "Azure DevOps CLI returned invalid repository JSON.", + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ); + }, + createPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "create", + "--only-show-errors", + "--detect", + "true", + "--target-branch", + input.target?.refName ?? input.baseBranch, + "--source-branch", + sourceBranch(input), + "--title", + input.title, + "--description", + `@${input.bodyFile}`, + ], + }).pipe(Effect.asVoid), + getDefaultBranch: (input) => + executeJson({ + cwd: input.cwd, + args: ["repos", "show", "--detect", "true"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeAzureDevOpsJson( + raw, + RawAzureDevOpsRepositorySchema, + "getDefaultBranch", + "Azure DevOps CLI returned invalid repository JSON.", + ), + ), + Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), + ), + checkoutPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "checkout", + "--only-show-errors", + "--detect", + "true", + "--id", + normalizeChangeRequestId(input.reference), + "--remote-name", + input.remoteName ?? "origin", + ], + }).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(AzureDevOpsCli, make()); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts new file mode 100644 index 0000000000..d1a56b6ca4 --- /dev/null +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -0,0 +1,90 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; + +import { AzureDevOpsCli, type AzureDevOpsCliShape } from "./AzureDevOpsCli.ts"; +import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; + +function makeProvider(azure: Partial) { + return AzureDevOpsSourceControlProvider.make().pipe( + Effect.provide(Layer.mock(AzureDevOpsCli)(azure)), + ); +} + +it.effect("maps Azure DevOps PR summaries into provider-neutral change requests", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + getPullRequest: () => + Effect.succeed({ + number: 42, + title: "Add Azure provider", + url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + }), + }); + + const changeRequest = yield* provider.getChangeRequest({ + cwd: "/repo", + reference: "42", + }); + + assert.deepStrictEqual(changeRequest, { + provider: "azure-devops", + number: 42, + title: "Add Azure provider", + url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + isCrossRepository: false, + }); + }), +); + +it.effect("creates Azure DevOps PRs through provider-neutral input names", () => + Effect.gen(function* () { + let createInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + createPullRequest: (input) => { + createInput = input; + return Effect.void; + }, + }); + + yield* provider.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + + assert.deepStrictEqual(createInput, { + cwd: "/repo", + baseBranch: "main", + headSelector: "feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + }), +); + +it.effect("uses Azure CLI repository detection for default branch lookup", () => + Effect.gen(function* () { + let cwdInput: string | null = null; + const provider = yield* makeProvider({ + getDefaultBranch: (input) => { + cwdInput = input.cwd; + return Effect.succeed("main"); + }, + }); + + const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); + + assert.strictEqual(defaultBranch, "main"); + assert.strictEqual(cwdInput, "/repo"); + }), +); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 21d80c2d46..ee1ae8faa6 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -1,3 +1,8 @@ +import { Effect, Layer } from "effect"; +import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; + +import { AzureDevOpsCli, type AzureDevOpsCliError } from "./AzureDevOpsCli.ts"; +import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts"; import { combinedAuthOutput, firstSafeAuthLine, @@ -6,6 +11,15 @@ import { type SourceControlCliDiscoverySpec, } from "./SourceControlProviderDiscovery.ts"; +function providerError(operation: string, cause: AzureDevOpsCliError): SourceControlProviderError { + return new SourceControlProviderError({ + provider: "azure-devops", + operation, + detail: cause.detail, + cause, + }); +} + function parseAzureAuth(input: SourceControlAuthProbeInput) { const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); @@ -36,7 +50,107 @@ export const discovery = { versionArgs: ["--version"], authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], parseAuth: parseAzureAuth, - implemented: false, + implemented: true, installHint: "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.", } satisfies SourceControlCliDiscoverySpec; + +function toChangeRequest(summary: { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: ChangeRequest["updatedAt"]; +}): ChangeRequest { + return { + provider: "azure-devops", + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state, + updatedAt: summary.updatedAt, + isCrossRepository: false, + }; +} + +function sourceFromInput(input: { + readonly headSelector: string; + readonly source?: SourceControlRefSelector; +}): SourceControlRefSelector | undefined { + if (input.source) { + return input.source; + } + + const match = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); + const owner = match?.[1]?.trim(); + const refName = match?.[2]?.trim(); + return owner && refName ? { owner, refName } : undefined; +} + +export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* () { + const azure = yield* AzureDevOpsCli; + + return SourceControlProvider.of({ + kind: "azure-devops", + listChangeRequests: (input) => { + const source = sourceFromInput(input); + return azure + .listPullRequests({ + cwd: input.cwd, + headSelector: input.headSelector, + ...(source ? { source } : {}), + state: input.state, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((items) => items.map(toChangeRequest)), + Effect.mapError((error) => providerError("listChangeRequests", error)), + ); + }, + getChangeRequest: (input) => + azure.getPullRequest(input).pipe( + Effect.map(toChangeRequest), + Effect.mapError((error) => providerError("getChangeRequest", error)), + ), + createChangeRequest: (input) => { + const source = sourceFromInput(input); + return azure + .createPullRequest({ + cwd: input.cwd, + baseBranch: input.baseRefName, + headSelector: input.headSelector, + ...(source ? { source } : {}), + ...(input.target ? { target: input.target } : {}), + title: input.title, + bodyFile: input.bodyFile, + }) + .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + }, + getRepositoryCloneUrls: (input) => + azure + .getRepositoryCloneUrls(input) + .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + createRepository: (input) => + azure + .createRepository(input) + .pipe(Effect.mapError((error) => providerError("createRepository", error))), + getDefaultBranch: (input) => + azure + .getDefaultBranch({ cwd: input.cwd }) + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + checkoutChangeRequest: (input) => + azure + .checkoutPullRequest({ + cwd: input.cwd, + reference: input.reference, + ...(input.context ? { remoteName: input.context.remoteName } : {}), + }) + .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + }); +}); + +export const layer = Layer.effect(SourceControlProvider, make()); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index e304a3f727..0436f0e127 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -6,6 +6,7 @@ import { VcsProcessSpawnError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { AzureDevOpsCli } from "./AzureDevOpsCli.ts"; import { BitbucketApi, type BitbucketApiShape } from "./BitbucketApi.ts"; import { GitHubCli } from "./GitHubCli.ts"; import { GitLabCli } from "./GitLabCli.ts"; @@ -23,6 +24,7 @@ const sourceControlProviderRegistryTestLayer = (input: { ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( Layer.provide(NodeServices.layer), ), + Layer.mock(AzureDevOpsCli)({}), Layer.mock(BitbucketApi)(input.bitbucket), Layer.mock(GitHubCli)({}), Layer.mock(GitLabCli)({}), @@ -139,7 +141,7 @@ Logged in to github.com account juliusmarminge (keyring) }, { kind: "azure-devops", - implemented: false, + implemented: true, status: "missing", auth: "unknown", account: Option.none(), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 0299142e2e..0fb6d50fe3 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { DateTime, Effect, Layer, Option } from "effect"; +import { AzureDevOpsCli } from "./AzureDevOpsCli.ts"; import { BitbucketApi } from "./BitbucketApi.ts"; import { GitHubCli } from "./GitHubCli.ts"; import { GitLabCli } from "./GitLabCli.ts"; @@ -58,6 +59,7 @@ function makeRegistry(input: { Effect.provide( Layer.mergeAll( registryLayer, + Layer.mock(AzureDevOpsCli)({}), Layer.mock(BitbucketApi)({}), Layer.mock(GitHubCli)({}), Layer.mock(GitLabCli)({}), @@ -118,6 +120,18 @@ it.effect("routes Bitbucket remotes to the Bitbucket provider", () => }), ); +it.effect("routes Azure DevOps remotes to the Azure DevOps provider", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "https://dev.azure.com/acme/project/_git/repo" }], + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "azure-devops"); + }), +); + it.effect("falls back to a non-origin remote when origin is not configured", () => Effect.gen(function* () { const registry = yield* makeRegistry({ diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 9824e16463..e6b0a34050 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -227,6 +227,7 @@ export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () const gitlab = yield* GitLabSourceControlProvider.make(); const bitbucket = yield* BitbucketSourceControlProvider.make(); const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery(); + const azureDevOps = yield* AzureDevOpsSourceControlProvider.make(); return yield* makeWithProviders([ { kind: "github", @@ -240,7 +241,7 @@ export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () }, { kind: "azure-devops", - provider: unsupportedProvider("azure-devops"), + provider: azureDevOps, discovery: AzureDevOpsSourceControlProvider.discovery, }, { diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts new file mode 100644 index 0000000000..48c7a83611 --- /dev/null +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -0,0 +1,106 @@ +import { Cause, DateTime, Exit, Option, Result, Schema } from "effect"; +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; + +export interface NormalizedAzureDevOpsPullRequestRecord { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: Option.Option; +} + +const AzureDevOpsPullRequestSchema = Schema.Struct({ + pullRequestId: PositiveInt, + title: TrimmedNonEmptyString, + url: Schema.optional(Schema.String), + sourceRefName: TrimmedNonEmptyString, + targetRefName: TrimmedNonEmptyString, + status: Schema.String, + creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), + closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), + _links: Schema.optional( + Schema.Struct({ + web: Schema.optional( + Schema.Struct({ + href: Schema.String, + }), + ), + }), + ), +}); + +function trimOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeRefName(refName: string): string { + return refName.trim().replace(/^refs\/heads\//, ""); +} + +function normalizeAzureDevOpsPullRequestState(status: string): "open" | "closed" | "merged" { + switch (status.trim().toLowerCase()) { + case "completed": + return "merged"; + case "abandoned": + return "closed"; + default: + return "open"; + } +} + +function normalizeAzureDevOpsPullRequestRecord( + raw: Schema.Schema.Type, +): NormalizedAzureDevOpsPullRequestRecord { + return { + number: raw.pullRequestId, + title: raw.title, + url: trimOptionalString(raw._links?.web?.href) ?? trimOptionalString(raw.url) ?? "", + baseRefName: normalizeRefName(raw.targetRefName), + headRefName: normalizeRefName(raw.sourceRefName), + state: normalizeAzureDevOpsPullRequestState(raw.status), + updatedAt: (raw.closedDate ?? Option.none()).pipe( + Option.orElse(() => raw.creationDate ?? Option.none()), + ), + }; +} + +const decodeAzureDevOpsPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeAzureDevOpsPullRequest = decodeJsonResult(AzureDevOpsPullRequestSchema); +const decodeAzureDevOpsPullRequestEntry = Schema.decodeUnknownExit(AzureDevOpsPullRequestSchema); + +export const formatAzureDevOpsJsonDecodeError = formatSchemaError; + +export function decodeAzureDevOpsPullRequestListJson( + raw: string, +): Result.Result< + ReadonlyArray, + Cause.Cause +> { + const result = decodeAzureDevOpsPullRequestList(raw); + if (Result.isSuccess(result)) { + const pullRequests: NormalizedAzureDevOpsPullRequestRecord[] = []; + for (const entry of result.success) { + const decodedEntry = decodeAzureDevOpsPullRequestEntry(entry); + if (Exit.isFailure(decodedEntry)) { + continue; + } + pullRequests.push(normalizeAzureDevOpsPullRequestRecord(decodedEntry.value)); + } + return Result.succeed(pullRequests); + } + return Result.fail(result.failure); +} + +export function decodeAzureDevOpsPullRequestJson( + raw: string, +): Result.Result> { + const result = decodeAzureDevOpsPullRequest(raw); + if (Result.isSuccess(result)) { + return Result.succeed(normalizeAzureDevOpsPullRequestRecord(result.success)); + } + return Result.fail(result.failure); +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 67df52ddc2..faeab9fbb7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -56,6 +56,7 @@ import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; import { ServerAuth } from "./auth/Services/ServerAuth.ts"; import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; +import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; @@ -1141,7 +1142,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( Layer.provide( SourceControlProviderRegistry.layer.pipe( Layer.provide( - Layer.mergeAll(BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer), + Layer.mergeAll( + AzureDevOpsCli.layer, + BitbucketApi.layer, + GitHubCli.layer, + GitLabCli.layer, + ), ), Layer.provideMerge(GitVcsDriver.layer), Layer.provide( diff --git a/apps/web/src/pullRequestReference.test.ts b/apps/web/src/pullRequestReference.test.ts index 2067c41cc1..9157061a30 100644 --- a/apps/web/src/pullRequestReference.test.ts +++ b/apps/web/src/pullRequestReference.test.ts @@ -57,6 +57,10 @@ describe("parsePullRequestReference", () => { expect(parsePullRequestReference("az repos pr checkout --id 42")).toBe("42"); }); + it("accepts az repos pr checkout commands with equals-style ids", () => { + expect(parsePullRequestReference("az repos pr checkout --id=42")).toBe("42"); + }); + it("accepts az repos pr checkout commands with extra flags", () => { expect(parsePullRequestReference("az repos pr checkout --id 42 --remote-name origin")).toBe( "42", diff --git a/apps/web/src/pullRequestReference.ts b/apps/web/src/pullRequestReference.ts index 93ae7caa0f..b919e736cc 100644 --- a/apps/web/src/pullRequestReference.ts +++ b/apps/web/src/pullRequestReference.ts @@ -11,9 +11,13 @@ const AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN = /^az\s+repos\s+pr\s+checkout\s+(.+) function parseAzureDevOpsCheckoutReference(args: string): string | null { const parts = args.trim().split(/\s+/).filter(Boolean); - const idFlagIndex = parts.findIndex((part) => part === "--id" || part === "-i"); - if (idFlagIndex >= 0) { - return parts[idFlagIndex + 1] ?? null; + for (const [index, part] of parts.entries()) { + if (part === "--id" || part === "-i") { + return parts[index + 1] ?? null; + } + if (part.startsWith("--id=")) { + return part.slice("--id=".length) || null; + } } return parts.find((part) => !part.startsWith("-")) ?? null; } diff --git a/docs/source-control-providers.md b/docs/source-control-providers.md index 36dd7234ec..ccdfd705a3 100644 --- a/docs/source-control-providers.md +++ b/docs/source-control-providers.md @@ -8,6 +8,7 @@ This guide covers the providers currently supported by T3 Code: - GitHub - GitLab - Bitbucket +- Azure DevOps ## What Provider Support Enables @@ -172,6 +173,57 @@ Bitbucket workspace billing and repository permissions can affect whether Git pu If pull request creation works but pushing fails, check the repository permissions, workspace plan, and whether your Git remote uses HTTPS credentials or SSH. +## Azure DevOps + +Azure DevOps support uses Azure CLI with the Azure DevOps extension. + +### Requirements + +Install Azure CLI: + +```bash +brew install azure-cli +``` + +Then install the Azure DevOps extension: + +```bash +az extension add --name azure-devops +``` + +### Sign In + +Run: + +```bash +az login +``` + +Follow the browser login flow for the Azure account that has access to your Azure DevOps +organization and project. + +To verify the login: + +```bash +az account show --query user.name -o tsv +``` + +T3 Code uses Azure CLI to check your sign-in status and show the signed-in account in Source +Control settings. + +### Notes + +Azure DevOps repository remotes usually look like one of these: + +```text +https://dev.azure.com/organization/project/_git/repository +git@ssh.dev.azure.com:v3/organization/project/repository +``` + +Make sure Azure CLI can detect the organization and project for the repository you are working in. +If your team uses SSH for Git operations, your Azure DevOps SSH key must be configured separately +from `az login`. + ## Version Control Requirements Source control providers work with your local version control setup. From 7cf2c5fecf110ecdffc2d2501567ed30ad221df7 Mon Sep 17 00:00:00 2001 From: Julius Date: Sun, 3 May 2026 22:10:23 -0700 Subject: [PATCH 14/24] refactor(source control): streamline imports and enhance type usage across VCS modules - Consolidated import statements for VCS-related modules to improve readability. - Updated type references to use the new structure for VCS driver shapes and related services. - Ensured consistency in the usage of layer mocks and type definitions across various source control providers, including GitHub, Bitbucket, and Azure DevOps. - Enhanced type safety in function signatures by utilizing the updated types from the respective modules. --- .../server/src/git/GitWorkflowService.test.ts | 37 +- apps/server/src/server.test.ts | 47 +- .../src/sourceControl/AzureDevOpsCli.test.ts | 11 +- .../src/sourceControl/AzureDevOpsCli.ts | 43 +- .../AzureDevOpsSourceControlProvider.test.ts | 9 +- .../AzureDevOpsSourceControlProvider.ts | 45 +- .../src/sourceControl/BitbucketApi.test.ts | 45 +- apps/server/src/sourceControl/BitbucketApi.ts | 85 +- .../BitbucketSourceControlProvider.test.ts | 11 +- .../BitbucketSourceControlProvider.ts | 31 +- .../src/sourceControl/GitHubCli.test.ts | 14 +- apps/server/src/sourceControl/GitHubCli.ts | 20 +- .../GitHubSourceControlProvider.test.ts | 14 +- .../GitHubSourceControlProvider.ts | 172 +-- .../src/sourceControl/GitLabCli.test.ts | 11 +- apps/server/src/sourceControl/GitLabCli.ts | 34 +- .../GitLabSourceControlProvider.test.ts | 12 +- .../GitLabSourceControlProvider.ts | 55 +- .../SourceControlDiscovery.test.ts | 32 +- .../sourceControl/SourceControlDiscovery.ts | 14 +- .../SourceControlProviderRegistry.test.ts | 30 +- .../SourceControlProviderRegistry.ts | 56 +- .../SourceControlRepositoryService.test.ts | 38 +- .../SourceControlRepositoryService.ts | 14 +- apps/server/src/vcs/GitVcsDriver.ts | 220 ++-- apps/server/src/vcs/GitVcsDriverCore.test.ts | 3 +- apps/server/src/vcs/GitVcsDriverCore.ts | 1153 ++++++++--------- apps/server/src/vcs/VcsDriver.ts | 6 +- apps/server/src/vcs/VcsDriverRegistry.test.ts | 29 +- apps/server/src/vcs/VcsDriverRegistry.ts | 10 +- apps/server/src/vcs/VcsProjectConfig.test.ts | 13 +- .../src/vcs/VcsProvisioningService.test.ts | 20 +- apps/server/src/vcs/VcsProvisioningService.ts | 4 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 38 +- apps/server/src/vcs/VcsStatusBroadcaster.ts | 6 +- .../vcs/testing/VcsDriverContractHarness.ts | 35 +- 36 files changed, 1211 insertions(+), 1206 deletions(-) diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index bc0624beaf..dd7273b40c 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -1,28 +1,27 @@ -import { assert, it } from "@effect/vitest"; +import { assert, describe, it, vi } from "@effect/vitest"; import { Effect, Layer } from "effect"; -import { describe, vi } from "vitest"; -import { GitManager } from "./GitManager.ts"; -import { GitWorkflowService, layer as GitWorkflowServiceLayer } from "./GitWorkflowService.ts"; -import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; -import { VcsDriverRegistry, type VcsDriverRegistryShape } from "../vcs/VcsDriverRegistry.ts"; +import * as GitManager from "./GitManager.ts"; +import * as GitWorkflowService from "./GitWorkflowService.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; -function makeLayer(input: { readonly detect: VcsDriverRegistryShape["detect"] }) { - return GitWorkflowServiceLayer.pipe( +function makeLayer(input: { readonly detect: VcsDriverRegistry.VcsDriverRegistryShape["detect"] }) { + return GitWorkflowService.layer.pipe( Layer.provide( - Layer.mock(VcsDriverRegistry)({ + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ detect: input.detect, }), ), - Layer.provide(Layer.mock(GitVcsDriver)({})), - Layer.provide(Layer.mock(GitManager)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(GitManager.GitManager)({})), ); } describe("GitWorkflowService", () => { it.effect("returns an empty local status when no VCS repository is detected", () => Effect.gen(function* () { - const workflow = yield* GitWorkflowService; + const workflow = yield* GitWorkflowService.GitWorkflowService; const status = yield* workflow.localStatus({ cwd: "/not-a-repo" }); assert.deepStrictEqual(status, { @@ -48,7 +47,7 @@ describe("GitWorkflowService", () => { it.effect("returns an empty full status when no VCS repository is detected", () => Effect.gen(function* () { - const workflow = yield* GitWorkflowService; + const workflow = yield* GitWorkflowService.GitWorkflowService; const status = yield* workflow.status({ cwd: "/not-a-repo" }); assert.deepStrictEqual(status, { @@ -82,15 +81,15 @@ describe("GitWorkflowService", () => { const remoteStatus = vi.fn(); const status = vi.fn(); - const testLayer = GitWorkflowServiceLayer.pipe( + const testLayer = GitWorkflowService.layer.pipe( Layer.provide( - Layer.mock(VcsDriverRegistry)({ + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ detect: () => Effect.succeed(null), }), ), - Layer.provide(Layer.mock(GitVcsDriver)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), Layer.provide( - Layer.mock(GitManager)({ + Layer.mock(GitManager.GitManager)({ localStatus, remoteStatus, status, @@ -99,7 +98,7 @@ describe("GitWorkflowService", () => { ); return Effect.gen(function* () { - const workflow = yield* GitWorkflowService; + const workflow = yield* GitWorkflowService.GitWorkflowService; yield* workflow.localStatus({ cwd: "/not-a-repo" }); yield* workflow.remoteStatus({ cwd: "/not-a-repo" }); yield* workflow.status({ cwd: "/not-a-repo" }); @@ -112,7 +111,7 @@ describe("GitWorkflowService", () => { it.effect("returns an empty ref list when no VCS repository is detected", () => Effect.gen(function* () { - const workflow = yield* GitWorkflowService; + const workflow = yield* GitWorkflowService.GitWorkflowService; const refs = yield* workflow.listRefs({ cwd: "/not-a-repo" }); assert.deepStrictEqual(refs, { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index dbe90e63ad..c2d97d9d4c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -104,23 +104,12 @@ import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; -import type { VcsDriverShape } from "./vcs/VcsDriver.ts"; -import { - VcsStatusBroadcaster, - type VcsStatusBroadcasterShape, - layer as VcsStatusBroadcasterLayer, -} from "./vcs/VcsStatusBroadcaster.ts"; -import { - VcsDriverRegistry, - type VcsDriverRegistryShape, - type VcsDriverHandle, -} from "./vcs/VcsDriverRegistry.ts"; -import { layer as VcsProvisioningServiceLayer } from "./vcs/VcsProvisioningService.ts"; -import { layer as GitWorkflowServiceLayer } from "./git/GitWorkflowService.ts"; -import { - SourceControlRepositoryService, - type SourceControlRepositoryServiceShape, -} from "./sourceControl/SourceControlRepositoryService.ts"; +import * as VcsDriver from "./vcs/VcsDriver.ts"; +import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; +import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; +import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; @@ -330,12 +319,12 @@ const buildAppUnderTest = (options?: { providerRegistry?: Partial; serverSettings?: Partial; open?: Partial; - vcsDriver?: Partial; - vcsDriverRegistry?: Partial; + vcsDriver?: Partial; + vcsDriverRegistry?: Partial; gitVcsDriver?: Partial; gitManager?: Partial; - sourceControlRepositoryService?: Partial; - vcsStatusBroadcaster?: Partial; + sourceControlRepositoryService?: Partial; + vcsStatusBroadcaster?: Partial; projectSetupScriptRunner?: Partial; terminalManager?: Partial; orchestrationEngine?: Partial; @@ -381,7 +370,7 @@ const buildAppUnderTest = (options?: { ...options?.config, }; const layerConfig = Layer.succeed(ServerConfig, config); - const defaultVcsDriver: VcsDriverShape = { + const defaultVcsDriver: VcsDriver.VcsDriverShape = { capabilities: { kind: "git", supportsWorktrees: true, @@ -423,7 +412,7 @@ const buildAppUnderTest = (options?: { initRepository: () => Effect.void, ...options?.layers?.vcsDriver, }; - const vcsDriverRegistryLayer = Layer.mock(VcsDriverRegistry)({ + const vcsDriverRegistryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ get: () => Effect.succeed(defaultVcsDriver), detect: (input) => defaultVcsDriver.detectRepository(input.cwd).pipe( @@ -453,7 +442,7 @@ const buildAppUnderTest = (options?: { kind: repository.kind, repository, driver: defaultVcsDriver, - } satisfies VcsDriverHandle) + } satisfies VcsDriverRegistry.VcsDriverHandle) : null, ), ), @@ -495,19 +484,19 @@ const buildAppUnderTest = (options?: { ), ProjectFaviconResolverLive, ); - const gitWorkflowLayer = GitWorkflowServiceLayer.pipe( + const gitWorkflowLayer = GitWorkflowService.layer.pipe( Layer.provideMerge(vcsDriverRegistryLayer), Layer.provideMerge(gitVcsDriverLayer), Layer.provideMerge(gitManagerLayer), ); - const vcsProvisioningLayer = VcsProvisioningServiceLayer.pipe( + const vcsProvisioningLayer = VcsProvisioningService.layer.pipe( Layer.provide(vcsDriverRegistryLayer), ); const vcsStatusBroadcasterLayer = options?.layers?.vcsStatusBroadcaster - ? Layer.mock(VcsStatusBroadcaster)({ + ? Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ ...options.layers.vcsStatusBroadcaster, }) - : VcsStatusBroadcasterLayer.pipe(Layer.provide(gitWorkflowLayer)); + : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer)); const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, @@ -551,7 +540,7 @@ const buildAppUnderTest = (options?: { Layer.provide(gitWorkflowLayer), Layer.provide(vcsProvisioningLayer), Layer.provide( - Layer.mock(SourceControlRepositoryService)({ + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ ...options?.layers?.sourceControlRepositoryService, }), ), diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 500ec7bc6a..406c9772f3 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -1,14 +1,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; import { Effect, FileSystem, Layer, Option } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { afterEach, describe, expect, vi } from "vitest"; import type { VcsError } from "@t3tools/contracts"; -import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; -const processOutput = (stdout: string): VcsProcessOutput => ({ +const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ exitCode: ChildProcessSpawner.ExitCode(0), stdout, stderr: "", @@ -16,10 +15,10 @@ const processOutput = (stdout: string): VcsProcessOutput => ({ stderrTruncated: false, }); -const mockRun = vi.fn<(input: VcsProcessInput) => Effect.Effect>(); +const mockRun = vi.fn(); const supportLayer = Layer.mergeAll( - Layer.mock(VcsProcess)({ + Layer.mock(VcsProcess.VcsProcess)({ run: mockRun, }), NodeServices.layer, diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index 0699b94c88..d8e8c077a0 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -5,14 +5,9 @@ import { type VcsError, } from "@t3tools/contracts"; -import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; -import { - decodeAzureDevOpsPullRequestJson, - decodeAzureDevOpsPullRequestListJson, - formatAzureDevOpsJsonDecodeError, - type NormalizedAzureDevOpsPullRequestRecord, -} from "./azureDevOpsPullRequests.ts"; -import type { SourceControlRefSelector } from "./SourceControlProvider.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as AzureDevOpsPullRequests from "./azureDevOpsPullRequests.ts"; +import type * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -40,20 +35,26 @@ export interface AzureDevOpsCliShape { readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly listPullRequests: (input: { readonly cwd: string; readonly headSelector: string; - readonly source?: SourceControlRefSelector; + readonly source?: SourceControlProvider.SourceControlRefSelector; readonly state: "open" | "closed" | "merged" | "all"; readonly limit?: number; - }) => Effect.Effect, AzureDevOpsCliError>; + }) => Effect.Effect< + ReadonlyArray, + AzureDevOpsCliError + >; readonly getPullRequest: (input: { readonly cwd: string; readonly reference: string; - }) => Effect.Effect; + }) => Effect.Effect< + AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord, + AzureDevOpsCliError + >; readonly getRepositoryCloneUrls: (input: { readonly cwd: string; @@ -70,8 +71,8 @@ export interface AzureDevOpsCliShape { readonly cwd: string; readonly baseBranch: string; readonly headSelector: string; - readonly source?: SourceControlRefSelector; - readonly target?: SourceControlRefSelector; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; readonly title: string; readonly bodyFile: string; }) => Effect.Effect; @@ -164,7 +165,7 @@ function normalizeSourceBranch(headSelector: string): string { function sourceBranch(input: { readonly headSelector: string; - readonly source?: SourceControlRefSelector; + readonly source?: SourceControlProvider.SourceControlRefSelector; }): string { return input.source?.refName ?? normalizeSourceBranch(input.headSelector); } @@ -244,7 +245,7 @@ function decodeAzureDevOpsJson( } export const make = Effect.fn("makeAzureDevOpsCli")(function* () { - const process = yield* VcsProcess; + const process = yield* VcsProcess.VcsProcess; const execute: AzureDevOpsCliShape["execute"] = (input) => process @@ -286,13 +287,15 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : Effect.sync(() => decodeAzureDevOpsPullRequestListJson(raw)).pipe( + : Effect.sync(() => + AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestListJson(raw), + ).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new AzureDevOpsCliError({ operation: "listPullRequests", - detail: `Azure DevOps CLI returned invalid PR list JSON: ${formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + detail: `Azure DevOps CLI returned invalid PR list JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -318,13 +321,13 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - Effect.sync(() => decodeAzureDevOpsPullRequestJson(raw)).pipe( + Effect.sync(() => AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new AzureDevOpsCliError({ operation: "getPullRequest", - detail: `Azure DevOps CLI returned invalid pull request JSON: ${formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + detail: `Azure DevOps CLI returned invalid pull request JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index d1a56b6ca4..c46bc0ee7f 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -1,12 +1,12 @@ import { assert, it } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; -import { AzureDevOpsCli, type AzureDevOpsCliShape } from "./AzureDevOpsCli.ts"; +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; -function makeProvider(azure: Partial) { +function makeProvider(azure: Partial) { return AzureDevOpsSourceControlProvider.make().pipe( - Effect.provide(Layer.mock(AzureDevOpsCli)(azure)), + Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)), ); } @@ -46,7 +46,8 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" it.effect("creates Azure DevOps PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = null; + let createInput: Parameters[0] | null = + null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index ee1ae8faa6..0321f4170b 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -1,17 +1,14 @@ import { Effect, Layer } from "effect"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; -import { AzureDevOpsCli, type AzureDevOpsCliError } from "./AzureDevOpsCli.ts"; -import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts"; -import { - combinedAuthOutput, - firstSafeAuthLine, - providerAuth, - type SourceControlAuthProbeInput, - type SourceControlCliDiscoverySpec, -} from "./SourceControlProviderDiscovery.ts"; +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; -function providerError(operation: string, cause: AzureDevOpsCliError): SourceControlProviderError { +function providerError( + operation: string, + cause: AzureDevOpsCli.AzureDevOpsCliError, +): SourceControlProviderError { return new SourceControlProviderError({ provider: "azure-devops", operation, @@ -20,22 +17,28 @@ function providerError(operation: string, cause: AzureDevOpsCliError): SourceCon }); } -function parseAzureAuth(input: SourceControlAuthProbeInput) { +function parseAzureAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); if (input.exitCode !== 0) { - return providerAuth({ + return SourceControlProviderDiscovery.providerAuth({ status: "unauthenticated", detail: - firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.", + SourceControlProviderDiscovery.firstSafeAuthLine( + SourceControlProviderDiscovery.combinedAuthOutput(input), + ) ?? "Run `az login` to authenticate Azure CLI.", }); } if (account && account.length > 0) { - return providerAuth({ status: "authenticated", account, host: "dev.azure.com" }); + return SourceControlProviderDiscovery.providerAuth({ + status: "authenticated", + account, + host: "dev.azure.com", + }); } - return providerAuth({ + return SourceControlProviderDiscovery.providerAuth({ status: "unknown", host: "dev.azure.com", detail: "Azure CLI account status could not be parsed.", @@ -53,7 +56,7 @@ export const discovery = { implemented: true, installHint: "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.", -} satisfies SourceControlCliDiscoverySpec; +} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; function toChangeRequest(summary: { readonly number: number; @@ -79,8 +82,8 @@ function toChangeRequest(summary: { function sourceFromInput(input: { readonly headSelector: string; - readonly source?: SourceControlRefSelector; -}): SourceControlRefSelector | undefined { + readonly source?: SourceControlProvider.SourceControlRefSelector; +}): SourceControlProvider.SourceControlRefSelector | undefined { if (input.source) { return input.source; } @@ -92,9 +95,9 @@ function sourceFromInput(input: { } export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* () { - const azure = yield* AzureDevOpsCli; + const azure = yield* AzureDevOpsCli.AzureDevOpsCli; - return SourceControlProvider.of({ + return SourceControlProvider.SourceControlProvider.of({ kind: "azure-devops", listChangeRequests: (input) => { const source = sourceFromInput(input); @@ -153,4 +156,4 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* }); }); -export const layer = Layer.effect(SourceControlProvider, make()); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 69c7bcea55..5542ea4092 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -1,13 +1,12 @@ -import { assert, it } from "@effect/vitest"; +import { assert, it, vi } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ConfigProvider, DateTime, Effect, FileSystem, Layer, Option } from "effect"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; -import { vi } from "vitest"; import * as BitbucketApi from "./BitbucketApi.ts"; -import { GitVcsDriver, type GitVcsDriverShape } from "../vcs/GitVcsDriver.ts"; -import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; -import type { VcsDriverShape } from "../vcs/VcsDriver.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import type * as VcsDriver from "../vcs/VcsDriver.ts"; const bitbucketPullRequest = { id: 42, @@ -49,35 +48,41 @@ const repositoryJson = { function makeLayer(input: { readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; - readonly git?: Partial; + readonly git?: Partial; }) { const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), ); const gitMock = { - readConfigValue: vi.fn(() => + readConfigValue: vi.fn(() => Effect.succeed("git@bitbucket.org:pingdotgg/t3code.git"), ), - resolvePrimaryRemoteName: vi.fn(() => - Effect.succeed("origin"), + resolvePrimaryRemoteName: vi.fn( + () => Effect.succeed("origin"), ), - ensureRemote: vi.fn(() => Effect.succeed("octocat")), - fetchRemoteBranch: vi.fn(() => Effect.void), - fetchRemoteTrackingBranch: vi.fn( + ensureRemote: vi.fn(() => + Effect.succeed("octocat"), + ), + fetchRemoteBranch: vi.fn( + () => Effect.void, + ), + fetchRemoteTrackingBranch: vi.fn( + () => Effect.void, + ), + setBranchUpstream: vi.fn( () => Effect.void, ), - setBranchUpstream: vi.fn(() => Effect.void), - switchRef: vi.fn((request) => + switchRef: vi.fn((request) => Effect.succeed({ refName: request.refName }), ), - listLocalBranchNames: vi.fn(() => + listLocalBranchNames: vi.fn(() => Effect.succeed([]), ), }; const git = { ...gitMock, ...input.git, - } satisfies Partial; + } satisfies Partial; const driver = { listRemotes: () => @@ -96,7 +101,7 @@ function makeLayer(input: { expiresAt: Option.none(), }, }), - } satisfies Partial; + } satisfies Partial; const layer = BitbucketApi.layer.pipe( Layer.provide( @@ -106,7 +111,7 @@ function makeLayer(input: { ), ), Layer.provide( - Layer.mock(VcsDriverRegistry)({ + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ resolve: () => Effect.succeed({ kind: "git", @@ -120,11 +125,11 @@ function makeLayer(input: { expiresAt: Option.none(), }, }, - driver: driver as unknown as VcsDriverShape, + driver: driver as unknown as VcsDriver.VcsDriverShape, }), }), ), - Layer.provide(Layer.mock(GitVcsDriver)(git)), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(git)), Layer.provide( ConfigProvider.layer( ConfigProvider.fromEnv({ diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 1ea93678fc..de6d75a634 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -9,19 +9,10 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import { sanitizeBranchFragment } from "@t3tools/shared/git"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import { - BitbucketPullRequestListSchema, - BitbucketPullRequestSchema, - normalizeBitbucketPullRequestRecord, - type NormalizedBitbucketPullRequestRecord, -} from "./bitbucketPullRequests.ts"; -import type { - SourceControlProviderContext, - SourceControlRefSelector, -} from "./SourceControlProvider.ts"; -import { parseSourceControlOwnerRef } from "./SourceControlProvider.ts"; -import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; -import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; +import * as BitbucketPullRequests from "./bitbucketPullRequests.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; @@ -89,20 +80,26 @@ export interface BitbucketApiShape { readonly probeAuth: Effect.Effect; readonly listPullRequests: (input: { readonly cwd: string; - readonly context?: SourceControlProviderContext; + readonly context?: SourceControlProvider.SourceControlProviderContext; readonly headSelector: string; - readonly source?: SourceControlRefSelector; + readonly source?: SourceControlProvider.SourceControlRefSelector; readonly state: "open" | "closed" | "merged" | "all"; readonly limit?: number; - }) => Effect.Effect, BitbucketApiError>; + }) => Effect.Effect< + ReadonlyArray, + BitbucketApiError + >; readonly getPullRequest: (input: { readonly cwd: string; - readonly context?: SourceControlProviderContext; + readonly context?: SourceControlProvider.SourceControlProviderContext; readonly reference: string; - }) => Effect.Effect; + }) => Effect.Effect< + BitbucketPullRequests.NormalizedBitbucketPullRequestRecord, + BitbucketApiError + >; readonly getRepositoryCloneUrls: (input: { readonly cwd: string; - readonly context?: SourceControlProviderContext; + readonly context?: SourceControlProvider.SourceControlProviderContext; readonly repository: string; }) => Effect.Effect; readonly createRepository: (input: { @@ -112,21 +109,21 @@ export interface BitbucketApiShape { }) => Effect.Effect; readonly createPullRequest: (input: { readonly cwd: string; - readonly context?: SourceControlProviderContext; + readonly context?: SourceControlProvider.SourceControlProviderContext; readonly baseBranch: string; readonly headSelector: string; - readonly source?: SourceControlRefSelector; - readonly target?: SourceControlRefSelector; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; readonly title: string; readonly bodyFile: string; }) => Effect.Effect; readonly getDefaultBranch: (input: { readonly cwd: string; - readonly context?: SourceControlProviderContext; + readonly context?: SourceControlProvider.SourceControlProviderContext; }) => Effect.Effect; readonly checkoutPullRequest: (input: { readonly cwd: string; - readonly context?: SourceControlProviderContext; + readonly context?: SourceControlProvider.SourceControlProviderContext; readonly reference: string; readonly force?: boolean; }) => Effect.Effect; @@ -150,22 +147,24 @@ function normalizeChangeRequestId(reference: string): string { } function normalizeSourceBranch(headSelector: string): string { - return parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim(); + return ( + SourceControlProvider.parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim() + ); } function sourceBranch(input: { readonly headSelector: string; - readonly source?: SourceControlRefSelector; + readonly source?: SourceControlProvider.SourceControlRefSelector; }): string { return input.source?.refName ?? normalizeSourceBranch(input.headSelector); } function sourceWorkspace(input: { readonly headSelector: string; - readonly source?: SourceControlRefSelector; + readonly source?: SourceControlProvider.SourceControlRefSelector; }): string | undefined { if (input.source?.owner) return input.source.owner; - return parseSourceControlOwnerRef(input.headSelector)?.owner; + return SourceControlProvider.parseSourceControlOwnerRef(input.headSelector)?.owner; } function toBitbucketStates(state: "open" | "closed" | "merged" | "all"): ReadonlyArray { @@ -271,7 +270,9 @@ function checkoutBranchName(input: { } function repositoryNameWithOwner( - repository: Schema.Schema.Type["source"]["repository"], + repository: Schema.Schema.Type< + typeof BitbucketPullRequests.BitbucketPullRequestSchema + >["source"]["repository"], ): string | null { const fullName = repository?.full_name?.trim() ?? ""; return fullName.length > 0 ? fullName : null; @@ -349,8 +350,8 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { const config = yield* BitbucketApiEnvConfig; const httpClient = yield* HttpClient.HttpClient; const fileSystem = yield* FileSystem.FileSystem; - const git = yield* GitVcsDriver; - const vcsRegistry = yield* VcsDriverRegistry; + const git = yield* GitVcsDriver.GitVcsDriver; + const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; @@ -396,7 +397,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { const resolveRepository = Effect.fn("BitbucketApi.resolveRepository")(function* (input: { readonly cwd: string; - readonly context?: SourceControlProviderContext; + readonly context?: SourceControlProvider.SourceControlProviderContext; readonly repository?: string; }) { const fromRepository = @@ -444,7 +445,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { const getRepository = (input: { readonly cwd: string; - readonly context?: SourceControlProviderContext; + readonly context?: SourceControlProvider.SourceControlProviderContext; readonly repository?: string; }) => resolveRepository(input).pipe( @@ -472,12 +473,12 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`, ), ), - BitbucketPullRequestSchema, + BitbucketPullRequests.BitbucketPullRequestSchema, ); const getRawPullRequest = (input: { readonly cwd: string; - readonly context?: SourceControlProviderContext; + readonly context?: SourceControlProvider.SourceControlProviderContext; readonly reference: string; }) => resolveRepository(input).pipe( @@ -489,7 +490,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { const resolveCheckoutRemote = Effect.fn("BitbucketApi.resolveCheckoutRemote")(function* (input: { readonly cwd: string; - readonly context?: SourceControlProviderContext; + readonly context?: SourceControlProvider.SourceControlProviderContext; readonly destinationRepository: BitbucketRepositoryLocator; readonly sourceRepositoryName: string; readonly isCrossRepository: boolean; @@ -560,13 +561,17 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { ), { urlParams: query }, ), - BitbucketPullRequestListSchema, + BitbucketPullRequests.BitbucketPullRequestListSchema, ); }), - Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)), + Effect.map((list) => + list.values.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord), + ), ), getPullRequest: (input) => - getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord)), + getRawPullRequest(input).pipe( + Effect.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord), + ), getRepositoryCloneUrls: (input) => getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), createRepository: (input) => @@ -632,7 +637,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, ), ).pipe(HttpClientRequest.bodyJsonUnsafe(body)), - BitbucketPullRequestSchema, + BitbucketPullRequests.BitbucketPullRequestSchema, ); }), getDefaultBranch: (input) => diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index 80cc29383b..4bf658f568 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -1,12 +1,12 @@ import { assert, it } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; -import { BitbucketApi, type BitbucketApiShape } from "./BitbucketApi.ts"; +import * as BitbucketApi from "./BitbucketApi.ts"; import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; -function makeProvider(bitbucket: Partial) { +function makeProvider(bitbucket: Partial) { return BitbucketSourceControlProvider.make().pipe( - Effect.provide(Layer.mock(BitbucketApi)(bitbucket)), + Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)), ); } @@ -51,7 +51,7 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", ( it.effect("lists Bitbucket PRs through provider-neutral input names", () => Effect.gen(function* () { - let listInput: Parameters[0] | null = null; + let listInput: Parameters[0] | null = null; const provider = yield* makeProvider({ listPullRequests: (input) => { listInput = input; @@ -77,7 +77,8 @@ it.effect("lists Bitbucket PRs through provider-neutral input names", () => it.effect("creates Bitbucket PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = null; + let createInput: Parameters[0] | null = + null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index 56b0f1437f..e3fe337e74 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -1,12 +1,15 @@ import { Effect, Layer, Option } from "effect"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; -import { BitbucketApi, type BitbucketApiError } from "./BitbucketApi.ts"; -import { SourceControlProvider, sourceControlRefFromInput } from "./SourceControlProvider.ts"; -import type { SourceControlApiDiscoverySpec } from "./SourceControlProviderDiscovery.ts"; -import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts"; +import * as BitbucketApi from "./BitbucketApi.ts"; +import * as BitbucketPullRequests from "./bitbucketPullRequests.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import type * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; -function providerError(operation: string, cause: BitbucketApiError): SourceControlProviderError { +function providerError( + operation: string, + cause: BitbucketApi.BitbucketApiError, +): SourceControlProviderError { return new SourceControlProviderError({ provider: "bitbucket", operation, @@ -15,7 +18,9 @@ function providerError(operation: string, cause: BitbucketApiError): SourceContr }); } -function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { +function toChangeRequest( + summary: BitbucketPullRequests.NormalizedBitbucketPullRequestRecord, +): ChangeRequest { return { provider: "bitbucket", number: summary.number, @@ -38,12 +43,12 @@ function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeR } export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () { - const bitbucket = yield* BitbucketApi; + const bitbucket = yield* BitbucketApi.BitbucketApi; - return SourceControlProvider.of({ + return SourceControlProvider.SourceControlProvider.of({ kind: "bitbucket", listChangeRequests: (input) => { - const source = sourceControlRefFromInput(input); + const source = SourceControlProvider.sourceControlRefFromInput(input); return bitbucket .listPullRequests({ cwd: input.cwd, @@ -64,7 +69,7 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () Effect.mapError((error) => providerError("getChangeRequest", error)), ), createChangeRequest: (input) => { - const source = sourceControlRefFromInput(input); + const source = SourceControlProvider.sourceControlRefFromInput(input); return bitbucket .createPullRequest({ cwd: input.cwd, @@ -105,10 +110,10 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () }); }); -export const layer = Layer.effect(SourceControlProvider, make()); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); export const makeDiscovery = Effect.fn("makeBitbucketSourceControlProviderDiscovery")(function* () { - const bitbucket = yield* BitbucketApi; + const bitbucket = yield* BitbucketApi.BitbucketApi; return { type: "api", @@ -119,5 +124,5 @@ export const makeDiscovery = Effect.fn("makeBitbucketSourceControlProviderDiscov installHint: "Create a Bitbucket API token with pull request/repository scopes, then set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN.", probeAuth: bitbucket.probeAuth, - } satisfies SourceControlApiDiscoverySpec; + } satisfies SourceControlProviderDiscovery.SourceControlApiDiscoverySpec; }); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 7f1ac44152..29a24bb49d 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -1,13 +1,12 @@ -import { assert, it } from "@effect/vitest"; +import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; import { Effect, Layer } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsProcessExitError, type VcsError } from "@t3tools/contracts"; -import { afterEach, describe, expect, vi } from "vitest"; -import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; -const processOutput = (stdout: string): VcsProcessOutput => ({ +const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ exitCode: ChildProcessSpawner.ExitCode(0), stdout, stderr: "", @@ -15,11 +14,14 @@ const processOutput = (stdout: string): VcsProcessOutput => ({ stderrTruncated: false, }); -const mockRun = vi.fn<(input: VcsProcessInput) => Effect.Effect>(); +const mockRun = + vi.fn< + VcsProcess.VcsProcessShape["run"] + >(); const layer = GitHubCli.layer.pipe( Layer.provide( - Layer.mock(VcsProcess)({ + Layer.mock(VcsProcess.VcsProcess)({ run: mockRun, }), ), diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index b646613853..fe83d41ef4 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -6,12 +6,8 @@ import { type VcsError, } from "@t3tools/contracts"; -import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; -import { - decodeGitHubPullRequestJson, - decodeGitHubPullRequestListJson, - formatGitHubJsonDecodeError, -} from "./gitHubPullRequests.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubPullRequests from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -48,7 +44,7 @@ export interface GitHubCliShape { readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly listOpenPullRequests: (input: { readonly cwd: string; @@ -226,7 +222,7 @@ function decodeGitHubJson( } export const make = Effect.fn("makeGitHubCli")(function* () { - const process = yield* VcsProcess; + const process = yield* VcsProcess.VcsProcess; const execute: GitHubCliShape["execute"] = (input) => process @@ -261,13 +257,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( + : Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new GitHubCliError({ operation: "listOpenPullRequests", - detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, + detail: `GitHub CLI returned invalid PR list JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -293,13 +289,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( + Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new GitHubCliError({ operation: "getPullRequest", - detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, + detail: `GitHub CLI returned invalid pull request JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index ac3de1d974..3c4ad5ac47 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -2,11 +2,11 @@ import { assert, it } from "@effect/vitest"; import { DateTime, Effect, Layer, Option } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { GitHubCli, type GitHubCliShape } from "./GitHubCli.ts"; -import type { VcsProcessOutput } from "../vcs/VcsProcess.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubCli from "./GitHubCli.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; -const processResult = (stdout: string): VcsProcessOutput => ({ +const processResult = (stdout: string): VcsProcess.VcsProcessOutput => ({ exitCode: ChildProcessSpawner.ExitCode(0), stdout, stderr: "", @@ -14,8 +14,10 @@ const processResult = (stdout: string): VcsProcessOutput => ({ stderrTruncated: false, }); -function makeProvider(github: Partial) { - return GitHubSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitHubCli)(github))); +function makeProvider(github: Partial) { + return GitHubSourceControlProvider.make().pipe( + Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)), + ); } it.effect("maps GitHub PR summaries into provider-neutral change requests", () => @@ -127,7 +129,7 @@ it.effect("treats empty non-open change request listing output as no results", ( it.effect("creates GitHub PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = null; + let createInput: Parameters[0] | null = null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 7dbef53da7..496f1ec48c 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -5,20 +5,15 @@ import { type ChangeRequestState, } from "@t3tools/contracts"; -import { GitHubCli, type GitHubCliError, type GitHubPullRequestSummary } from "./GitHubCli.ts"; -import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts"; -import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts"; -import { - combinedAuthOutput, - firstSafeAuthLine, - matchFirst, - parseCliHost, - providerAuth, - type SourceControlAuthProbeInput, - type SourceControlCliDiscoverySpec, -} from "./SourceControlProviderDiscovery.ts"; +import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubPullRequests from "./gitHubPullRequests.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; -function providerError(operation: string, cause: GitHubCliError): SourceControlProviderError { +function providerError( + operation: string, + cause: GitHubCli.GitHubCliError, +): SourceControlProviderError { return new SourceControlProviderError({ provider: "github", operation, @@ -27,7 +22,7 @@ function providerError(operation: string, cause: GitHubCliError): SourceControlP }); } -function toChangeRequest(summary: GitHubPullRequestSummary): ChangeRequest { +function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { return { provider: "github", number: summary.number, @@ -49,30 +44,34 @@ function toChangeRequest(summary: GitHubPullRequestSummary): ChangeRequest { }; } -function parseGitHubAuth(input: SourceControlAuthProbeInput) { - const output = combinedAuthOutput(input); - const account = matchFirst(output, [ +function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { + const output = SourceControlProviderDiscovery.combinedAuthOutput(input); + const account = SourceControlProviderDiscovery.matchFirst(output, [ /Logged in to .* account\s+([^\s(]+)/iu, /Logged in to .* as\s+([^\s(]+)/iu, ]); - const host = parseCliHost(output); + const host = SourceControlProviderDiscovery.parseCliHost(output); if (input.exitCode !== 0) { - return providerAuth({ + return SourceControlProviderDiscovery.providerAuth({ status: "unauthenticated", host, - detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", + detail: + SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? + "Run `gh auth login` to authenticate GitHub CLI.", }); } if (account) { - return providerAuth({ status: "authenticated", account, host }); + return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host }); } - return providerAuth({ + return SourceControlProviderDiscovery.providerAuth({ status: "unknown", host, - detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", + detail: + SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? + "GitHub CLI auth status could not be parsed.", }); } @@ -86,77 +85,78 @@ export const discovery = { parseAuth: parseGitHubAuth, implemented: true, installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.", -} satisfies SourceControlCliDiscoverySpec; +} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { - const github = yield* GitHubCli; + const github = yield* GitHubCli.GitHubCli; + + const listChangeRequests: SourceControlProvider.SourceControlProviderShape["listChangeRequests"] = + (input) => { + if (input.state === "open") { + return github + .listOpenPullRequests({ + cwd: input.cwd, + headSelector: input.headSelector, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((items) => items.map(toChangeRequest)), + Effect.mapError((error) => providerError("listChangeRequests", error)), + ); + } - const listChangeRequests: SourceControlProviderShape["listChangeRequests"] = (input) => { - if (input.state === "open") { + const stateArg: ChangeRequestState | "all" = input.state; return github - .listOpenPullRequests({ + .execute({ cwd: input.cwd, - headSelector: input.headSelector, - ...(input.limit !== undefined ? { limit: input.limit } : {}), + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + stateArg, + "--limit", + String(input.limit ?? 20), + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], }) .pipe( - Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed([]); + } + return Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => + Result.isSuccess(decoded) + ? Effect.succeed( + decoded.success.map((item) => ({ + ...toChangeRequest(item), + updatedAt: item.updatedAt, + })), + ) + : Effect.fail( + new SourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + detail: "GitHub CLI returned invalid change request JSON.", + cause: decoded.failure, + }), + ), + ), + ); + }), + Effect.mapError((error) => + Schema.is(SourceControlProviderError)(error) + ? error + : providerError("listChangeRequests", error), + ), ); - } - - const stateArg: ChangeRequestState | "all" = input.state; - return github - .execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - "--head", - input.headSelector, - "--state", - stateArg, - "--limit", - String(input.limit ?? 20), - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }) - .pipe( - Effect.flatMap((result) => { - const raw = result.stdout.trim(); - if (raw.length === 0) { - return Effect.succeed([]); - } - return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( - Effect.flatMap((decoded) => - Result.isSuccess(decoded) - ? Effect.succeed( - decoded.success.map((item) => ({ - ...toChangeRequest(item), - updatedAt: item.updatedAt, - })), - ) - : Effect.fail( - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", - detail: "GitHub CLI returned invalid change request JSON.", - cause: decoded.failure, - }), - ), - ), - ); - }), - Effect.mapError((error) => - Schema.is(SourceControlProviderError)(error) - ? error - : providerError("listChangeRequests", error), - ), - ); - }; + }; - return SourceControlProvider.of({ + return SourceControlProvider.SourceControlProvider.of({ kind: "github", listChangeRequests, getChangeRequest: (input) => @@ -193,4 +193,4 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { }); }); -export const layer = Layer.effect(SourceControlProvider, make()); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index d4e07efb88..5c7e978d85 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -1,25 +1,24 @@ -import { assert, it } from "@effect/vitest"; +import { assert, it, afterEach, expect, vi } from "@effect/vitest"; import { Effect, Layer } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { afterEach, expect, vi } from "vitest"; import { VcsProcessExitError } from "@t3tools/contracts"; -import { VcsProcess, type VcsProcessOutput, type VcsProcessShape } from "../vcs/VcsProcess.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitLabCli from "./GitLabCli.ts"; -const mockedRun = vi.fn(); +const mockedRun = vi.fn(); const layer = it.layer( GitLabCli.layer.pipe( Layer.provide( - Layer.mock(VcsProcess)({ + Layer.mock(VcsProcess.VcsProcess)({ run: mockedRun, }), ), ), ); -function processOutput(stdout: string): VcsProcessOutput { +function processOutput(stdout: string): VcsProcess.VcsProcessOutput { return { exitCode: ChildProcessSpawner.ExitCode(0), stdout, diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 6dac7f7eab..c4485bb09b 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -2,13 +2,9 @@ import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateT import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; -import { - decodeGitLabMergeRequestJson, - decodeGitLabMergeRequestListJson, - formatGitLabJsonDecodeError, -} from "./gitLabMergeRequests.ts"; -import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; -import type { SourceControlRefSelector } from "./SourceControlProvider.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitLabMergeRequests from "./gitLabMergeRequests.ts"; +import type * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -46,12 +42,12 @@ export interface GitLabCliShape { readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly listMergeRequests: (input: { readonly cwd: string; readonly headSelector: string; - readonly source?: SourceControlRefSelector; + readonly source?: SourceControlProvider.SourceControlRefSelector; readonly state: "open" | "closed" | "merged" | "all"; readonly limit?: number; }) => Effect.Effect, GitLabCliError>; @@ -76,8 +72,8 @@ export interface GitLabCliShape { readonly cwd: string; readonly baseBranch: string; readonly headSelector: string; - readonly source?: SourceControlRefSelector; - readonly target?: SourceControlRefSelector; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; readonly title: string; readonly bodyFile: string; }) => Effect.Effect; @@ -220,12 +216,14 @@ function normalizeHeadSelector(headSelector: string): string { function sourceRefName(input: { readonly headSelector: string; - readonly source?: SourceControlRefSelector; + readonly source?: SourceControlProvider.SourceControlRefSelector; }): string { return input.source?.refName ?? normalizeHeadSelector(input.headSelector); } -function sourceProjectIdentifier(source: SourceControlRefSelector | undefined): string | null { +function sourceProjectIdentifier( + source: SourceControlProvider.SourceControlRefSelector | undefined, +): string | null { return source?.repository ?? source?.owner ?? null; } @@ -252,7 +250,7 @@ function parseRepositoryPath(repository: string): { } export const make = Effect.fn("makeGitLabCli")(function* () { - const process = yield* VcsProcess; + const process = yield* VcsProcess.VcsProcess; const execute: GitLabCliShape["execute"] = (input) => process @@ -286,13 +284,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe( + : Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestListJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new GitLabCliError({ operation: "listMergeRequests", - detail: `GitLab CLI returned invalid MR list JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, + detail: `GitLab CLI returned invalid MR list JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -310,13 +308,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe( + Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new GitLabCliError({ operation: "getMergeRequest", - detail: `GitLab CLI returned invalid merge request JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, + detail: `GitLab CLI returned invalid merge request JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 7773a3b905..930c1c018f 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -1,11 +1,13 @@ import { assert, it } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; -import { GitLabCli, type GitLabCliShape } from "./GitLabCli.ts"; +import * as GitLabCli from "./GitLabCli.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; -function makeProvider(gitlab: Partial) { - return GitLabSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitLabCli)(gitlab))); +function makeProvider(gitlab: Partial) { + return GitLabSourceControlProvider.make().pipe( + Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)), + ); } it.effect("maps GitLab MR summaries into provider-neutral change requests", () => @@ -48,7 +50,7 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = it.effect("lists GitLab MRs through provider-neutral input names", () => Effect.gen(function* () { - let listInput: Parameters[0] | null = null; + let listInput: Parameters[0] | null = null; const provider = yield* makeProvider({ listMergeRequests: (input) => { listInput = input; @@ -74,7 +76,7 @@ it.effect("lists GitLab MRs through provider-neutral input names", () => it.effect("creates GitLab MRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = null; + let createInput: Parameters[0] | null = null; const provider = yield* makeProvider({ createMergeRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index bf5d28b3ae..a007dd246f 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -1,19 +1,14 @@ import { Effect, Layer, Option } from "effect"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; -import { GitLabCli, type GitLabCliError, type GitLabMergeRequestSummary } from "./GitLabCli.ts"; -import { SourceControlProvider, sourceControlRefFromInput } from "./SourceControlProvider.ts"; -import { - combinedAuthOutput, - firstSafeAuthLine, - matchFirst, - parseCliHost, - providerAuth, - type SourceControlAuthProbeInput, - type SourceControlCliDiscoverySpec, -} from "./SourceControlProviderDiscovery.ts"; +import * as GitLabCli from "./GitLabCli.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; -function providerError(operation: string, cause: GitLabCliError): SourceControlProviderError { +function providerError( + operation: string, + cause: GitLabCli.GitLabCliError, +): SourceControlProviderError { return new SourceControlProviderError({ provider: "gitlab", operation, @@ -22,7 +17,7 @@ function providerError(operation: string, cause: GitLabCliError): SourceControlP }); } -function toChangeRequest(summary: GitLabMergeRequestSummary): ChangeRequest { +function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest { return { provider: "gitlab", number: summary.number, @@ -44,31 +39,35 @@ function toChangeRequest(summary: GitLabMergeRequestSummary): ChangeRequest { }; } -function parseGitLabAuth(input: SourceControlAuthProbeInput) { - const output = combinedAuthOutput(input); - const account = matchFirst(output, [ +function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { + const output = SourceControlProviderDiscovery.combinedAuthOutput(input); + const account = SourceControlProviderDiscovery.matchFirst(output, [ /Logged in to .* as\s+([^\s(]+)/iu, /Logged in to .* account\s+([^\s(]+)/iu, /account:\s*([^\s(]+)/iu, ]); - const host = parseCliHost(output); + const host = SourceControlProviderDiscovery.parseCliHost(output); if (input.exitCode !== 0) { - return providerAuth({ + return SourceControlProviderDiscovery.providerAuth({ status: "unauthenticated", host, - detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.", + detail: + SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? + "Run `glab auth login` to authenticate GitLab CLI.", }); } if (account) { - return providerAuth({ status: "authenticated", account, host }); + return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host }); } - return providerAuth({ + return SourceControlProviderDiscovery.providerAuth({ status: "unknown", host, - detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.", + detail: + SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? + "GitLab CLI auth status could not be parsed.", }); } @@ -83,15 +82,15 @@ export const discovery = { implemented: true, installHint: "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.", -} satisfies SourceControlCliDiscoverySpec; +} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { - const gitlab = yield* GitLabCli; + const gitlab = yield* GitLabCli.GitLabCli; - return SourceControlProvider.of({ + return SourceControlProvider.SourceControlProvider.of({ kind: "gitlab", listChangeRequests: (input) => { - const source = sourceControlRefFromInput(input); + const source = SourceControlProvider.sourceControlRefFromInput(input); return gitlab .listMergeRequests({ cwd: input.cwd, @@ -111,7 +110,7 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { Effect.mapError((error) => providerError("getChangeRequest", error)), ), createChangeRequest: (input) => { - const source = sourceControlRefFromInput(input); + const source = SourceControlProvider.sourceControlRefFromInput(input); return gitlab .createMergeRequest({ cwd: input.cwd, @@ -143,4 +142,4 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { }); }); -export const layer = Layer.effect(SourceControlProvider, make()); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 0436f0e127..6da3513e73 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -5,17 +5,17 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsProcessSpawnError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { AzureDevOpsCli } from "./AzureDevOpsCli.ts"; -import { BitbucketApi, type BitbucketApiShape } from "./BitbucketApi.ts"; -import { GitHubCli } from "./GitHubCli.ts"; -import { GitLabCli } from "./GitLabCli.ts"; -import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts"; +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as BitbucketApi from "./BitbucketApi.ts"; +import * as GitHubCli from "./GitHubCli.ts"; +import * as GitLabCli from "./GitLabCli.ts"; +import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; -import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; const sourceControlProviderRegistryTestLayer = (input: { - readonly bitbucket: Partial; + readonly bitbucket: Partial; readonly process: Partial; }) => SourceControlProviderRegistry.layer.pipe( @@ -24,11 +24,11 @@ const sourceControlProviderRegistryTestLayer = (input: { ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( Layer.provide(NodeServices.layer), ), - Layer.mock(AzureDevOpsCli)({}), - Layer.mock(BitbucketApi)(input.bitbucket), - Layer.mock(GitHubCli)({}), - Layer.mock(GitLabCli)({}), - Layer.mock(VcsDriverRegistry)({}), + Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), + Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), + Layer.mock(GitHubCli.GitHubCli)({}), + Layer.mock(GitLabCli.GitLabCli)({}), + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), Layer.mock(VcsProcess.VcsProcess)(input.process), ), ), @@ -78,7 +78,7 @@ Logged in to github.com account juliusmarminge (keyring) ); }, } satisfies Partial; - const testLayer = layer.pipe( + const testLayer = SourceControlDiscovery.layer.pipe( Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), ), @@ -102,7 +102,7 @@ Logged in to github.com account juliusmarminge (keyring) ); return Effect.gen(function* () { - const discovery = yield* SourceControlDiscovery; + const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; const result = yield* discovery.discover; assert.deepStrictEqual( @@ -199,7 +199,7 @@ Logged in to gitlab.com as gitlab-user ); }, } satisfies Partial; - const testLayer = layer.pipe( + const testLayer = SourceControlDiscovery.layer.pipe( Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), ), @@ -221,7 +221,7 @@ Logged in to gitlab.com as gitlab-user ); return Effect.gen(function* () { - const discovery = yield* SourceControlDiscovery; + const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; const result = yield* discovery.discover; assert.deepStrictEqual( diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index fa01023de5..4a44d35087 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -7,8 +7,8 @@ import { Context, Effect, Layer, Option } from "effect"; import { ServerConfig } from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; -import { detailFromCause, firstNonEmptyLine } from "./SourceControlProviderDiscovery.ts"; +import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; interface DiscoveryProbe { readonly label: string; @@ -68,7 +68,8 @@ export const layer = Layer.effect( Effect.gen(function* () { const config = yield* ServerConfig; const process = yield* VcsProcess.VcsProcess; - const sourceControlProviders = yield* SourceControlProviderRegistry; + const sourceControlProviders = + yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const probe = ( input: DiscoveryProbe & { readonly kind: Kind }, @@ -107,8 +108,9 @@ export const layer = Layer.effect( executable, implemented: input.implemented, status: "available" as const, - version: Option.orElse(firstNonEmptyLine(result.stdout), () => - firstNonEmptyLine(result.stderr), + version: Option.orElse( + SourceControlProviderDiscovery.firstNonEmptyLine(result.stdout), + () => SourceControlProviderDiscovery.firstNonEmptyLine(result.stderr), ), installHint: input.installHint, detail: Option.none(), @@ -123,7 +125,7 @@ export const layer = Layer.effect( status: "missing" as const, version: Option.none(), installHint: input.installHint, - detail: detailFromCause(cause), + detail: SourceControlProviderDiscovery.detailFromCause(cause), } satisfies DiscoveryProbeResult), ), ); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 0fb6d50fe3..395bd9b5e8 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -2,15 +2,15 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { DateTime, Effect, Layer, Option } from "effect"; -import { AzureDevOpsCli } from "./AzureDevOpsCli.ts"; -import { BitbucketApi } from "./BitbucketApi.ts"; -import { GitHubCli } from "./GitHubCli.ts"; -import { GitLabCli } from "./GitLabCli.ts"; -import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import { ServerConfig } from "../config.ts"; +import type * as VcsDriver from "../vcs/VcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; -import type { VcsDriverShape } from "../vcs/VcsDriver.ts"; +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as BitbucketApi from "./BitbucketApi.ts"; +import * as GitHubCli from "./GitHubCli.ts"; +import * as GitLabCli from "./GitLabCli.ts"; +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); @@ -34,10 +34,10 @@ function makeRegistry(input: { expiresAt: Option.none(), }, }), - } satisfies Partial; + } satisfies Partial; - const registryLayer = Layer.mock(VcsDriverRegistry)({ - get: () => Effect.succeed(driver as unknown as VcsDriverShape), + const registryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriverShape), resolve: () => Effect.succeed({ kind: "git", @@ -51,7 +51,7 @@ function makeRegistry(input: { expiresAt: Option.none(), }, }, - driver: driver as unknown as VcsDriverShape, + driver: driver as unknown as VcsDriver.VcsDriverShape, }), }); @@ -59,10 +59,10 @@ function makeRegistry(input: { Effect.provide( Layer.mergeAll( registryLayer, - Layer.mock(AzureDevOpsCli)({}), - Layer.mock(BitbucketApi)({}), - Layer.mock(GitHubCli)({}), - Layer.mock(GitLabCli)({}), + Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), + Layer.mock(BitbucketApi.BitbucketApi)({}), + Layer.mock(GitHubCli.GitHubCli)({}), + Layer.mock(GitLabCli.GitLabCli)({}), Layer.mock(VcsProcess.VcsProcess)({}), ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( Layer.provide(NodeServices.layer), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index e6b0a34050..c8b79f2165 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -7,20 +7,13 @@ import type { SourceControlProviderKind } from "@t3tools/contracts"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; -import { - SourceControlProvider, - type SourceControlProviderContext, - type SourceControlProviderShape, -} from "./SourceControlProvider.ts"; -import { - probeSourceControlProvider, - type SourceControlProviderDiscoverySpec, -} from "./SourceControlProviderDiscovery.ts"; import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; import { ServerConfig } from "../config.ts"; -import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; @@ -28,25 +21,25 @@ const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); export interface SourceControlProviderRegistration { readonly kind: SourceControlProviderKind; - readonly provider: SourceControlProviderShape; - readonly discovery: SourceControlProviderDiscoverySpec; + readonly provider: SourceControlProvider.SourceControlProviderShape; + readonly discovery: SourceControlProviderDiscovery.SourceControlProviderDiscoverySpec; } export interface SourceControlProviderHandle { - readonly provider: SourceControlProviderShape; - readonly context: SourceControlProviderContext | null; + readonly provider: SourceControlProvider.SourceControlProviderShape; + readonly context: SourceControlProvider.SourceControlProviderContext | null; } export interface SourceControlProviderRegistryShape { readonly get: ( kind: SourceControlProviderKind, - ) => Effect.Effect; + ) => Effect.Effect; readonly resolveHandle: (input: { readonly cwd: string; }) => Effect.Effect; readonly resolve: (input: { readonly cwd: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly discover: Effect.Effect>; } @@ -55,7 +48,9 @@ export class SourceControlProviderRegistry extends Context.Service< SourceControlProviderRegistryShape >()("t3/source-control/SourceControlProviderRegistry") {} -function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProviderShape { +function unsupportedProvider( + kind: SourceControlProviderKind, +): SourceControlProvider.SourceControlProviderShape { const unsupported = (operation: string) => Effect.fail( new SourceControlProviderError({ @@ -65,7 +60,7 @@ function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProv }), ); - return SourceControlProvider.of({ + return SourceControlProvider.SourceControlProvider.of({ kind, listChangeRequests: () => unsupported("listChangeRequests"), getChangeRequest: () => unsupported("getChangeRequest"), @@ -91,7 +86,7 @@ function selectProviderContext( readonly name: string; readonly url: string; }>, -): SourceControlProviderContext | null { +): SourceControlProvider.SourceControlProviderContext | null { const candidates = remotes .map((remote) => { const provider = detectSourceControlProviderFromRemoteUrl(remote.url); @@ -103,7 +98,7 @@ function selectProviderContext( } : null; }) - .filter((value): value is SourceControlProviderContext => value !== null); + .filter((value): value is SourceControlProvider.SourceControlProviderContext => value !== null); return ( candidates.find((candidate) => candidate.remoteName === "origin") ?? @@ -114,14 +109,14 @@ function selectProviderContext( } function bindProviderContext( - provider: SourceControlProviderShape, - context: SourceControlProviderContext | null, -): SourceControlProviderShape { + provider: SourceControlProvider.SourceControlProviderShape, + context: SourceControlProvider.SourceControlProviderContext | null, +): SourceControlProvider.SourceControlProviderShape { if (context === null) { return provider; } - return SourceControlProvider.of({ + return SourceControlProvider.SourceControlProvider.of({ kind: provider.kind, listChangeRequests: (input) => provider.listChangeRequests({ @@ -161,10 +156,11 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit function* (registrations: ReadonlyArray) { const config = yield* ServerConfig; const process = yield* VcsProcess.VcsProcess; - const vcsRegistry = yield* VcsDriverRegistry; - const providers = new Map( - registrations.map((registration) => [registration.kind, registration.provider]), - ); + const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; + const providers = new Map< + SourceControlProviderKind, + SourceControlProvider.SourceControlProviderShape + >(registrations.map((registration) => [registration.kind, registration.provider])); const discoverySpecs = registrations.map((registration) => registration.discovery); const get: SourceControlProviderRegistryShape["get"] = (kind) => @@ -185,7 +181,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit const providerContextCache = yield* Cache.makeWith< string, - SourceControlProviderContext | null, + SourceControlProvider.SourceControlProviderContext | null, SourceControlProviderError >(detectProviderContext, { capacity: PROVIDER_DETECTION_CACHE_CAPACITY, @@ -210,7 +206,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), discover: Effect.all( discoverySpecs.map((spec) => - probeSourceControlProvider({ + SourceControlProviderDiscovery.probeSourceControlProvider({ spec, process, cwd: config.cwd, diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 56ab64b54b..5280ee0e59 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -6,14 +6,10 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; -import { - GitVcsDriver, - type ExecuteGitResult, - type GitVcsDriverShape, -} from "../vcs/GitVcsDriver.ts"; -import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; -import type { SourceControlProviderShape } from "./SourceControlProvider.ts"; -import { SourceControlRepositoryService, layer } from "./SourceControlRepositoryService.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import type * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; +import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts"; const CLONE_URLS = { nameWithOwner: "octocat/t3code", @@ -22,8 +18,8 @@ const CLONE_URLS = { }; function makeProvider( - overrides: Partial = {}, -): SourceControlProviderShape { + overrides: Partial = {}, +): SourceControlProvider.SourceControlProviderShape { const unsupported = (operation: string) => Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect< never, @@ -43,7 +39,7 @@ function makeProvider( }; } -function processOutput(): ExecuteGitResult { +function processOutput(): GitVcsDriver.ExecuteGitResult { return { exitCode: ChildProcessSpawner.ExitCode(0), stdout: "", @@ -54,17 +50,17 @@ function processOutput(): ExecuteGitResult { } function makeLayer(input: { - readonly provider?: SourceControlProviderShape; - readonly git?: Partial; + readonly provider?: SourceControlProvider.SourceControlProviderShape; + readonly git?: Partial; }) { - return layer.pipe( + return SourceControlRepositoryService.layer.pipe( Layer.provide( - Layer.mock(SourceControlProviderRegistry)({ + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ get: () => Effect.succeed(input.provider ?? makeProvider()), }), ), Layer.provide( - Layer.mock(GitVcsDriver)({ + Layer.mock(GitVcsDriver.GitVcsDriver)({ execute: () => Effect.succeed(processOutput()), ensureRemote: () => Effect.succeed("origin"), pushCurrentBranch: () => @@ -93,7 +89,7 @@ it.effect("looks up repositories through the requested provider without search", }); return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService; + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.lookupRepository({ provider: "github", repository: "octocat/t3code", @@ -115,7 +111,7 @@ it.effect("clones a looked-up repository into the requested destination", () => const cloneCalls: Array<{ cwd: string; args: ReadonlyArray }> = []; yield* Effect.gen(function* () { - const service = yield* SourceControlRepositoryService; + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.cloneRepository({ provider: "github", repository: "octocat/t3code", @@ -167,7 +163,7 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up }); return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService; + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.publishRepository({ cwd: "/workspace", provider: "github", @@ -222,7 +218,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => { const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService; + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.publishRepository({ cwd: "/workspace", provider: "github", @@ -258,7 +254,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => { it.effect("publish succeeds with status remote_added when the local repo has no commits", () => { let pushCalls = 0; return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService; + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.publishRepository({ cwd: "/workspace", provider: "github", diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index bfd19f2cdf..1bf71ac12d 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -1,4 +1,4 @@ -import OS from "node:os"; +import * as NodeOS from "node:os"; import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect"; import { @@ -15,8 +15,8 @@ import { } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; -import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; -import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; export interface SourceControlRepositoryServiceShape { readonly lookupRepository: ( @@ -102,10 +102,10 @@ function selectRemoteUrl( function expandHomePath(input: string, path: Path.Path): string { if (input === "~") { - return OS.homedir(); + return NodeOS.homedir(); } if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(OS.homedir(), input.slice(2)); + return path.join(NodeOS.homedir(), input.slice(2)); } return input; } @@ -113,9 +113,9 @@ function expandHomePath(input: string, path: Path.Path): string { export const make = Effect.fn("makeSourceControlRepositoryService")(function* () { const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; - const git = yield* GitVcsDriver; + const git = yield* GitVcsDriver.GitVcsDriver; const path = yield* Path.Path; - const providers = yield* SourceControlProviderRegistry; + const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const ensureConcreteProvider = (input: { readonly operation: string; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 727f7e8650..49a135aec4 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -18,9 +18,9 @@ import { type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; -import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; -import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts"; -import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts"; +import * as GitVcsDriverCore from "./GitVcsDriverCore.ts"; +import * as VcsDriver from "./VcsDriver.ts"; +import * as VcsProcess from "./VcsProcess.ts"; export interface ExecuteGitInput { readonly operation: string; @@ -304,7 +304,7 @@ function parseGitRemoteVerboseOutput( } const gitCommand = ( - process: VcsProcessShape, + process: VcsProcess.VcsProcessShape, operation: string, cwd: string, args: ReadonlyArray, @@ -335,7 +335,7 @@ const gitCommand = ( }); export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () { - const process = yield* VcsProcess; + const process = yield* VcsProcess.VcsProcess; const capabilities = { kind: "git" as const, supportsWorktrees: true, @@ -345,7 +345,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ignoreClassifier: "native" as const, }; - const isInsideWorkTree: VcsDriverShape["isInsideWorkTree"] = (cwd) => + const isInsideWorkTree: VcsDriver.VcsDriverShape["isInsideWorkTree"] = (cwd) => gitCommand( process, "GitVcsDriver.isInsideWorkTree", @@ -358,7 +358,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }, ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true")); - const execute: VcsDriverShape["execute"] = (input) => + const execute: VcsDriver.VcsDriverShape["execute"] = (input) => gitCommand(process, input.operation, input.cwd, input.args, { ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), ...(input.env !== undefined ? { env: input.env } : {}), @@ -370,33 +370,33 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( : {}), }); - const detectRepository: VcsDriverShape["detectRepository"] = Effect.fn("detectRepository")( - function* (cwd) { - if (!(yield* isInsideWorkTree(cwd))) { - return null; - } + const detectRepository: VcsDriver.VcsDriverShape["detectRepository"] = Effect.fn( + "detectRepository", + )(function* (cwd) { + if (!(yield* isInsideWorkTree(cwd))) { + return null; + } - const root = yield* gitCommand(process, "GitVcsDriver.detectRepository.root", cwd, [ - "rev-parse", - "--show-toplevel", - ]); - const gitCommonDir = yield* gitCommand( - process, - "GitVcsDriver.detectRepository.commonDir", - cwd, - ["rev-parse", "--git-common-dir"], - ).pipe(Effect.catch(() => Effect.succeed(null))); + const root = yield* gitCommand(process, "GitVcsDriver.detectRepository.root", cwd, [ + "rev-parse", + "--show-toplevel", + ]); + const gitCommonDir = yield* gitCommand( + process, + "GitVcsDriver.detectRepository.commonDir", + cwd, + ["rev-parse", "--git-common-dir"], + ).pipe(Effect.catch(() => Effect.succeed(null))); - return { - kind: "git" as const, - rootPath: root.stdout.trim(), - metadataPath: gitCommonDir?.stdout.trim() || null, - freshness: yield* nowFreshness(), - }; - }, - ); + return { + kind: "git" as const, + rootPath: root.stdout.trim(), + metadataPath: gitCommonDir?.stdout.trim() || null, + freshness: yield* nowFreshness(), + }; + }); - const listWorkspaceFiles: VcsDriverShape["listWorkspaceFiles"] = (cwd) => + const listWorkspaceFiles: VcsDriver.VcsDriverShape["listWorkspaceFiles"] = (cwd) => gitCommand( process, "GitVcsDriver.listWorkspaceFiles", @@ -438,98 +438,100 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ), ); - const listRemotes: VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")(function* (cwd) { - const result = yield* gitCommand(process, "GitVcsDriver.listRemotes", cwd, ["remote", "-v"], { - allowNonZeroExit: true, - timeoutMs: 5_000, - maxOutputBytes: 64 * 1024, - }); - - if (result.exitCode !== 0) { - return yield* new VcsProcessExitError({ - operation: "GitVcsDriver.listRemotes", - command: "git remote -v", - cwd, - exitCode: result.exitCode, - detail: result.stderr.trim() || "git remote -v failed", + const listRemotes: VcsDriver.VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")( + function* (cwd) { + const result = yield* gitCommand(process, "GitVcsDriver.listRemotes", cwd, ["remote", "-v"], { + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, }); - } - const parsed = parseGitRemoteVerboseOutput(result.stdout); - const remotes = Array.from(parsed.entries()).flatMap(([name, remote]) => { - if (!remote.url) { - return []; + if (result.exitCode !== 0) { + return yield* new VcsProcessExitError({ + operation: "GitVcsDriver.listRemotes", + command: "git remote -v", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "git remote -v failed", + }); } - return [ - { - name, - url: remote.url, - pushUrl: remote.pushUrl ? Option.some(remote.pushUrl) : Option.none(), - isPrimary: name === "origin", - }, - ]; - }); - return { - remotes, - freshness: yield* nowFreshness(), - }; - }); + const parsed = parseGitRemoteVerboseOutput(result.stdout); + const remotes = Array.from(parsed.entries()).flatMap(([name, remote]) => { + if (!remote.url) { + return []; + } + return [ + { + name, + url: remote.url, + pushUrl: remote.pushUrl ? Option.some(remote.pushUrl) : Option.none(), + isPrimary: name === "origin", + }, + ]; + }); - const filterIgnoredPaths: VcsDriverShape["filterIgnoredPaths"] = Effect.fn("filterIgnoredPaths")( - function* (cwd, relativePaths) { - if (relativePaths.length === 0) { - return relativePaths; - } + return { + remotes, + freshness: yield* nowFreshness(), + }; + }, + ); - const ignoredPaths = new Set(); - const chunks = chunkPathsForGitCheckIgnore(relativePaths); + const filterIgnoredPaths: VcsDriver.VcsDriverShape["filterIgnoredPaths"] = Effect.fn( + "filterIgnoredPaths", + )(function* (cwd, relativePaths) { + if (relativePaths.length === 0) { + return relativePaths; + } - for (const chunk of chunks) { - const result = yield* gitCommand( - process, - "GitVcsDriver.filterIgnoredPaths", - cwd, - [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], - { - stdin: `${chunk.join("\0")}\0`, - allowNonZeroExit: true, - timeoutMs: 20_000, - maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ); - - if (result.exitCode !== 0 && result.exitCode !== 1) { - return yield* new VcsProcessExitError({ - operation: "GitVcsDriver.filterIgnoredPaths", - command: "git check-ignore", - cwd, - exitCode: result.exitCode, - detail: result.stderr.trim() || "git check-ignore failed", - }); - } + const ignoredPaths = new Set(); + const chunks = chunkPathsForGitCheckIgnore(relativePaths); - for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { - ignoredPaths.add(ignoredPath); - } + for (const chunk of chunks) { + const result = yield* gitCommand( + process, + "GitVcsDriver.filterIgnoredPaths", + cwd, + [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], + { + stdin: `${chunk.join("\0")}\0`, + allowNonZeroExit: true, + timeoutMs: 20_000, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ); + + if (result.exitCode !== 0 && result.exitCode !== 1) { + return yield* new VcsProcessExitError({ + operation: "GitVcsDriver.filterIgnoredPaths", + command: "git check-ignore", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "git check-ignore failed", + }); } - if (ignoredPaths.size === 0) { - return relativePaths; + for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { + ignoredPaths.add(ignoredPath); } + } - return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); - }, - ); + if (ignoredPaths.size === 0) { + return relativePaths; + } - const initRepository: VcsDriverShape["initRepository"] = (input) => + return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); + }); + + const initRepository: VcsDriver.VcsDriverShape["initRepository"] = (input) => gitCommand(process, "GitVcsDriver.initRepository", input.cwd, ["init"], { timeoutMs: 10_000, maxOutputBytes: 64 * 1024, }).pipe(Effect.asVoid); - return { + return VcsDriver.VcsDriver.of({ capabilities, execute, detectRepository, @@ -538,18 +540,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( listRemotes, filterIgnoredPaths, initRepository, - } satisfies VcsDriverShape; + }); }); export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { const driver = yield* makeVcsDriverShape(); - return VcsDriver.of(driver); + return VcsDriver.VcsDriver.of(driver); }); export const make = Effect.fn("makeGitVcsDriverService")(function* () { - const git = yield* makeGitVcsDriverCore(); + const git = yield* GitVcsDriverCore.makeGitVcsDriverCore(); return GitVcsDriver.of(git); }); -export const vcsLayer = Layer.effect(VcsDriver, makeVcsDriver()); +export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver()); export const layer = Layer.effect(GitVcsDriver, make()); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 25e9071b83..0daf9ab956 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,7 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { assert, it, describe } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path, PlatformError, Scope } from "effect"; -import { describe } from "vitest"; import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 707461cebc..e01a78a21f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -22,14 +22,7 @@ import { GitCommandError, type VcsRef } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import { compactTraceAttributes } from "../observability/Attributes.ts"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; -import type { - ExecuteGitProgress, - GitCommitOptions, - GitVcsDriverShape, - GitStatusDetails, - ExecuteGitInput, - ExecuteGitResult, -} from "./GitVcsDriver.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; import { parseRemoteNames, parseRemoteNamesInGitOrder, @@ -51,7 +44,7 @@ const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; -const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ +const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ isRepo: false, hasOriginRemote: false, isDefaultBranch: false, @@ -82,7 +75,7 @@ interface ExecuteGitOptions { fallbackErrorMessage?: string | undefined; maxOutputBytes?: number | undefined; truncateOutputAtMaxBytes?: boolean | undefined; - progress?: ExecuteGitProgress | undefined; + progress?: GitVcsDriver.ExecuteGitProgress | undefined; } function parseBranchAb(value: string): { ahead: number; behind: number } { @@ -332,7 +325,7 @@ function isMissingGitCwdError(error: GitCommandError): boolean { } function toGitCommandError( - input: Pick, + input: Pick, detail: string, ) { return (cause: unknown) => @@ -377,8 +370,8 @@ function trace2ChildKey(record: Record): string | null { const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( - input: Pick, - progress: ExecuteGitProgress | undefined, + input: Pick, + progress: GitVcsDriver.ExecuteGitProgress | undefined, ): Effect.fn.Return< Trace2Monitor, PlatformError.PlatformError, @@ -536,7 +529,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( }); const collectOutput = Effect.fn("collectOutput")(function* ( - input: Pick, + input: Pick, stream: Stream.Stream, maxOutputBytes: number, truncateOutputAtMaxBytes: boolean, @@ -610,13 +603,13 @@ const collectOutput = Effect.fn("collectOutput")(function* ( }); export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* (options?: { - executeOverride?: GitVcsDriverShape["execute"]; + executeOverride?: GitVcsDriver.GitVcsDriverShape["execute"]; }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const { worktreesDir } = yield* ServerConfig; - let executeRaw: GitVcsDriverShape["execute"]; + let executeRaw: GitVcsDriver.GitVcsDriverShape["execute"]; if (options?.executeOverride) { executeRaw = options.executeOverride; @@ -698,7 +691,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* stderr: stderr.text, stdoutTruncated: stdout.truncated, stderrTruncated: stderr.truncated, - } satisfies ExecuteGitResult; + } satisfies GitVcsDriver.ExecuteGitResult; }); return yield* runGitCommand().pipe( @@ -722,7 +715,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); } - const execute: GitVcsDriverShape["execute"] = (input) => + const execute: GitVcsDriver.GitVcsDriverShape["execute"] = (input) => executeRaw(input).pipe( withMetrics({ counter: gitCommandsTotal, @@ -746,7 +739,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd: string, args: readonly string[], options: ExecuteGitOptions = {}, - ): Effect.Effect => + ): Effect.Effect => execute({ operation, cwd, @@ -1023,7 +1016,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null))); }); - const ensureRemote: GitVcsDriverShape["ensureRemote"] = Effect.fn("ensureRemote")( + const ensureRemote: GitVcsDriver.GitVcsDriverShape["ensureRemote"] = Effect.fn("ensureRemote")( function* (input) { const preferredName = sanitizeRemoteName(input.preferredName); const normalizedTargetUrl = normalizeRemoteUrl(input.url); @@ -1322,13 +1315,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const statusDetailsLocal: GitVcsDriverShape["statusDetailsLocal"] = Effect.fn( + const statusDetailsLocal: GitVcsDriver.GitVcsDriverShape["statusDetailsLocal"] = Effect.fn( "statusDetailsLocal", )(function* (cwd) { return yield* readStatusDetailsLocal(cwd); }); - const statusDetails: GitVcsDriverShape["statusDetails"] = Effect.fn("statusDetails")( + const statusDetails: GitVcsDriver.GitVcsDriverShape["statusDetails"] = Effect.fn("statusDetails")( function* (cwd) { yield* refreshStatusUpstreamIfStale(cwd).pipe( Effect.catchIf(isMissingGitCwdError, () => Effect.void), @@ -1338,7 +1331,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); - const status: GitVcsDriverShape["status"] = (input) => + const status: GitVcsDriver.GitVcsDriverShape["status"] = (input) => statusDetails(input.cwd).pipe( Effect.map((details) => ({ isRepo: details.isRepo, @@ -1355,7 +1348,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* })), ); - const prepareCommitContext: GitVcsDriverShape["prepareCommitContext"] = Effect.fn( + const prepareCommitContext: GitVcsDriver.GitVcsDriverShape["prepareCommitContext"] = Effect.fn( "prepareCommitContext", )(function* (cwd, filePaths) { if (filePaths && filePaths.length > 0) { @@ -1397,11 +1390,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const commit: GitVcsDriverShape["commit"] = Effect.fn("commit")(function* ( + const commit: GitVcsDriver.GitVcsDriverShape["commit"] = Effect.fn("commit")(function* ( cwd, subject, body, - options?: GitCommitOptions, + options?: GitVcsDriver.GitCommitOptions, ) { const args = ["commit", "-m", subject]; const trimmedBody = body.trim(); @@ -1430,529 +1423,529 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { commitSha }; }); - const pushCurrentBranch: GitVcsDriverShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")( - function* (cwd, fallbackBranch, options) { - const details = yield* statusDetails(cwd); - const branch = details.branch ?? fallbackBranch; - if (!branch) { - return yield* createGitCommandError( - "GitVcsDriver.pushCurrentBranch", - cwd, - ["push"], - "Cannot push from detached HEAD.", - ); - } + const pushCurrentBranch: GitVcsDriver.GitVcsDriverShape["pushCurrentBranch"] = Effect.fn( + "pushCurrentBranch", + )(function* (cwd, fallbackBranch, options) { + const details = yield* statusDetails(cwd); + const branch = details.branch ?? fallbackBranch; + if (!branch) { + return yield* createGitCommandError( + "GitVcsDriver.pushCurrentBranch", + cwd, + ["push"], + "Cannot push from detached HEAD.", + ); + } + + const requestedRemoteName = options?.remoteName?.trim() || null; + if (requestedRemoteName) { + const publishBranch = yield* resolvePublishBranchName(cwd, branch); + yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", cwd, [ + "push", + "-u", + requestedRemoteName, + `HEAD:refs/heads/${publishBranch}`, + ]); + return { + status: "pushed" as const, + branch, + upstreamBranch: `${requestedRemoteName}/${publishBranch}`, + setUpstream: true, + }; + } - const requestedRemoteName = options?.remoteName?.trim() || null; - if (requestedRemoteName) { - const publishBranch = yield* resolvePublishBranchName(cwd, branch); - yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", cwd, [ - "push", - "-u", - requestedRemoteName, - `HEAD:refs/heads/${publishBranch}`, - ]); + const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; + if (hasNoLocalDelta) { + if (details.hasUpstream) { return { - status: "pushed" as const, + status: "skipped_up_to_date" as const, branch, - upstreamBranch: `${requestedRemoteName}/${publishBranch}`, - setUpstream: true, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), }; } - const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; - if (hasNoLocalDelta) { - if (details.hasUpstream) { + const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (comparableBaseBranch) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (!publishRemoteName) { return { status: "skipped_up_to_date" as const, branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), }; } - const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( - Effect.catch(() => Effect.succeed(null)), + const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( + Effect.catch(() => Effect.succeed(false)), ); - if (comparableBaseBranch) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( - Effect.catch(() => Effect.succeed(null)), - ); - if (!publishRemoteName) { - return { - status: "skipped_up_to_date" as const, - branch, - }; - } - - const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( - Effect.catch(() => Effect.succeed(false)), - ); - if (hasRemoteBranch) { - return { - status: "skipped_up_to_date" as const, - branch, - }; - } - } - } - - if (!details.hasUpstream) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); - if (!publishRemoteName) { - return yield* createGitCommandError( - "GitVcsDriver.pushCurrentBranch", - cwd, - ["push"], - "Cannot push because no git remote is configured for this repository.", - ); + if (hasRemoteBranch) { + return { + status: "skipped_up_to_date" as const, + branch, + }; } - const publishBranch = yield* resolvePublishBranchName(cwd, branch); - yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [ - "push", - "-u", - publishRemoteName, - `HEAD:refs/heads/${publishBranch}`, - ]); - return { - status: "pushed" as const, - branch, - upstreamBranch: `${publishRemoteName}/${publishBranch}`, - setUpstream: true, - }; } + } - const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( - Effect.catch(() => Effect.succeed(null)), - ); - if (currentUpstream) { - yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [ - "push", - currentUpstream.remoteName, - `HEAD:refs/heads/${currentUpstream.branchName}`, - ]); - return { - status: "pushed" as const, - branch, - upstreamBranch: currentUpstream.upstreamRef, - setUpstream: false, - }; + if (!details.hasUpstream) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); + if (!publishRemoteName) { + return yield* createGitCommandError( + "GitVcsDriver.pushCurrentBranch", + cwd, + ["push"], + "Cannot push because no git remote is configured for this repository.", + ); } + const publishBranch = yield* resolvePublishBranchName(cwd, branch); + yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [ + "push", + "-u", + publishRemoteName, + `HEAD:refs/heads/${publishBranch}`, + ]); + return { + status: "pushed" as const, + branch, + upstreamBranch: `${publishRemoteName}/${publishBranch}`, + setUpstream: true, + }; + } - yield* runGit("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"]); + const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (currentUpstream) { + yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [ + "push", + currentUpstream.remoteName, + `HEAD:refs/heads/${currentUpstream.branchName}`, + ]); return { status: "pushed" as const, branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + upstreamBranch: currentUpstream.upstreamRef, setUpstream: false, }; - }, - ); + } - const pullCurrentBranch: GitVcsDriverShape["pullCurrentBranch"] = Effect.fn("pullCurrentBranch")( - function* (cwd) { - const details = yield* statusDetails(cwd); - const refName = details.branch; - if (!refName) { - return yield* createGitCommandError( - "GitVcsDriver.pullCurrentBranch", - cwd, - ["pull", "--ff-only"], - "Cannot pull from detached HEAD.", - ); - } - if (!details.hasUpstream) { - return yield* createGitCommandError( - "GitVcsDriver.pullCurrentBranch", - cwd, - ["pull", "--ff-only"], - "Current branch has no upstream configured. Push with upstream first.", - ); - } - const beforeSha = yield* runGitStdout( - "GitVcsDriver.pullCurrentBranch.beforeSha", + yield* runGit("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"]); + return { + status: "pushed" as const, + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + setUpstream: false, + }; + }); + + const pullCurrentBranch: GitVcsDriver.GitVcsDriverShape["pullCurrentBranch"] = Effect.fn( + "pullCurrentBranch", + )(function* (cwd) { + const details = yield* statusDetails(cwd); + const refName = details.branch; + if (!refName) { + return yield* createGitCommandError( + "GitVcsDriver.pullCurrentBranch", cwd, - ["rev-parse", "HEAD"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { - timeoutMs: 30_000, - fallbackErrorMessage: "git pull failed", - }); - const afterSha = yield* runGitStdout( - "GitVcsDriver.pullCurrentBranch.afterSha", + ["pull", "--ff-only"], + "Cannot pull from detached HEAD.", + ); + } + if (!details.hasUpstream) { + return yield* createGitCommandError( + "GitVcsDriver.pullCurrentBranch", cwd, - ["rev-parse", "HEAD"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); + ["pull", "--ff-only"], + "Current branch has no upstream configured. Push with upstream first.", + ); + } + const beforeSha = yield* runGitStdout( + "GitVcsDriver.pullCurrentBranch.beforeSha", + cwd, + ["rev-parse", "HEAD"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { + timeoutMs: 30_000, + fallbackErrorMessage: "git pull failed", + }); + const afterSha = yield* runGitStdout( + "GitVcsDriver.pullCurrentBranch.afterSha", + cwd, + ["rev-parse", "HEAD"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); - const refreshed = yield* statusDetails(cwd); - return { - status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", - refName, - upstreamRef: refreshed.upstreamRef, - }; - }, - ); + const refreshed = yield* statusDetails(cwd); + return { + status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", + refName, + upstreamRef: refreshed.upstreamRef, + }; + }); - const readRangeContext: GitVcsDriverShape["readRangeContext"] = Effect.fn("readRangeContext")( - function* (cwd, baseRef) { - const range = `${baseRef}..HEAD`; - const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( - [ - runGitStdoutWithOptions( - "GitVcsDriver.readRangeContext.log", - cwd, - ["log", "--oneline", range], - { - maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ), - runGitStdoutWithOptions( - "GitVcsDriver.readRangeContext.diffStat", - cwd, - ["diff", "--stat", range], - { - maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ), - runGitStdoutWithOptions( - "GitVcsDriver.readRangeContext.diffPatch", - cwd, - ["diff", "--patch", "--minimal", range], - { - maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ), - ], - { concurrency: "unbounded" }, - ); + const readRangeContext: GitVcsDriver.GitVcsDriverShape["readRangeContext"] = Effect.fn( + "readRangeContext", + )(function* (cwd, baseRef) { + const range = `${baseRef}..HEAD`; + const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( + [ + runGitStdoutWithOptions( + "GitVcsDriver.readRangeContext.log", + cwd, + ["log", "--oneline", range], + { + maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ), + runGitStdoutWithOptions( + "GitVcsDriver.readRangeContext.diffStat", + cwd, + ["diff", "--stat", range], + { + maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ), + runGitStdoutWithOptions( + "GitVcsDriver.readRangeContext.diffPatch", + cwd, + ["diff", "--patch", "--minimal", range], + { + maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ), + ], + { concurrency: "unbounded" }, + ); - return { - commitSummary, - diffSummary, - diffPatch, - }; - }, - ); + return { + commitSummary, + diffSummary, + diffPatch, + }; + }); - const readConfigValue: GitVcsDriverShape["readConfigValue"] = (cwd, key) => + const readConfigValue: GitVcsDriver.GitVcsDriverShape["readConfigValue"] = (cwd, key) => runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe( Effect.map((stdout) => stdout.trim()), Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), ); - const listRefs: GitVcsDriverShape["listRefs"] = Effect.fn("listRefs")(function* (input) { - const branchRecencyPromise = readBranchRecency(input.cwd).pipe( - Effect.catch(() => Effect.succeed(new Map())), - ); - const localBranchResult = yield* executeGit( - "GitVcsDriver.listRefs.branchNoColor", - input.cwd, - ["branch", "--no-color", "--no-column"], - { - timeoutMs: 10_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catchIf(isMissingGitCwdError, () => - Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(128), - stdout: "", - stderr: "fatal: not a git repository", - stdoutTruncated: false, - stderrTruncated: false, - }), - ), - ); - - if (localBranchResult.exitCode !== 0) { - const stderr = localBranchResult.stderr.trim(); - if (stderr.toLowerCase().includes("not a git repository")) { - return { - refs: [], - isRepo: false, - hasPrimaryRemote: false, - nextCursor: null, - totalCount: 0, - }; - } - return yield* createGitCommandError( - "GitVcsDriver.listRefs", + const listRefs: GitVcsDriver.GitVcsDriverShape["listRefs"] = Effect.fn("listRefs")( + function* (input) { + const branchRecencyPromise = readBranchRecency(input.cwd).pipe( + Effect.catch(() => Effect.succeed(new Map())), + ); + const localBranchResult = yield* executeGit( + "GitVcsDriver.listRefs.branchNoColor", input.cwd, ["branch", "--no-color", "--no-column"], - stderr || "git branch failed", - ); - } - - const remoteBranchResultEffect = executeGit( - "GitVcsDriver.listRefs.remoteBranches", - input.cwd, - ["branch", "--no-color", "--no-column", "--remotes"], - { - timeoutMs: 10_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitVcsDriver.listRefs: remote refName lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote refName list.`, - ).pipe( - Effect.as({ - exitCode: ChildProcessSpawner.ExitCode(1), + { + timeoutMs: 10_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catchIf(isMissingGitCwdError, () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(128), stdout: "", - stderr: "", + stderr: "fatal: not a git repository", stdoutTruncated: false, stderrTruncated: false, - } satisfies ExecuteGitResult), + }), ), - ), - ); + ); - const remoteNamesResultEffect = executeGit( - "GitVcsDriver.listRefs.remoteNames", - input.cwd, - ["remote"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitVcsDriver.listRefs: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, - ).pipe( - Effect.as({ - exitCode: ChildProcessSpawner.ExitCode(1), - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - } satisfies ExecuteGitResult), - ), - ), - ); + if (localBranchResult.exitCode !== 0) { + const stderr = localBranchResult.stderr.trim(); + if (stderr.toLowerCase().includes("not a git repository")) { + return { + refs: [], + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 0, + }; + } + return yield* createGitCommandError( + "GitVcsDriver.listRefs", + input.cwd, + ["branch", "--no-color", "--no-column"], + stderr || "git branch failed", + ); + } - const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = - yield* Effect.all( - [ - executeGit( - "GitVcsDriver.listRefs.defaultRef", - input.cwd, - ["symbolic-ref", "refs/remotes/origin/HEAD"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ), - executeGit( - "GitVcsDriver.listRefs.worktreeList", - input.cwd, - ["worktree", "list", "--porcelain"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, + const remoteBranchResultEffect = executeGit( + "GitVcsDriver.listRefs.remoteBranches", + input.cwd, + ["branch", "--no-color", "--no-column", "--remotes"], + { + timeoutMs: 10_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catch((error) => + Effect.logWarning( + `GitVcsDriver.listRefs: remote refName lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote refName list.`, + ).pipe( + Effect.as({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } satisfies GitVcsDriver.ExecuteGitResult), ), - remoteBranchResultEffect, - remoteNamesResultEffect, - branchRecencyPromise, - ], - { concurrency: "unbounded" }, + ), ); - const remoteNames = - remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; - if (remoteBranchResult.exitCode !== 0 && remoteBranchResult.stderr.trim().length > 0) { - yield* Effect.logWarning( - `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.exitCode} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`, - ); - } - if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) { - yield* Effect.logWarning( - `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, + const remoteNamesResultEffect = executeGit( + "GitVcsDriver.listRefs.remoteNames", + input.cwd, + ["remote"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catch((error) => + Effect.logWarning( + `GitVcsDriver.listRefs: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, + ).pipe( + Effect.as({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } satisfies GitVcsDriver.ExecuteGitResult), + ), + ), ); - } - const defaultBranch = - defaultRef.exitCode === 0 - ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") - : null; + const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = + yield* Effect.all( + [ + executeGit( + "GitVcsDriver.listRefs.defaultRef", + input.cwd, + ["symbolic-ref", "refs/remotes/origin/HEAD"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + executeGit( + "GitVcsDriver.listRefs.worktreeList", + input.cwd, + ["worktree", "list", "--porcelain"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + remoteBranchResultEffect, + remoteNamesResultEffect, + branchRecencyPromise, + ], + { concurrency: "unbounded" }, + ); - const worktreeMap = new Map(); - if (worktreeList.exitCode === 0) { - let currentPath: string | null = null; - for (const line of worktreeList.stdout.split("\n")) { - if (line.startsWith("worktree ")) { - const candidatePath = line.slice("worktree ".length); - const exists = yield* fileSystem.stat(candidatePath).pipe( - Effect.map(() => true), - Effect.catch(() => Effect.succeed(false)), - ); - currentPath = exists ? candidatePath : null; - } else if (line.startsWith("branch refs/heads/") && currentPath) { - worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); - } else if (line === "") { - currentPath = null; - } + const remoteNames = + remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; + if (remoteBranchResult.exitCode !== 0 && remoteBranchResult.stderr.trim().length > 0) { + yield* Effect.logWarning( + `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.exitCode} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`, + ); + } + if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) { + yield* Effect.logWarning( + `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, + ); } - } - - const localBranches = localBranchResult.stdout - .split("\n") - .map(parseBranchLine) - .filter((refName): refName is { name: string; current: boolean } => refName !== null) - .map((refName) => ({ - name: refName.name, - current: refName.current, - isRemote: false, - isDefault: refName.name === defaultBranch, - worktreePath: worktreeMap.get(refName.name) ?? null, - })) - .toSorted((a, b) => { - const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; - const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; - if (aPriority !== bPriority) return aPriority - bPriority; - - const aLastCommit = branchLastCommit.get(a.name) ?? 0; - const bLastCommit = branchLastCommit.get(b.name) ?? 0; - if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; - return a.name.localeCompare(b.name); - }); - const remoteBranches = - remoteBranchResult.exitCode === 0 - ? remoteBranchResult.stdout - .split("\n") - .map(parseBranchLine) - .filter((refName): refName is { name: string; current: boolean } => refName !== null) - .map((refName) => { - const parsedRemoteRef = parseRemoteRefWithRemoteNames(refName.name, remoteNames); - const remoteBranch: { - name: string; - current: boolean; - isRemote: boolean; - remoteName?: string; - isDefault: boolean; - worktreePath: string | null; - } = { - name: refName.name, - current: false, - isRemote: true, - isDefault: false, - worktreePath: null, - }; - if (parsedRemoteRef) { - remoteBranch.remoteName = parsedRemoteRef.remoteName; - } - return remoteBranch; - }) - .toSorted((a, b) => { - const aLastCommit = branchLastCommit.get(a.name) ?? 0; - const bLastCommit = branchLastCommit.get(b.name) ?? 0; - if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; - return a.name.localeCompare(b.name); - }) - : []; - - const refs = paginateBranches({ - refs: filterBranchesForListQuery( - dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), - input.query, - ), - cursor: input.cursor, - limit: input.limit, - }); + const defaultBranch = + defaultRef.exitCode === 0 + ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") + : null; + + const worktreeMap = new Map(); + if (worktreeList.exitCode === 0) { + let currentPath: string | null = null; + for (const line of worktreeList.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + const candidatePath = line.slice("worktree ".length); + const exists = yield* fileSystem.stat(candidatePath).pipe( + Effect.map(() => true), + Effect.catch(() => Effect.succeed(false)), + ); + currentPath = exists ? candidatePath : null; + } else if (line.startsWith("branch refs/heads/") && currentPath) { + worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); + } else if (line === "") { + currentPath = null; + } + } + } - return { - refs: [...refs.refs], - isRepo: true, - hasPrimaryRemote: remoteNames.includes("origin"), - nextCursor: refs.nextCursor, - totalCount: refs.totalCount, - }; - }); + const localBranches = localBranchResult.stdout + .split("\n") + .map(parseBranchLine) + .filter((refName): refName is { name: string; current: boolean } => refName !== null) + .map((refName) => ({ + name: refName.name, + current: refName.current, + isRemote: false, + isDefault: refName.name === defaultBranch, + worktreePath: worktreeMap.get(refName.name) ?? null, + })) + .toSorted((a, b) => { + const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; + const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; + if (aPriority !== bPriority) return aPriority - bPriority; + + const aLastCommit = branchLastCommit.get(a.name) ?? 0; + const bLastCommit = branchLastCommit.get(b.name) ?? 0; + if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; + return a.name.localeCompare(b.name); + }); - const createWorktree: GitVcsDriverShape["createWorktree"] = Effect.fn("createWorktree")( - function* (input) { - const targetBranch = input.newRefName ?? input.refName; - const sanitizedBranch = targetBranch.replace(/\//g, "-"); - const repoName = path.basename(input.cwd); - const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); - const args = input.newRefName - ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] - : ["worktree", "add", worktreePath, input.refName]; - - yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { - fallbackErrorMessage: "git worktree add failed", + const remoteBranches = + remoteBranchResult.exitCode === 0 + ? remoteBranchResult.stdout + .split("\n") + .map(parseBranchLine) + .filter((refName): refName is { name: string; current: boolean } => refName !== null) + .map((refName) => { + const parsedRemoteRef = parseRemoteRefWithRemoteNames(refName.name, remoteNames); + const remoteBranch: { + name: string; + current: boolean; + isRemote: boolean; + remoteName?: string; + isDefault: boolean; + worktreePath: string | null; + } = { + name: refName.name, + current: false, + isRemote: true, + isDefault: false, + worktreePath: null, + }; + if (parsedRemoteRef) { + remoteBranch.remoteName = parsedRemoteRef.remoteName; + } + return remoteBranch; + }) + .toSorted((a, b) => { + const aLastCommit = branchLastCommit.get(a.name) ?? 0; + const bLastCommit = branchLastCommit.get(b.name) ?? 0; + if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; + return a.name.localeCompare(b.name); + }) + : []; + + const refs = paginateBranches({ + refs: filterBranchesForListQuery( + dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), + input.query, + ), + cursor: input.cursor, + limit: input.limit, }); return { - worktree: { - path: worktreePath, - refName: targetBranch, - }, + refs: [...refs.refs], + isRepo: true, + hasPrimaryRemote: remoteNames.includes("origin"), + nextCursor: refs.nextCursor, + totalCount: refs.totalCount, }; }, ); - const fetchPullRequestBranch: GitVcsDriverShape["fetchPullRequestBranch"] = Effect.fn( - "fetchPullRequestBranch", + const createWorktree: GitVcsDriver.GitVcsDriverShape["createWorktree"] = Effect.fn( + "createWorktree", )(function* (input) { - const remoteName = yield* resolvePrimaryRemoteName(input.cwd); - yield* executeGit( - "GitVcsDriver.fetchPullRequestBranch", - input.cwd, - [ - "fetch", - "--quiet", - "--no-tags", - remoteName, - `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, - ], - { - fallbackErrorMessage: "git fetch pull request branch failed", + const targetBranch = input.newRefName ?? input.refName; + const sanitizedBranch = targetBranch.replace(/\//g, "-"); + const repoName = path.basename(input.cwd); + const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); + const args = input.newRefName + ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] + : ["worktree", "add", worktreePath, input.refName]; + + yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { + fallbackErrorMessage: "git worktree add failed", + }); + + return { + worktree: { + path: worktreePath, + refName: targetBranch, }, - ); + }; }); - const fetchRemoteBranch: GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn("fetchRemoteBranch")( - function* (input) { - yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [ - "fetch", - "--quiet", - "--no-tags", - input.remoteName, - `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, - ]); - - const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); - const targetRef = `${input.remoteName}/${input.remoteBranch}`; - yield* runGit( - "GitVcsDriver.fetchRemoteBranch.materialize", + const fetchPullRequestBranch: GitVcsDriver.GitVcsDriverShape["fetchPullRequestBranch"] = + Effect.fn("fetchPullRequestBranch")(function* (input) { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + yield* executeGit( + "GitVcsDriver.fetchPullRequestBranch", input.cwd, - localBranchAlreadyExists - ? ["branch", "--force", input.localBranch, targetRef] - : ["branch", input.localBranch, targetRef], + [ + "fetch", + "--quiet", + "--no-tags", + remoteName, + `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, + ], + { + fallbackErrorMessage: "git fetch pull request branch failed", + }, ); - }, - ); + }); - const fetchRemoteTrackingBranch: GitVcsDriverShape["fetchRemoteTrackingBranch"] = Effect.fn( - "fetchRemoteTrackingBranch", + const fetchRemoteBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn( + "fetchRemoteBranch", )(function* (input) { - yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [ + yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [ "fetch", "--quiet", "--no-tags", input.remoteName, `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, ]); + + const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); + const targetRef = `${input.remoteName}/${input.remoteBranch}`; + yield* runGit( + "GitVcsDriver.fetchRemoteBranch.materialize", + input.cwd, + localBranchAlreadyExists + ? ["branch", "--force", input.localBranch, targetRef] + : ["branch", input.localBranch, targetRef], + ); }); - const setBranchUpstream: GitVcsDriverShape["setBranchUpstream"] = (input) => + const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteTrackingBranch"] = + Effect.fn("fetchRemoteTrackingBranch")(function* (input) { + yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [ + "fetch", + "--quiet", + "--no-tags", + input.remoteName, + `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, + ]); + }); + + const setBranchUpstream: GitVcsDriver.GitVcsDriverShape["setBranchUpstream"] = (input) => runGit("GitVcsDriver.setBranchUpstream", input.cwd, [ "branch", "--set-upstream-to", @@ -1960,31 +1953,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.branch, ]); - const removeWorktree: GitVcsDriverShape["removeWorktree"] = Effect.fn("removeWorktree")( - function* (input) { - const args = ["worktree", "remove"]; - if (input.force) { - args.push("--force"); - } - args.push(input.path); - yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { - timeoutMs: 15_000, - fallbackErrorMessage: "git worktree remove failed", - }).pipe( - Effect.mapError((error) => - createGitCommandError( - "GitVcsDriver.removeWorktree", - input.cwd, - args, - `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, - error, - ), + const removeWorktree: GitVcsDriver.GitVcsDriverShape["removeWorktree"] = Effect.fn( + "removeWorktree", + )(function* (input) { + const args = ["worktree", "remove"]; + if (input.force) { + args.push("--force"); + } + args.push(input.path); + yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { + timeoutMs: 15_000, + fallbackErrorMessage: "git worktree remove failed", + }).pipe( + Effect.mapError((error) => + createGitCommandError( + "GitVcsDriver.removeWorktree", + input.cwd, + args, + `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, + error, ), - ); - }, - ); + ), + ); + }); - const renameBranch: GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")( + const renameBranch: GitVcsDriver.GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")( function* (input) { if (input.oldBranch === input.newBranch) { return { branch: input.newBranch }; @@ -2005,105 +1998,109 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); - const switchRef: GitVcsDriverShape["switchRef"] = Effect.fn("switchRef")(function* (input) { - const [localInputExists, remoteExists] = yield* Effect.all( - [ - executeGit( - "GitVcsDriver.switchRef.localInputExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.exitCode === 0)), - executeGit( - "GitVcsDriver.switchRef.remoteExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.exitCode === 0)), - ], - { concurrency: "unbounded" }, - ); - - const localTrackingBranch = remoteExists - ? yield* executeGit( - "GitVcsDriver.switchRef.localTrackingBranch", - input.cwd, - ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.map((result) => - result.exitCode === 0 - ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName) - : null, - ), - ) - : null; + const switchRef: GitVcsDriver.GitVcsDriverShape["switchRef"] = Effect.fn("switchRef")( + function* (input) { + const [localInputExists, remoteExists] = yield* Effect.all( + [ + executeGit( + "GitVcsDriver.switchRef.localInputExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)), + executeGit( + "GitVcsDriver.switchRef.remoteExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)), + ], + { concurrency: "unbounded" }, + ); - const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName); - const localTrackedBranchTargetExists = - remoteExists && localTrackedBranchCandidate + const localTrackingBranch = remoteExists ? yield* executeGit( - "GitVcsDriver.switchRef.localTrackedBranchTargetExists", + "GitVcsDriver.switchRef.localTrackingBranch", input.cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], + ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], { timeoutMs: 5_000, allowNonZeroExit: true, }, - ).pipe(Effect.map((result) => result.exitCode === 0)) - : false; + ).pipe( + Effect.map((result) => + result.exitCode === 0 + ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName) + : null, + ), + ) + : null; - const checkoutArgs = localInputExists - ? ["checkout", input.refName] - : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists - ? ["checkout", input.refName] - : remoteExists && !localTrackingBranch - ? ["checkout", "--track", input.refName] - : remoteExists && localTrackingBranch - ? ["checkout", localTrackingBranch] - : ["checkout", input.refName]; + const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName); + const localTrackedBranchTargetExists = + remoteExists && localTrackedBranchCandidate + ? yield* executeGit( + "GitVcsDriver.switchRef.localTrackedBranchTargetExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)) + : false; - yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { - timeoutMs: 10_000, - fallbackErrorMessage: "git checkout failed", - }); + const checkoutArgs = localInputExists + ? ["checkout", input.refName] + : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists + ? ["checkout", input.refName] + : remoteExists && !localTrackingBranch + ? ["checkout", "--track", input.refName] + : remoteExists && localTrackingBranch + ? ["checkout", localTrackingBranch] + : ["checkout", input.refName]; + + yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { + timeoutMs: 10_000, + fallbackErrorMessage: "git checkout failed", + }); - const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ - "branch", - "--show-current", - ]).pipe(Effect.map((stdout) => stdout.trim() || null)); + const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ + "branch", + "--show-current", + ]).pipe(Effect.map((stdout) => stdout.trim() || null)); - return { refName }; - }); + return { refName }; + }, + ); - const createRef: GitVcsDriverShape["createRef"] = Effect.fn("createRef")(function* (input) { - yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], { - timeoutMs: 10_000, - fallbackErrorMessage: "git branch create failed", - }); - if (input.switchRef) { - yield* switchRef({ cwd: input.cwd, refName: input.refName }); - } + const createRef: GitVcsDriver.GitVcsDriverShape["createRef"] = Effect.fn("createRef")( + function* (input) { + yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], { + timeoutMs: 10_000, + fallbackErrorMessage: "git branch create failed", + }); + if (input.switchRef) { + yield* switchRef({ cwd: input.cwd, refName: input.refName }); + } - return { refName: input.refName }; - }); + return { refName: input.refName }; + }, + ); - const initRepo: GitVcsDriverShape["initRepo"] = (input) => + const initRepo: GitVcsDriver.GitVcsDriverShape["initRepo"] = (input) => executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], { timeoutMs: 10_000, fallbackErrorMessage: "git init failed", }).pipe(Effect.asVoid); - const listLocalBranchNames: GitVcsDriverShape["listLocalBranchNames"] = (cwd) => + const listLocalBranchNames: GitVcsDriver.GitVcsDriverShape["listLocalBranchNames"] = (cwd) => runGitStdout("GitVcsDriver.listLocalBranchNames", cwd, [ "branch", "--list", @@ -2118,7 +2115,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); - return { + return GitVcsDriver.GitVcsDriver.of({ execute, status, statusDetails, @@ -2143,5 +2140,5 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* switchRef, initRepo, listLocalBranchNames, - } satisfies GitVcsDriverShape; + }); }); diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index 14e5f68ef4..ae09d840f8 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -8,13 +8,13 @@ import type { VcsListWorkspaceFilesResult, VcsRepositoryIdentity, } from "@t3tools/contracts"; -import type { VcsProcessInput, VcsProcessOutput } from "./VcsProcess.ts"; +import * as VcsProcess from "./VcsProcess.ts"; export interface VcsDriverShape { readonly capabilities: VcsDriverCapabilities; readonly execute: ( - input: Omit, - ) => Effect.Effect; + input: Omit, + ) => Effect.Effect; readonly detectRepository: (cwd: string) => Effect.Effect; readonly isInsideWorkTree: (cwd: string) => Effect.Effect; readonly listWorkspaceFiles: ( diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index 8e482c46b8..b9330c885a 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -1,13 +1,12 @@ -import { assert, it } from "@effect/vitest"; +import { assert, it, describe } from "@effect/vitest"; import { Effect, Layer } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { describe } from "vitest"; -import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "./VcsProcess.ts"; -import { VcsProjectConfig } from "./VcsProjectConfig.ts"; -import { VcsDriverRegistry, make as makeVcsDriverRegistry } from "./VcsDriverRegistry.ts"; +import * as VcsProcess from "./VcsProcess.ts"; +import * as VcsProjectConfig from "./VcsProjectConfig.ts"; +import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; -const processOutput = (stdout: string): VcsProcessOutput => ({ +const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ exitCode: ChildProcessSpawner.ExitCode(0), stdout, stderr: "", @@ -17,21 +16,21 @@ const processOutput = (stdout: string): VcsProcessOutput => ({ describe("VcsDriverRegistry", () => { it.effect("routes directly by VCS driver kind for non-repository workflows", () => { - const layer = Layer.effect(VcsDriverRegistry, makeVcsDriverRegistry()).pipe( + const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe( Layer.provide( - Layer.mock(VcsProjectConfig)({ + Layer.mock(VcsProjectConfig.VcsProjectConfig)({ resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), }), ), Layer.provide( - Layer.mock(VcsProcess)({ + Layer.mock(VcsProcess.VcsProcess)({ run: () => Effect.succeed(processOutput("")), }), ), ); return Effect.gen(function* () { - const registry = yield* VcsDriverRegistry; + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; const driver = yield* registry.get("git"); assert.strictEqual(driver.capabilities.kind, "git"); @@ -39,15 +38,15 @@ describe("VcsDriverRegistry", () => { }); it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => { - const calls: VcsProcessInput[] = []; - const layer = Layer.effect(VcsDriverRegistry, makeVcsDriverRegistry()).pipe( + const calls: VcsProcess.VcsProcessInput[] = []; + const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe( Layer.provide( - Layer.mock(VcsProjectConfig)({ + Layer.mock(VcsProjectConfig.VcsProjectConfig)({ resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), }), ), Layer.provide( - Layer.mock(VcsProcess)({ + Layer.mock(VcsProcess.VcsProcess)({ run: (input) => Effect.sync(() => { calls.push(input); @@ -68,7 +67,7 @@ describe("VcsDriverRegistry", () => { ); return Effect.gen(function* () { - const registry = yield* VcsDriverRegistry; + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; const first = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); const second = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index 7798d63a08..b29fa43ed8 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -4,7 +4,7 @@ import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/co import { VcsUnsupportedOperationError } from "@t3tools/contracts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import * as VcsProjectConfig from "./VcsProjectConfig.ts"; -import type { VcsDriverShape } from "./VcsDriver.ts"; +import * as VcsDriver from "./VcsDriver.ts"; const DETECTION_CACHE_CAPACITY = 2_048; const DETECTION_CACHE_TTL = Duration.seconds(2); @@ -17,11 +17,11 @@ export interface VcsDriverResolveInput { export interface VcsDriverHandle { readonly kind: VcsDriverKind; readonly repository: VcsRepositoryIdentity; - readonly driver: VcsDriverShape; + readonly driver: VcsDriver.VcsDriverShape; } export interface VcsDriverRegistryShape { - readonly get: (kind: VcsDriverKind) => Effect.Effect; + readonly get: (kind: VcsDriverKind) => Effect.Effect; readonly detect: ( input: VcsDriverResolveInput, ) => Effect.Effect; @@ -66,7 +66,7 @@ function parseDetectionCacheKey(key: string): { export const make = Effect.fn("makeVcsDriverRegistry")(function* () { const projectConfig = yield* VcsProjectConfig.VcsProjectConfig; const git = yield* GitVcsDriver.makeVcsDriverShape(); - const drivers: Partial> = { + const drivers: Partial> = { git, }; @@ -82,7 +82,7 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* ( kind: VcsDriverKind, - driver: VcsDriverShape, + driver: VcsDriver.VcsDriverShape, cwd: string, ) { const repository = yield* driver.detectRepository(cwd); diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index 95181e94fb..b08b571673 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -1,11 +1,10 @@ -import { assert, it } from "@effect/vitest"; +import { assert, it, describe } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { Effect, FileSystem, Layer, Path } from "effect"; -import { describe } from "vitest"; -import { VcsProjectConfig, layer as VcsProjectConfigLayer } from "./VcsProjectConfig.ts"; +import * as VcsProjectConfig from "./VcsProjectConfig.ts"; -const TestLayer = VcsProjectConfigLayer.pipe( +const TestLayer = VcsProjectConfig.layer.pipe( Layer.provide(NodeServices.layer), Layer.provideMerge(NodeServices.layer), ); @@ -14,7 +13,7 @@ describe("VcsProjectConfig", () => { it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => { it.effect("returns the requested kind", () => Effect.gen(function* () { - const config = yield* VcsProjectConfig; + const config = yield* VcsProjectConfig.VcsProjectConfig; const kind = yield* config.resolveKind({ cwd: "/repo", requestedKind: "jj", @@ -42,7 +41,7 @@ describe("VcsProjectConfig", () => { JSON.stringify({ vcs: { kind: "jj" } }), ); - const config = yield* VcsProjectConfig; + const config = yield* VcsProjectConfig.VcsProjectConfig; const kind = yield* config.resolveKind({ cwd: nested }); assert.equal(kind, "jj"); @@ -57,7 +56,7 @@ describe("VcsProjectConfig", () => { const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-vcs-config-test-", }); - const config = yield* VcsProjectConfig; + const config = yield* VcsProjectConfig.VcsProjectConfig; const kind = yield* config.resolveKind({ cwd: root }); assert.equal(kind, "auto"); diff --git a/apps/server/src/vcs/VcsProvisioningService.test.ts b/apps/server/src/vcs/VcsProvisioningService.test.ts index 5f936e7a1d..b26f331a3c 100644 --- a/apps/server/src/vcs/VcsProvisioningService.test.ts +++ b/apps/server/src/vcs/VcsProvisioningService.test.ts @@ -2,13 +2,13 @@ import { assert, it } from "@effect/vitest"; import { DateTime, Effect, Layer, Option } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import type { VcsDriverShape } from "./VcsDriver.ts"; -import { VcsDriverRegistry } from "./VcsDriverRegistry.ts"; -import { VcsProvisioningService, layer } from "./VcsProvisioningService.ts"; +import * as VcsDriver from "./VcsDriver.ts"; +import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; +import * as VcsProvisioningService from "./VcsProvisioningService.ts"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); -function makeDriver(calls: string[]): VcsDriverShape { +function makeDriver(calls: string[]): VcsDriver.VcsDriverShape { return { capabilities: { kind: "git", @@ -58,16 +58,16 @@ function makeDriver(calls: string[]): VcsDriverShape { it.effect("routes repository initialization through an explicit VCS driver kind", () => { const calls: string[] = []; const driver = makeDriver(calls); - const testLayer = layer.pipe( + const testLayer = VcsProvisioningService.layer.pipe( Layer.provide( - Layer.mock(VcsDriverRegistry)({ + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), }), ), ); return Effect.gen(function* () { - const provisioning = yield* VcsProvisioningService; + const provisioning = yield* VcsProvisioningService.VcsProvisioningService; yield* provisioning.initRepository({ cwd: "/repo", kind: "git" }); assert.deepStrictEqual(calls, ["git:/repo"]); @@ -77,16 +77,16 @@ it.effect("routes repository initialization through an explicit VCS driver kind" it.effect("defaults repository initialization to Git until callers choose a VCS kind", () => { const calls: string[] = []; const driver = makeDriver(calls); - const testLayer = layer.pipe( + const testLayer = VcsProvisioningService.layer.pipe( Layer.provide( - Layer.mock(VcsDriverRegistry)({ + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), }), ), ); return Effect.gen(function* () { - const provisioning = yield* VcsProvisioningService; + const provisioning = yield* VcsProvisioningService.VcsProvisioningService; yield* provisioning.initRepository({ cwd: "/repo" }); assert.deepStrictEqual(calls, ["default:/repo"]); diff --git a/apps/server/src/vcs/VcsProvisioningService.ts b/apps/server/src/vcs/VcsProvisioningService.ts index 7d5b93dd14..9f8f822f2a 100644 --- a/apps/server/src/vcs/VcsProvisioningService.ts +++ b/apps/server/src/vcs/VcsProvisioningService.ts @@ -6,7 +6,7 @@ import { type VcsInitInput, VcsUnsupportedOperationError, } from "@t3tools/contracts"; -import { VcsDriverRegistry } from "./VcsDriverRegistry.ts"; +import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; export interface VcsProvisioningServiceShape { readonly initRepository: (input: VcsInitInput) => Effect.Effect; @@ -36,7 +36,7 @@ function resolveRequestedKind( } export const make = Effect.fn("makeVcsProvisioningService")(function* () { - const registry = yield* VcsDriverRegistry; + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; const initRepository: VcsProvisioningServiceShape["initRepository"] = Effect.fn( "VcsProvisioningService.initRepository", diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index e8bcf4bdf0..ca7cccf303 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -1,4 +1,4 @@ -import { assert, it } from "@effect/vitest"; +import { assert, it, describe } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { Deferred, Effect, Exit, FileSystem, Layer, Option, Path, Scope, Stream } from "effect"; import type { @@ -7,13 +7,9 @@ import type { VcsStatusResult, VcsStatusStreamEvent, } from "@t3tools/contracts"; -import { describe } from "vitest"; -import { - VcsStatusBroadcaster, - layer as VcsStatusBroadcasterLayer, -} from "./VcsStatusBroadcaster.ts"; -import { GitWorkflowService, type GitWorkflowServiceShape } from "../git/GitWorkflowService.ts"; +import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; const baseLocalStatus: VcsStatusLocalResult = { isRepo: true, @@ -49,9 +45,9 @@ function makeTestLayer(state: { localInvalidationCalls: number; remoteInvalidationCalls: number; }) { - return VcsStatusBroadcasterLayer.pipe( + return VcsStatusBroadcaster.layer.pipe( Layer.provide( - Layer.mock(GitWorkflowService)({ + Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => Effect.sync(() => { state.localStatusCalls += 1; @@ -87,7 +83,7 @@ describe("VcsStatusBroadcaster", () => { }; return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const first = yield* broadcaster.getStatus({ cwd: "/repo" }); const second = yield* broadcaster.getStatus({ cwd: "/repo" }); @@ -112,7 +108,7 @@ describe("VcsStatusBroadcaster", () => { }; return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); state.currentLocalStatus = { @@ -153,7 +149,7 @@ describe("VcsStatusBroadcaster", () => { }; return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); state.currentLocalStatus = { @@ -188,9 +184,9 @@ describe("VcsStatusBroadcaster", () => { localInvalidationCalls: 0, remoteInvalidationCalls: 0, }; - const testLayer = VcsStatusBroadcasterLayer.pipe( + const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provide( - Layer.mock(GitWorkflowService)({ + Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: (input) => Effect.sync(() => { seenCwds.push(input.cwd); @@ -211,7 +207,7 @@ describe("VcsStatusBroadcaster", () => { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), - } satisfies Partial), + } satisfies Partial), ), ); @@ -228,7 +224,7 @@ describe("VcsStatusBroadcaster", () => { yield* fileSystem.symlink(realDir, linkDir); const realPath = yield* fileSystem.realPath(realDir); - const broadcaster = yield* VcsStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; yield* broadcaster.getStatus({ cwd: linkDir }); yield* broadcaster.getStatus({ cwd: realDir }); @@ -249,7 +245,7 @@ describe("VcsStatusBroadcaster", () => { }; return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const snapshotDeferred = yield* Deferred.make(); const remoteUpdatedDeferred = yield* Deferred.make(); yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { @@ -289,9 +285,9 @@ describe("VcsStatusBroadcaster", () => { }; let remoteInterruptedDeferred: Deferred.Deferred | null = null; let remoteStartedDeferred: Deferred.Deferred | null = null; - const testLayer = VcsStatusBroadcasterLayer.pipe( + const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provide( - Layer.mock(GitWorkflowService)({ + Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => Effect.sync(() => { state.localStatusCalls += 1; @@ -321,7 +317,7 @@ describe("VcsStatusBroadcaster", () => { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), - } satisfies Partial), + } satisfies Partial), ), ); @@ -331,7 +327,7 @@ describe("VcsStatusBroadcaster", () => { remoteInterruptedDeferred = remoteInterrupted; remoteStartedDeferred = remoteStarted; - const broadcaster = yield* VcsStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const firstSnapshot = yield* Deferred.make(); const secondSnapshot = yield* Deferred.make(); const firstScope = yield* Scope.make(); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 028d3f1a1d..1d0cddc4c4 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -1,6 +1,7 @@ import { realpathSync } from "node:fs"; import { + Context, Duration, Effect, Exit, @@ -20,10 +21,9 @@ import type { VcsStatusResult, VcsStatusStreamEvent, } from "@t3tools/contracts"; -import { Context } from "effect"; import { mergeGitStatusParts } from "@t3tools/shared/git"; -import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; const VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); @@ -80,7 +80,7 @@ function normalizeCwd(cwd: string): string { export const layer = Layer.effect( VcsStatusBroadcaster, Effect.gen(function* () { - const workflow = yield* GitWorkflowService; + const workflow = yield* GitWorkflowService.GitWorkflowService; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), (pubsub) => PubSub.shutdown(pubsub), diff --git a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts index c0e195558b..f513f03b75 100644 --- a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts +++ b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts @@ -1,10 +1,17 @@ -import { assert, it } from "@effect/vitest"; -import { DateTime, Option } from "effect"; -import { Effect, FileSystem, Layer, Path, type PlatformError, type Scope } from "effect"; -import { describe } from "vitest"; +import { assert, it, describe } from "@effect/vitest"; +import { + Effect, + FileSystem, + Layer, + Path, + type PlatformError, + type Scope, + DateTime, + Option, +} from "effect"; import type { VcsDriverKind } from "@t3tools/contracts"; -import { VcsDriver } from "../VcsDriver.ts"; +import * as VcsDriver from "../VcsDriver.ts"; export interface VcsDriverFixture { readonly createRepo: (cwd: string) => Effect.Effect; @@ -24,7 +31,11 @@ export interface VcsDriverFixture { export interface VcsDriverContractSuiteInput { readonly name: string; readonly kind: VcsDriverKind; - readonly layer: Layer.Layer; + readonly layer: Layer.Layer< + VcsDriver.VcsDriver | R | FileSystem.FileSystem | Path.Path, + E, + never + >; readonly fixture: VcsDriverFixture; } @@ -42,7 +53,7 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp it.effect("returns null outside a repository", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); - const driver = yield* VcsDriver; + const driver = yield* VcsDriver.VcsDriver; assert.equal(yield* driver.detectRepository(cwd), null); assert.equal(yield* driver.isInsideWorkTree(cwd), false); @@ -52,7 +63,7 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp it.effect("detects repository identity inside a repository and nested directories", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); - const driver = yield* VcsDriver; + const driver = yield* VcsDriver.VcsDriver; yield* input.fixture.createRepo(cwd); yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n"); @@ -77,7 +88,7 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp it.effect("lists tracked and untracked non-ignored files", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); - const driver = yield* VcsDriver; + const driver = yield* VcsDriver.VcsDriver; yield* input.fixture.createRepo(cwd); yield* input.fixture.writeFile(cwd, "tracked.ts", "export const tracked = true;\n"); @@ -101,7 +112,7 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp it.effect("excludes ignored files from workspace listing", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); - const driver = yield* VcsDriver; + const driver = yield* VcsDriver.VcsDriver; yield* input.fixture.createRepo(cwd); yield* input.fixture.ignorePath(cwd, "*.log"); @@ -122,7 +133,7 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp it.effect("filters ignored paths", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); - const driver = yield* VcsDriver; + const driver = yield* VcsDriver.VcsDriver; yield* input.fixture.createRepo(cwd); yield* input.fixture.ignorePath(cwd, "*.log"); @@ -140,7 +151,7 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp it.effect("returns empty input unchanged", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); - const driver = yield* VcsDriver; + const driver = yield* VcsDriver.VcsDriver; yield* input.fixture.createRepo(cwd); From 7185db210f60fc7e52e48b5dd7a21d6839a99b47 Mon Sep 17 00:00:00 2001 From: Julius Date: Sun, 3 May 2026 22:12:37 -0700 Subject: [PATCH 15/24] nit --- .../AzureDevOpsSourceControlProvider.ts | 18 ++---------------- .../server/src/sourceControl/GitHubCli.test.ts | 5 +---- 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 0321f4170b..6e4b1eb68f 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -80,27 +80,13 @@ function toChangeRequest(summary: { }; } -function sourceFromInput(input: { - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; -}): SourceControlProvider.SourceControlRefSelector | undefined { - if (input.source) { - return input.source; - } - - const match = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); - const owner = match?.[1]?.trim(); - const refName = match?.[2]?.trim(); - return owner && refName ? { owner, refName } : undefined; -} - export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* () { const azure = yield* AzureDevOpsCli.AzureDevOpsCli; return SourceControlProvider.SourceControlProvider.of({ kind: "azure-devops", listChangeRequests: (input) => { - const source = sourceFromInput(input); + const source = SourceControlProvider.sourceControlRefFromInput(input); return azure .listPullRequests({ cwd: input.cwd, @@ -120,7 +106,7 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* Effect.mapError((error) => providerError("getChangeRequest", error)), ), createChangeRequest: (input) => { - const source = sourceFromInput(input); + const source = SourceControlProvider.sourceControlRefFromInput(input); return azure .createPullRequest({ cwd: input.cwd, diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 29a24bb49d..778e0c4962 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -14,10 +14,7 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ stderrTruncated: false, }); -const mockRun = - vi.fn< - VcsProcess.VcsProcessShape["run"] - >(); +const mockRun = vi.fn(); const layer = GitHubCli.layer.pipe( Layer.provide( From 7166117faf856d06131125bf2515be2fc49388c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 4 May 2026 05:21:00 +0000 Subject: [PATCH 16/24] refactor: reuse shared parseSourceControlOwnerRef in AzureDevOpsCli normalizeSourceBranch --- apps/server/src/sourceControl/AzureDevOpsCli.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index d8e8c077a0..eefae1cbde 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -7,7 +7,7 @@ import { import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsPullRequests from "./azureDevOpsPullRequests.ts"; -import type * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -158,9 +158,9 @@ function normalizeChangeRequestId(reference: string): string { } function normalizeSourceBranch(headSelector: string): string { - const trimmed = headSelector.trim(); - const ownerSelector = /^([^:/\s]+):(.+)$/u.exec(trimmed); - return ownerSelector?.[2]?.trim() ?? trimmed; + return ( + SourceControlProvider.parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim() + ); } function sourceBranch(input: { From 43a46a5bdd9e089ee70617e99b59a595416a4682 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 4 May 2026 05:47:08 +0000 Subject: [PATCH 17/24] Extract duplicated normalizeSourceBranch/sourceBranch helpers to SourceControlProvider Move identical normalizeSourceBranch and sourceBranch helper functions from AzureDevOpsCli.ts and BitbucketApi.ts into the shared SourceControlProvider.ts module, where parseSourceControlOwnerRef already lives. Update call sites to use the shared exports. Applied via @cursor push command --- apps/server/src/sourceControl/AzureDevOpsCli.ts | 17 ++--------------- apps/server/src/sourceControl/BitbucketApi.ts | 17 ++--------------- .../src/sourceControl/SourceControlProvider.ts | 11 +++++++++++ 3 files changed, 15 insertions(+), 30 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index eefae1cbde..375bfc1a54 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -157,19 +157,6 @@ function normalizeChangeRequestId(reference: string): string { return urlMatch?.[1] ?? trimmed; } -function normalizeSourceBranch(headSelector: string): string { - return ( - SourceControlProvider.parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim() - ); -} - -function sourceBranch(input: { - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; -}): string { - return input.source?.refName ?? normalizeSourceBranch(input.headSelector); -} - function toAzureStatus(state: "open" | "closed" | "merged" | "all"): string { switch (state) { case "open": @@ -276,7 +263,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { "--detect", "true", "--source-branch", - sourceBranch(input), + SourceControlProvider.sourceBranch(input), "--status", toAzureStatus(input.state), "--top", @@ -397,7 +384,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { "--target-branch", input.target?.refName ?? input.baseBranch, "--source-branch", - sourceBranch(input), + SourceControlProvider.sourceBranch(input), "--title", input.title, "--description", diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index de6d75a634..4f795e864a 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -146,19 +146,6 @@ function normalizeChangeRequestId(reference: string): string { return urlMatch?.[1] ?? trimmed; } -function normalizeSourceBranch(headSelector: string): string { - return ( - SourceControlProvider.parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim() - ); -} - -function sourceBranch(input: { - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; -}): string { - return input.source?.refName ?? normalizeSourceBranch(input.headSelector); -} - function sourceWorkspace(input: { readonly headSelector: string; readonly source?: SourceControlProvider.SourceControlRefSelector; @@ -547,7 +534,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))), sort: "-updated_on", q: bitbucketQueryString([ - `source.branch.name = "${sourceBranch(input).replaceAll('"', '\\"')}"`, + `source.branch.name = "${SourceControlProvider.sourceBranch(input).replaceAll('"', '\\"')}"`, bitbucketStateFilter(states), ]), state: states, @@ -613,7 +600,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { description, source: { branch: { - name: sourceBranch(input), + name: SourceControlProvider.sourceBranch(input), }, ...(sourceOwner ? { diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index 8f56121097..12f89caf77 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -30,6 +30,17 @@ export function parseSourceControlOwnerRef( return owner && refName ? { owner, refName } : undefined; } +export function normalizeSourceBranch(headSelector: string): string { + return parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim(); +} + +export function sourceBranch(input: { + readonly headSelector: string; + readonly source?: SourceControlRefSelector; +}): string { + return input.source?.refName ?? normalizeSourceBranch(input.headSelector); +} + export function sourceControlRefFromInput(input: { readonly headSelector: string; readonly source?: SourceControlRefSelector; From 63fc7c7c75ca3fac47a63d1d756f299081a6b9b0 Mon Sep 17 00:00:00 2001 From: Julius Date: Sun, 3 May 2026 23:46:12 -0700 Subject: [PATCH 18/24] refactor(source control): update install hints and remove implemented flags - Revised install hints for Azure DevOps, Bitbucket, GitHub, and GitLab to improve clarity and consistency. - Removed the `implemented` flag from source control provider discovery specifications as it is no longer necessary. - Updated related tests and components to reflect these changes. --- .../AzureDevOpsSourceControlProvider.ts | 3 +- .../BitbucketSourceControlProvider.ts | 4 +- .../GitHubSourceControlProvider.ts | 4 +- .../GitLabSourceControlProvider.ts | 3 +- .../SourceControlDiscovery.test.ts | 9 +- .../SourceControlProviderDiscovery.ts | 10 +- apps/web/src/components/AnimatedHeight.tsx | 59 + .../src/components/CommandPalette.logic.ts | 1 + apps/web/src/components/CommandPalette.tsx | 175 ++- .../src/components/CommandPaletteResults.tsx | 40 +- apps/web/src/components/GitActionsControl.tsx | 1181 ++++++++++------- .../settings/AddProviderInstanceDialog.tsx | 312 ++--- .../settings/ConnectionsSettings.tsx | 68 +- .../settings/SettingsPanels.browser.tsx | 14 +- .../settings/SourceControlSettings.tsx | 47 +- docs/source-control-providers.md | 303 ++--- packages/contracts/src/sourceControl.ts | 14 +- 17 files changed, 1238 insertions(+), 1009 deletions(-) create mode 100644 apps/web/src/components/AnimatedHeight.tsx diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 6e4b1eb68f..cd4162a934 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -53,9 +53,8 @@ export const discovery = { versionArgs: ["--version"], authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], parseAuth: parseAzureAuth, - implemented: true, installHint: - "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.", + "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.", } satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; function toChangeRequest(summary: { diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index e3fe337e74..ede80e921d 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -119,10 +119,8 @@ export const makeDiscovery = Effect.fn("makeBitbucketSourceControlProviderDiscov type: "api", kind: "bitbucket", label: "Bitbucket", - executable: "Bitbucket REST API", - implemented: true, installHint: - "Create a Bitbucket API token with pull request/repository scopes, then set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN.", + "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request and repository scopes).", probeAuth: bitbucket.probeAuth, } satisfies SourceControlProviderDiscovery.SourceControlApiDiscoverySpec; }); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 496f1ec48c..7dba489369 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -83,8 +83,8 @@ export const discovery = { versionArgs: ["--version"], authArgs: ["auth", "status"], parseAuth: parseGitHubAuth, - implemented: true, - installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.", + installHint: + "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", } satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index a007dd246f..5b0538babd 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -79,9 +79,8 @@ export const discovery = { versionArgs: ["--version"], authArgs: ["auth", "status"], parseAuth: parseGitLabAuth, - implemented: true, installHint: - "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.", + "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).", } satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 6da3513e73..ce41265d33 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -48,7 +48,7 @@ const processOutput = ( stderrTruncated: false, }); -it.effect("reports implemented tools separately from locally available CLIs", () => { +it.effect("reports implemented tools separately from locally available executables", () => { const processMock = { run: (input: VcsProcess.VcsProcessInput) => { if (input.command === "git") { @@ -119,7 +119,6 @@ Logged in to github.com account juliusmarminge (keyring) assert.deepStrictEqual( result.sourceControlProviders.map((item) => ({ kind: item.kind, - implemented: item.implemented, status: item.status, auth: item.auth.status, account: item.auth.account, @@ -127,28 +126,24 @@ Logged in to github.com account juliusmarminge (keyring) [ { kind: "github", - implemented: true, status: "available", auth: "authenticated", account: Option.some("juliusmarminge"), }, { kind: "gitlab", - implemented: true, status: "missing", auth: "unknown", account: Option.none(), }, { kind: "azure-devops", - implemented: true, status: "missing", auth: "unknown", account: Option.none(), }, { kind: "bitbucket", - implemented: true, status: "available", auth: "unauthenticated", account: Option.none(), @@ -157,7 +152,7 @@ Logged in to github.com account juliusmarminge (keyring) ); const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); assert.ok(bitbucket); - assert.strictEqual(bitbucket.executable, "Bitbucket REST API"); + assert.strictEqual(bitbucket.executable, undefined); }).pipe(Effect.provide(testLayer)); }); diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index 5182812489..87c0c4756a 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -16,13 +16,12 @@ export interface SourceControlAuthProbeInput { interface SourceControlDiscoverySpecBase { readonly kind: SourceControlProviderKind; readonly label: string; - readonly executable: string; - readonly implemented: boolean; readonly installHint: string; } export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly type: "cli"; + readonly executable: string; readonly versionArgs: ReadonlyArray; readonly authArgs: ReadonlyArray; readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; @@ -41,7 +40,6 @@ interface DiscoveryProbeResult { readonly kind: SourceControlProviderKind; readonly label: string; readonly executable: string; - readonly implemented: boolean; readonly status: "available" | "missing"; readonly version: Option.Option; readonly installHint: string; @@ -149,7 +147,6 @@ function probeCli(input: { kind: input.spec.kind, label: input.spec.label, executable: input.spec.executable, - implemented: input.spec.implemented, status: "available" as const, version: Option.orElse(firstNonEmptyLine(result.stdout), () => firstNonEmptyLine(result.stderr), @@ -163,7 +160,6 @@ function probeCli(input: { kind: input.spec.kind, label: input.spec.label, executable: input.spec.executable, - implemented: input.spec.implemented, status: "missing" as const, version: Option.none(), installHint: input.spec.installHint, @@ -185,8 +181,6 @@ export function probeSourceControlProvider(input: { ({ kind: input.spec.kind, label: input.spec.label, - executable: input.spec.executable, - implemented: input.spec.implemented, status: "available" as const, version: Option.none(), installHint: input.spec.installHint, @@ -208,7 +202,7 @@ export function probeSourceControlProvider(input: { if (item.status !== "available") { return Effect.succeed({ ...item, - auth: unknownAuth("CLI is not installed."), + auth: unknownAuth("Hosting integration command was not found on the server PATH."), } satisfies SourceControlProviderDiscoveryItem); } diff --git a/apps/web/src/components/AnimatedHeight.tsx b/apps/web/src/components/AnimatedHeight.tsx new file mode 100644 index 0000000000..dd404c49df --- /dev/null +++ b/apps/web/src/components/AnimatedHeight.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { type ReactNode, useLayoutEffect, useRef, useState } from "react"; + +export function AnimatedHeight({ children }: { readonly children: ReactNode }) { + const contentRef = useRef(null); + const [height, setHeight] = useState(null); + + useLayoutEffect(() => { + const element = contentRef.current; + if (!element) return; + let firstFrameId: number | null = null; + let secondFrameId: number | null = null; + + const updateHeight = () => { + const nextHeight = Math.ceil(element.scrollHeight || element.getBoundingClientRect().height); + setHeight((currentHeight) => (currentHeight === nextHeight ? currentHeight : nextHeight)); + }; + const cancelPendingFrames = () => { + if (firstFrameId !== null) { + window.cancelAnimationFrame(firstFrameId); + firstFrameId = null; + } + if (secondFrameId !== null) { + window.cancelAnimationFrame(secondFrameId); + secondFrameId = null; + } + }; + const updateHeightAfterPaint = () => { + cancelPendingFrames(); + updateHeight(); + firstFrameId = window.requestAnimationFrame(() => { + firstFrameId = null; + updateHeight(); + secondFrameId = window.requestAnimationFrame(() => { + secondFrameId = null; + updateHeight(); + }); + }); + }; + + updateHeightAfterPaint(); + const resizeObserver = new ResizeObserver(updateHeightAfterPaint); + resizeObserver.observe(element); + return () => { + resizeObserver.disconnect(); + cancelPendingFrames(); + }; + }, []); + + return ( +
+
{children}
+
+ ); +} diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 450f678dd5..3f4997e215 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -17,6 +17,7 @@ export interface CommandPaletteItem { readonly description?: string; readonly timestamp?: string; readonly icon: ReactNode; + readonly disabled?: boolean; /** Optional content rendered inline before the title text. */ readonly titleLeadingContent?: ReactNode; /** Optional content rendered inline after the title text (before the timestamp). */ diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 6b40066147..7ddd98776a 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -12,6 +12,7 @@ import { } from "@t3tools/contracts"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; +import { Option } from "effect"; import { ArrowDownIcon, ArrowLeftIcon, @@ -45,6 +46,7 @@ import { import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useSettings } from "../hooks/useSettings"; import { readLocalApi } from "../localApi"; +import { useSourceControlDiscovery } from "../lib/sourceControlDiscoveryState"; import { startNewThreadInProjectFromContext, startNewThreadFromContext, @@ -92,7 +94,7 @@ import { } from "./CommandPalette.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; -import { GitHubIcon, GitLabIcon } from "./Icons"; +import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { useServerKeybindings } from "../rpc/serverState"; @@ -108,6 +110,7 @@ import { import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; import { stackedThreadToast, toastManager } from "./ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; @@ -143,7 +146,10 @@ interface AddProjectEnvironmentOption { readonly isPrimary: boolean; } -type AddProjectRemoteProviderKind = Extract; +type AddProjectRemoteProviderKind = Extract< + SourceControlProviderKind, + "github" | "gitlab" | "bitbucket" | "azure-devops" +>; type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; type AddProjectCloneFlow = @@ -161,7 +167,13 @@ type AddProjectCloneFlow = readonly remoteUrl: string; }; -const REMOTE_PROJECT_SOURCES: ReadonlyArray = ["github", "gitlab", "url"]; +const REMOTE_PROJECT_SOURCES: ReadonlyArray = [ + "url", + "github", + "gitlab", + "bitbucket", + "azure-devops", +]; function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { switch (source) { @@ -169,11 +181,30 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { return "GitHub"; case "gitlab": return "GitLab"; + case "bitbucket": + return "Bitbucket"; + case "azure-devops": + return "Azure DevOps"; case "url": return "Git URL"; } } +function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string { + switch (source) { + case "github": + return "owner/repo"; + case "gitlab": + return "group/project"; + case "bitbucket": + return "workspace/repository"; + case "azure-devops": + return "project/repository"; + case "url": + return "URL"; + } +} + function remoteProjectSourceProvider( source: AddProjectRemoteSource, ): AddProjectRemoteProviderKind | null { @@ -186,6 +217,10 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri return ; case "gitlab": return ; + case "bitbucket": + return ; + case "azure-devops": + return ; case "url": return ; } @@ -197,7 +232,11 @@ function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string if (flow.source === "url") { return "Enter Git clone URL"; } - return `Enter ${remoteProjectSourceLabel(flow.source)} repository (owner/repo)`; + return `Enter ${remoteProjectSourceLabel(flow.source)} repository (${remoteProjectSourcePathHint(flow.source)})`; +} + +function sourceProviderKind(source: AddProjectRemoteSource): AddProjectRemoteProviderKind | null { + return source === "url" ? null : source; } function errorMessage(error: unknown): string { @@ -288,6 +327,7 @@ function OpenCommandPaletteDialog() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); const keybindings = useServerKeybindings(); + const sourceControlDiscovery = useSourceControlDiscovery(); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; const [browseGeneration, setBrowseGeneration] = useState(0); @@ -714,10 +754,65 @@ function OpenCommandPaletteDialog() { return { github: buildGroups("github"), gitlab: buildGroups("gitlab"), + bitbucket: buildGroups("bitbucket"), + "azure-devops": buildGroups("azure-devops"), url: buildGroups("url"), }; }, [addProjectEnvironmentOptions, startAddProjectClone]); + const addProjectRemoteSourceReadiness = useMemo(() => { + const defaultReadiness: Record< + AddProjectRemoteSource, + { readonly ready: boolean; readonly hint: string | null } + > = { + url: { ready: true, hint: null }, + github: { ready: true, hint: null }, + gitlab: { ready: true, hint: null }, + bitbucket: { ready: true, hint: null }, + "azure-devops": { ready: true, hint: null }, + }; + + if (!sourceControlDiscovery.data) { + return defaultReadiness; + } + + const providerByKind = new Map( + (sourceControlDiscovery.data?.sourceControlProviders ?? []).map((provider) => [ + provider.kind, + provider, + ]), + ); + + const readiness = { ...defaultReadiness }; + + for (const source of REMOTE_PROJECT_SOURCES) { + const kind = sourceProviderKind(source); + if (!kind) continue; + const provider = providerByKind.get(kind); + if (!provider) { + readiness[source] = { + ready: false, + hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", + }; + continue; + } + if (provider.status !== "available") { + readiness[source] = { ready: false, hint: provider.installHint }; + continue; + } + if (provider.auth.status === "unauthenticated") { + readiness[source] = { + ready: false, + hint: + Option.getOrNull(provider.auth.detail) ?? + `${provider.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, + }; + } + } + + return readiness; + }, [sourceControlDiscovery.data]); + const openAddProjectCloneFlow = useCallback( (source: AddProjectRemoteSource) => { if (addProjectEnvironmentOptions.length > 1) { @@ -750,6 +845,11 @@ function OpenCommandPaletteDialog() { ], ); + const openSourceControlSettings = useCallback(() => { + setOpen(false); + void navigate({ to: "/settings/source-control" }); + }, [navigate, setOpen]); + const addProjectSourceGroups = useMemo(() => { const sourceItems: Array = []; @@ -758,8 +858,7 @@ function OpenCommandPaletteDialog() { kind: "submenu", value: "action:add-project:local", searchTerms: ["local", "folder", "directory", "browse", "environment"], - title: "Local folder", - description: "Browse a folder on disk", + title: "Browse local directory", icon: , addonIcon: , groups: addProjectEnvironmentGroups, @@ -769,8 +868,7 @@ function OpenCommandPaletteDialog() { kind: "action", value: "action:add-project:local", searchTerms: ["local", "folder", "directory", "browse"], - title: "Local folder", - description: "Browse a folder on disk", + title: "Browse local directory", icon: , keepOpen: true, run: async () => { @@ -792,9 +890,47 @@ function OpenCommandPaletteDialog() { for (const source of REMOTE_PROJECT_SOURCES) { const label = remoteProjectSourceLabel(source); - const title = source === "url" ? "Git URL" : `${label} repository`; - const description = - source === "url" ? "Clone from a remote URL" : `Clone ${label} owner/repo`; + const title = source === "url" ? "Clone from Git URL" : `Clone ${label} repository`; + const readiness = addProjectRemoteSourceReadiness[source]; + const disabledHint = readiness.hint; + + const titleTrailingContent = readiness.ready ? undefined : ( + + + { + openSourceControlSettings(); + }} + > + Setup Required + + } + /> + + {disabledHint ?? "Open Settings -> Source Control to configure this provider."} + + + + ); + + if (!readiness.ready) { + sourceItems.push({ + kind: "action", + value: `action:add-project:${source}:not-ready`, + searchTerms: ["clone", "remote", "repository", "repo", "git", label, "setup required"], + title, + disabled: true, + icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), + ...(titleTrailingContent ? { titleTrailingContent } : {}), + run: async () => {}, + }); + continue; + } if (addProjectEnvironmentOptions.length > 1) { sourceItems.push({ @@ -802,8 +938,8 @@ function OpenCommandPaletteDialog() { value: `action:add-project:${source}`, searchTerms: ["clone", "remote", "repository", "repo", "git", label], title, - description, icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), + ...(titleTrailingContent ? { titleTrailingContent } : {}), addonIcon: remoteProjectSourceIcon(source, ADDON_ICON_CLASS), groups: addProjectRemoteEnvironmentGroups[source], }); @@ -815,8 +951,8 @@ function OpenCommandPaletteDialog() { value: `action:add-project:${source}`, searchTerms: ["clone", "remote", "repository", "repo", "git", label], title, - description, icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), + ...(titleTrailingContent ? { titleTrailingContent } : {}), keepOpen: true, run: async () => { openAddProjectCloneFlow(source); @@ -828,8 +964,10 @@ function OpenCommandPaletteDialog() { }, [ addProjectEnvironmentGroups, addProjectEnvironmentOptions.length, + addProjectRemoteSourceReadiness, addProjectRemoteEnvironmentGroups, defaultAddProjectEnvironmentId, + openSourceControlSettings, openAddProjectCloneFlow, startAddProjectBrowse, ]); @@ -928,6 +1066,9 @@ function OpenCommandPaletteDialog() { "git", "github", "gitlab", + "bitbucket", + "azure", + "devops", "url", "environment", ], @@ -1355,6 +1496,10 @@ function OpenCommandPaletteDialog() { } function executeItem(item: CommandPaletteActionItem | CommandPaletteSubmenuItem): void { + if (item.disabled) { + return; + } + if (item.kind === "submenu") { pushView(item); return; @@ -1471,7 +1616,7 @@ function OpenCommandPaletteDialog() { variant="outline" size="xs" tabIndex={-1} - className="absolute end-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2" + className="absolute inset-e-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2" aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`} disabled={!canSubmitRemoteProjectFlow} onMouseDown={(event) => { @@ -1493,7 +1638,7 @@ function OpenCommandPaletteDialog() { size="xs" tabIndex={-1} className={cn( - "absolute end-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2", + "absolute inset-e-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2", hasHighlightedBrowseItem ? "gap-1" : "gap-1.5", )} aria-label={`${submitActionLabel} (${addShortcutLabel})`} diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index 8cdf0694a0..7c689f0012 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -43,15 +43,19 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { {group.label} - {(item) => ( - - )} + {(item) => + item.disabled ? ( + + ) : ( + + ) + } ))} @@ -59,6 +63,21 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { ); } +function DisabledCommandPaletteResultRow(props: { + item: CommandPaletteActionItem | CommandPaletteSubmenuItem; +}) { + return ( +
+ {props.item.icon} + + {props.item.titleLeadingContent} + {props.item.title} + + {props.item.titleTrailingContent} +
+ ); +} + function CommandPaletteResultRow(props: { item: CommandPaletteActionItem | CommandPaletteSubmenuItem; isActive: boolean; @@ -89,7 +108,6 @@ function CommandPaletteResultRow(props: { {props.item.titleLeadingContent} {props.item.title} - {props.item.titleTrailingContent} {props.item.description} @@ -99,9 +117,9 @@ function CommandPaletteResultRow(props: { {props.item.titleLeadingContent} {props.item.title} - {props.item.titleTrailingContent} )} + {props.item.titleTrailingContent} {props.item.timestamp ? ( {props.item.timestamp} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index f2977d4c66..9a6c41e207 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -4,14 +4,17 @@ import type { GitRunStackedActionResult, GitStackedAction, SourceControlCloneProtocol, + SourceControlProviderDiscoveryItem, SourceControlProviderKind, SourceControlPublishRepositoryResult, SourceControlRepositoryVisibility, VcsStatusResult, } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; import { Option } from "effect"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { flushSync } from "react-dom"; import { CheckIcon, ChevronDownIcon, @@ -23,7 +26,7 @@ import { GlobeIcon, } from "lucide-react"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; -import { GitHubIcon, GitLabIcon } from "~/components/Icons"; +import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "~/components/Icons"; import { RadioGroup } from "~/components/ui/radio-group"; import { Spinner } from "~/components/ui/spinner"; import { cn } from "~/lib/utils"; @@ -40,6 +43,7 @@ import { resolveQuickAction, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; +import { AnimatedHeight } from "./AnimatedHeight"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; import { @@ -66,6 +70,7 @@ import { } from "~/components/ui/select"; import { Textarea } from "~/components/ui/textarea"; import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { openInPreferredEditor } from "~/editorPreferences"; import { gitInitMutationOptions, @@ -100,7 +105,10 @@ interface PendingDefaultBranchAction { filePaths?: string[]; } -type PublishProviderKind = Extract; +type PublishProviderKind = Extract< + SourceControlProviderKind, + "github" | "gitlab" | "bitbucket" | "azure-devops" +>; type GitActionToastId = ReturnType; @@ -129,6 +137,82 @@ interface RunGitActionWithToastInput { const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; +const PUBLISH_PROVIDER_OPTIONS = [ + { + value: "github", + label: "GitHub", + description: "github.com", + host: "github.com", + pathPlaceholder: "owner/repo", + Icon: GitHubIcon, + }, + { + value: "gitlab", + label: "GitLab", + description: "gitlab.com", + host: "gitlab.com", + pathPlaceholder: "group/project", + Icon: GitLabIcon, + }, + { + value: "bitbucket", + label: "Bitbucket", + description: "bitbucket.org", + host: "bitbucket.org", + pathPlaceholder: "workspace/repository", + Icon: BitbucketIcon, + }, + { + value: "azure-devops", + label: "Azure DevOps", + description: "dev.azure.com", + host: "dev.azure.com", + pathPlaceholder: "project/repository", + Icon: AzureDevOpsIcon, + }, +] as const satisfies ReadonlyArray<{ + readonly value: PublishProviderKind; + readonly label: string; + readonly description: string; + readonly host: string; + readonly pathPlaceholder: string; + readonly Icon: typeof GitHubIcon; +}>; + +function publishProviderOption(provider: PublishProviderKind) { + return ( + PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ?? + PUBLISH_PROVIDER_OPTIONS[0] + ); +} + +function getPublishProviderReadiness(input: { + provider: PublishProviderKind; + sourceControlProviders: ReadonlyArray; +}): { readonly ready: boolean; readonly hint: string | null } { + const discovered = input.sourceControlProviders.find( + (provider) => provider.kind === input.provider, + ); + if (!discovered) { + return { + ready: false, + hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", + }; + } + if (discovered.status !== "available") { + return { ready: false, hint: discovered.installHint }; + } + if (discovered.auth.status === "unauthenticated") { + return { + ready: false, + hint: + Option.getOrNull(discovered.auth.detail) ?? + `${discovered.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, + }; + } + return { ready: true, hint: null }; +} + function formatElapsedDescription(startedAtMs: number | null): string | undefined { if (startedAtMs === null) { return undefined; @@ -256,6 +340,601 @@ function GitQuickActionIcon({ return ; } +interface PublishRepositoryDialogProps { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly environmentId: ScopedThreadRef["environmentId"] | null; + readonly gitCwd: string; +} + +function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const sourceControlDiscovery = useSourceControlDiscovery(); + const [publishProvider, setPublishProvider] = useState("github"); + const [publishRepository, setPublishRepository] = useState(""); + const [publishVisibility, setPublishVisibility] = + useState("private"); + const [publishRemoteName, setPublishRemoteName] = useState("origin"); + const [publishProtocol, setPublishProtocol] = useState("ssh"); + const [publishWizardStep, setPublishWizardStep] = useState(0); + const [publishAdvancedOpen, setPublishAdvancedOpen] = useState(false); + const [publishError, setPublishError] = useState(null); + const [publishResult, setPublishResult] = useState( + null, + ); + const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false); + const publishRepositoryMutation = useMutation( + sourceControlPublishRepositoryMutationOptions({ + environmentId: props.environmentId, + cwd: props.gitCwd, + queryClient, + }), + ); + const publishAccountByProvider = useMemo(() => { + const accounts: Record = { + github: null, + gitlab: null, + bitbucket: null, + "azure-devops": null, + }; + for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { + if (provider.kind === "github" || provider.kind === "gitlab") { + accounts[provider.kind] = Option.getOrNull(provider.auth.account); + } + } + return accounts; + }, [sourceControlDiscovery.data]); + const publishProviderReadiness = useMemo(() => { + const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? []; + return Object.fromEntries( + PUBLISH_PROVIDER_OPTIONS.map((option) => [ + option.value, + getPublishProviderReadiness({ + provider: option.value, + sourceControlProviders, + }), + ]), + ) as Record; + }, [sourceControlDiscovery.data]); + const hasReadyPublishProvider = useMemo( + () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness[option.value].ready), + [publishProviderReadiness], + ); + const selectedPublishProviderReadiness = publishProviderReadiness[publishProvider]; + const publishRepositoryPrefill = publishAccountByProvider[publishProvider] + ? `${publishAccountByProvider[publishProvider]}/` + : ""; + const currentPublishProvider = publishProviderOption(publishProvider); + const publishHost = currentPublishProvider.host; + const publishPathPlaceholder = currentPublishProvider.pathPlaceholder; + const publishProviderLabel = currentPublishProvider.label; + const publishWizardSteps = ["Provider", "Repository", "Summary"] as const; + const publishWizardStepSummaries = [ + publishProviderLabel, + publishResult?.repository.nameWithOwner ?? null, + null, + ] as const; + + useEffect(() => { + if (!props.open || hasUserEditedPublishRepository) { + return; + } + setPublishRepository(publishRepositoryPrefill); + }, [hasUserEditedPublishRepository, props.open, publishRepositoryPrefill]); + + const canSubmitPublishRepository = useMemo(() => { + if (!selectedPublishProviderReadiness.ready) return false; + if (publishRepositoryMutation.isPending) return false; + const repositoryParts = publishRepository.trim().split("/"); + const owner = repositoryParts[0]?.trim() ?? ""; + const rest = repositoryParts.slice(1); + const name = rest.join("/").trim(); + return owner.length > 0 && name.length > 0; + }, [publishRepository, publishRepositoryMutation.isPending, selectedPublishProviderReadiness]); + + useEffect(() => { + if (!props.open) { + return; + } + if (publishProviderReadiness[publishProvider].ready) { + return; + } + const firstReadyProvider = PUBLISH_PROVIDER_OPTIONS.find( + (option) => publishProviderReadiness[option.value].ready, + ); + if (firstReadyProvider) { + setPublishProvider(firstReadyProvider.value); + } + }, [props.open, publishProvider, publishProviderReadiness]); + + const submitPublishRepository = useCallback(() => { + if (!canSubmitPublishRepository) { + return; + } + + setPublishError(null); + + void publishRepositoryMutation + .mutateAsync({ + provider: publishProvider, + repository: publishRepository.trim(), + visibility: publishVisibility, + remoteName: publishRemoteName.trim() || "origin", + protocol: publishProtocol, + }) + .then((result) => { + flushSync(() => { + setPublishResult(result); + setPublishWizardStep(2); + }); + void refreshGitStatus({ environmentId: props.environmentId, cwd: props.gitCwd }).catch( + () => undefined, + ); + }) + .catch((err: unknown) => { + setPublishError(err instanceof Error ? err.message : "An error occurred."); + }); + }, [ + canSubmitPublishRepository, + props.environmentId, + props.gitCwd, + publishProtocol, + publishProvider, + publishRemoteName, + publishRepository, + publishRepositoryMutation, + publishVisibility, + ]); + + const resetState = useCallback(() => { + setPublishRemoteName("origin"); + setPublishRepository(""); + setHasUserEditedPublishRepository(false); + setPublishWizardStep(0); + setPublishAdvancedOpen(false); + setPublishError(null); + setPublishResult(null); + }, []); + + const handleOpenChange = useCallback( + (open: boolean) => { + props.onOpenChange(open); + if (!open) { + resetState(); + } + }, + [props, resetState], + ); + + const openSourceControlSettings = useCallback(() => { + handleOpenChange(false); + void navigate({ to: "/settings/source-control" }); + }, [handleOpenChange, navigate]); + + return ( + + +
+ + Publish repository + + Pick where to host it, then point us at a repo to push to. + +
+ {publishWizardSteps.map((label, index) => { + const isComplete = index < publishWizardStep; + const isClickable = + publishWizardStep !== 2 && + index < publishWizardSteps.length - 1 && + index <= publishWizardStep; + return ( + + ); + })} +
+
+ + + +
+ + Provider + + setPublishProvider(value as PublishProviderKind)} + aria-labelledby="publish-provider-cards-label" + className="grid grid-cols-2 gap-2.5" + > + {PUBLISH_PROVIDER_OPTIONS.map((option) => { + const readiness = publishProviderReadiness[option.value]; + const isSelected = publishProvider === option.value && readiness.ready; + if (!readiness.ready) { + return ( +
+ + + {option.label} + + + { + event.preventDefault(); + event.stopPropagation(); + openSourceControlSettings(); + }} + > + Setup Required + + } + /> + + {readiness.hint ?? + "Open Settings -> Source Control to configure this provider."} + + +
+ ); + } + + return ( + + + + {option.label} + + + ); + })} +
+
+ +
+
+ +
+ + + {publishHost}/ + + { + setPublishRepository(event.target.value); + setHasUserEditedPublishRepository(true); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + submitPublishRepository(); + } + }} + placeholder={publishPathPlaceholder} + disabled={publishRepositoryMutation.isPending} + className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" + /> +
+
+ +
+ + Visibility + + + setPublishVisibility(value as SourceControlRepositoryVisibility) + } + aria-labelledby="publish-visibility-cards-label" + disabled={publishRepositoryMutation.isPending} + className="grid grid-cols-2 gap-2.5" + > + {[ + { + value: "private" as const, + label: "Private", + description: "Only invited people", + Icon: LockIcon, + }, + { + value: "public" as const, + label: "Public", + description: "Anyone on the web", + Icon: GlobeIcon, + }, + ].map((option) => { + const isSelected = publishVisibility === option.value; + return ( + + + + + {option.label} + + + {option.description} + + + + ); + })} + +
+ +
+ + {publishAdvancedOpen ? ( +
+ +
+ + Protocol + + + setPublishProtocol(value as SourceControlCloneProtocol) + } + aria-labelledby="publish-protocol-label" + disabled={publishRepositoryMutation.isPending} + className="grid grid-cols-2 gap-2" + > + {(["ssh", "https"] as const).map((value) => { + const isSelected = publishProtocol === value; + return ( + + {value === "ssh" ? "SSH" : "HTTPS"} + + ); + })} + +
+
+ ) : null} +
+ + {publishRepositoryMutation.isPending ? ( +
+ + Publishing repository to {publishProviderLabel}... +
+ ) : null} + {publishError && !publishRepositoryMutation.isPending ? ( +
+

Publish failed

+

{publishError}

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

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

+

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

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

Publish failed

-

{publishError}

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

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

-

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

-
-
- {publishProvider === "github" ? ( - - ) : ( - - )} - - {publishResult.repository.nameWithOwner} - -
- -
- )} - - - {publishWizardStep === 2 ? ( - - ) : ( - <> - - {publishWizardStep < 1 ? ( - - ) : ( - - )} - - )} - -
-
+ onOpenChange={setIsPublishDialogOpen} + environmentId={activeEnvironmentId} + gitCwd={gitCwd} + /> -
- - Driver - - setDriver(ProviderDriverKind.make(value))} - aria-labelledby="add-instance-driver-label" - className="grid grid-cols-2 gap-2.5" - > - {DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - const isSelected = option.value === driver; - return ( - - - - {option.label} - - {option.badgeLabel ? ( - - {option.badgeLabel} - - ) : null} - - ); - })} - {COMING_SOON_DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - return ( - - - - {option.label} - - - Coming Soon - - - ); - })} - -
- - - -