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.