From aec540c96afc5bc7dc3465d9c1914b69e22c90a1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 01:41:11 -0700 Subject: [PATCH 1/9] Add Azure DevOps source control provider support - Introduce Azure DevOps CLI and provider adapters - Route remote detection, PR actions, and UI labels through provider terms - Update server wiring and tests for Azure DevOps remotes --- apps/server/src/server.ts | 5 +- .../src/sourceControl/AzureDevOpsCli.test.ts | 204 ++++++++++ .../src/sourceControl/AzureDevOpsCli.ts | 370 ++++++++++++++++++ .../AzureDevOpsSourceControlProvider.test.ts | 101 +++++ .../AzureDevOpsSourceControlProvider.ts | 110 +++++- .../SourceControlProviderRegistry.test.ts | 14 + .../SourceControlProviderRegistry.ts | 3 +- .../sourceControl/azureDevOpsPullRequests.ts | 104 +++++ 8 files changed, 908 insertions(+), 3 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..5db4dadc4f --- /dev/null +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -0,0 +1,204 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { afterEach, expect, vi } from "vitest"; + +vi.mock("../processRunner", () => ({ + runProcess: vi.fn(), +})); + +vi.mock("node:fs/promises", () => ({ + readFile: vi.fn(), +})); + +import { readFile } from "node:fs/promises"; +import { runProcess } from "../processRunner.ts"; +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; + +const mockedRunProcess = vi.mocked(runProcess); +const mockedReadFile = vi.mocked(readFile); +const layer = it.layer(AzureDevOpsCli.layer); + +afterEach(() => { + mockedRunProcess.mockReset(); + mockedReadFile.mockReset(); +}); + +layer("AzureDevOpsCli.layer", (it) => { + it.effect("parses pull request view output", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: 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", + _links: { + web: { + href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + }, + }, + }), + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + const result = yield* Effect.gen(function* () { + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + return 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); + expect(mockedRunProcess).toHaveBeenCalledWith( + "az", + [ + "repos", + "pr", + "show", + "--detect", + "true", + "--id", + "42", + "--only-show-errors", + "--output", + "json", + ], + expect.objectContaining({ cwd: "/repo" }), + ); + }), + ); + + it.effect("lists pull requests with Azure status and source branch arguments", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: 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", + }, + }, + }, + ]), + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + const result = yield* Effect.gen(function* () { + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + return yield* az.listPullRequests({ + cwd: "/repo", + headSelector: "origin:feature/merged", + state: "merged", + limit: 10, + }); + }); + + assert.strictEqual(result[0]?.state, "merged"); + expect(mockedRunProcess).toHaveBeenCalledWith( + "az", + [ + "repos", + "pr", + "list", + "--detect", + "true", + "--source-branch", + "feature/merged", + "--status", + "completed", + "--top", + "10", + "--only-show-errors", + "--output", + "json", + ], + expect.objectContaining({ cwd: "/repo" }), + ); + }), + ); + + it.effect("reads repository clone URLs", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: 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", + }, + }), + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + const result = yield* Effect.gen(function* () { + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + return 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", + }); + }), + ); + + it.effect("creates pull requests using the body file as the Azure description", () => + Effect.gen(function* () { + mockedReadFile.mockResolvedValueOnce("Generated body"); + mockedRunProcess.mockResolvedValueOnce({ + stdout: "{}", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + yield* Effect.gen(function* () { + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + }); + + expect(mockedReadFile).toHaveBeenCalledWith("/tmp/body.md", "utf8"); + expect(mockedRunProcess).toHaveBeenCalledWith( + "az", + expect.arrayContaining(["--description", "Generated body"]), + expect.objectContaining({ cwd: "/repo" }), + ); + }), + ); +}); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts new file mode 100644 index 0000000000..a27bc1d198 --- /dev/null +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -0,0 +1,370 @@ +import { readFile } from "node:fs/promises"; + +import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; +import { TrimmedNonEmptyString } from "@t3tools/contracts"; + +import type { ProcessRunResult } from "../processRunner.ts"; +import { runProcess } from "../processRunner.ts"; +import { + decodeAzureDevOpsPullRequestJson, + decodeAzureDevOpsPullRequestListJson, + formatAzureDevOpsJsonDecodeError, + type NormalizedAzureDevOpsPullRequestRecord, +} from "./azureDevOpsPullRequests.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 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 createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; +} + +export class AzureDevOpsCli extends Context.Service()( + "t3/source-control/AzureDevOpsCli", +) {} + +function normalizeAzureDevOpsCliError( + operation: "execute" | "readBodyFile", + error: unknown, +): AzureDevOpsCliError { + if (error instanceof Error) { + if (error.message.includes("Command not found: az")) { + return new AzureDevOpsCliError({ + operation, + detail: + "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH.", + cause: error, + }); + } + + const lower = error.message.toLowerCase(); + 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: `Azure DevOps CLI command failed: ${error.message}`, + cause: error, + }); + } + + return new AzureDevOpsCliError({ + operation, + detail: "Azure DevOps CLI command failed.", + 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 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 decodeAzureDevOpsJson( + raw: string, + schema: S, + operation: "getRepositoryCloneUrls" | "getDefaultBranch", + 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.sync(() => { + const execute: AzureDevOpsCliShape["execute"] = (input) => + Effect.tryPromise({ + try: () => + runProcess("az", [...input.args, "--only-show-errors", "--output", "json"], { + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }), + catch: (error) => normalizeAzureDevOpsCliError("execute", error), + }); + + const readBodyFile = (path: string) => + Effect.tryPromise({ + try: () => readFile(path, "utf8"), + catch: (error) => normalizeAzureDevOpsCliError("readBodyFile", error), + }); + + return AzureDevOpsCli.of({ + execute, + listPullRequests: (input) => + execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "list", + "--detect", + "true", + "--source-branch", + normalizeSourceBranch(input.headSelector), + "--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) => + execute({ + 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) => + execute({ + 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), + ), + createPullRequest: (input) => + readBodyFile(input.bodyFile).pipe( + Effect.flatMap((description) => + execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "create", + "--detect", + "true", + "--target-branch", + input.baseBranch, + "--source-branch", + normalizeSourceBranch(input.headSelector), + "--title", + input.title, + "--description", + description, + ], + }), + ), + Effect.asVoid, + ), + getDefaultBranch: (input) => + execute({ + cwd: input.cwd, + args: ["repos", "show", "--detect", "true", "--repository", input.repository], + }).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", + "--id", + normalizeChangeRequestId(input.reference), + "--remote-name", + "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..6c3d06245c --- /dev/null +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -0,0 +1,101 @@ +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"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; + +function makeProvider(azure: Partial, originUrl?: string | null) { + return AzureDevOpsSourceControlProvider.make().pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(AzureDevOpsCli)(azure), + Layer.mock(GitVcsDriver.GitVcsDriver)({ + readConfigValue: (_cwd, key) => + key === "remote.origin.url" + ? Effect.succeed(originUrl ?? "https://dev.azure.com/acme/project/_git/repo") + : Effect.succeed(null), + }), + ), + ), + ); +} + +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 the current Azure DevOps repository for default branch lookup", () => + Effect.gen(function* () { + let repositoryInput: string | null = null; + const provider = yield* makeProvider({ + getDefaultBranch: (input) => { + repositoryInput = input.repository; + return Effect.succeed("main"); + }, + }); + + const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); + + assert.strictEqual(defaultBranch, "main"); + assert.strictEqual(repositoryInput, "repo"); + }), +); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 21d80c2d46..2a6813ecf4 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 } from "./SourceControlProvider.ts"; import { combinedAuthOutput, firstSafeAuthLine, @@ -5,6 +10,16 @@ import { type SourceControlAuthProbeInput, type SourceControlCliDiscoverySpec, } from "./SourceControlProviderDiscovery.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.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 +51,100 @@ 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 parseAzureDevOpsRepositoryNameFromRemoteUrl(url: string | null): string | null { + const trimmed = url?.trim() ?? ""; + if (trimmed.length === 0) { + return null; + } + + const httpsMatch = + /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/([^/\s]+?)(?:\.git)?\/?$/i.exec( + trimmed, + ); + const sshMatch = /^git@ssh\.dev\.azure\.com:v3\/[^/\s]+\/[^/\s]+\/([^/\s]+?)(?:\.git)?\/?$/i.exec( + trimmed, + ); + const repositoryName = (httpsMatch?.[1] ?? sshMatch?.[1] ?? "").trim(); + return repositoryName.length > 0 ? decodeURIComponent(repositoryName) : null; +} + +export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* () { + const azure = yield* AzureDevOpsCli; + const git = yield* GitVcsDriver.GitVcsDriver; + + const currentRepositoryName = (cwd: string) => + git.readConfigValue(cwd, "remote.origin.url").pipe( + Effect.map(parseAzureDevOpsRepositoryNameFromRemoteUrl), + Effect.catch(() => Effect.succeed(null)), + ); + + return SourceControlProvider.of({ + kind: "azure-devops", + listChangeRequests: (input) => + azure.listPullRequests(input).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) => + azure + .createPullRequest({ + cwd: input.cwd, + baseBranch: input.baseRefName, + headSelector: input.headSelector, + 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))), + getDefaultBranch: (input) => + currentRepositoryName(input.cwd).pipe( + Effect.flatMap((repository) => + repository + ? azure.getDefaultBranch({ cwd: input.cwd, repository }) + : Effect.succeed(null), + ), + Effect.mapError((error) => providerError("getDefaultBranch", error)), + ), + checkoutChangeRequest: (input) => + azure + .checkoutPullRequest(input) + .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 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..4431f4ba10 --- /dev/null +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -0,0 +1,104 @@ +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 ?? 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); +} From 59f508e9a07ecd467d43b1b55edfa17f3f094122 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 01:58:03 -0700 Subject: [PATCH 2/9] Refactor VCS CLI process handling - Route GitHub and Azure DevOps CLI calls through the shared VcsProcess service - Improve Azure DevOps error normalization and body file reading - Update GitManager path canonicalization and related tests --- .../src/sourceControl/AzureDevOpsCli.test.ts | 254 +++++++++--------- .../src/sourceControl/AzureDevOpsCli.ts | 123 +++++---- 2 files changed, 191 insertions(+), 186 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 5db4dadc4f..86f5f2d028 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -1,57 +1,62 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { Effect, Option } from "effect"; -import { afterEach, expect, vi } from "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"; -vi.mock("../processRunner", () => ({ - runProcess: vi.fn(), -})); +import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; -vi.mock("node:fs/promises", () => ({ - readFile: vi.fn(), -})); +const processOutput = (stdout: string): VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); -import { readFile } from "node:fs/promises"; -import { runProcess } from "../processRunner.ts"; -import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +const mockRun = vi.fn<(input: VcsProcessInput) => Effect.Effect>(); -const mockedRunProcess = vi.mocked(runProcess); -const mockedReadFile = vi.mocked(readFile); -const layer = it.layer(AzureDevOpsCli.layer); +const supportLayer = Layer.mergeAll( + Layer.mock(VcsProcess)({ + run: mockRun, + }), + NodeServices.layer, +); +const layer = Layer.mergeAll(AzureDevOpsCli.layer.pipe(Layer.provide(supportLayer)), supportLayer); afterEach(() => { - mockedRunProcess.mockReset(); - mockedReadFile.mockReset(); + mockRun.mockReset(); }); -layer("AzureDevOpsCli.layer", (it) => { +describe("AzureDevOpsCli.layer", () => { it.effect("parses pull request view output", () => Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: 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", - _links: { - web: { - href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", - }, - }, - }), - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + 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", + _links: { + web: { + href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + }, + }, + }), + ), + ), + ); - const result = yield* Effect.gen(function* () { - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - return yield* az.getPullRequest({ - cwd: "/repo", - reference: "#42", - }); + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getPullRequest({ + cwd: "/repo", + reference: "#42", }); assert.strictEqual(result.number, 42); @@ -60,9 +65,10 @@ layer("AzureDevOpsCli.layer", (it) => { assert.strictEqual(result.headRefName, "feature/source-control"); assert.strictEqual(result.state, "open"); assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag); - expect(mockedRunProcess).toHaveBeenCalledWith( - "az", - [ + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ "repos", "pr", "show", @@ -74,49 +80,49 @@ layer("AzureDevOpsCli.layer", (it) => { "--output", "json", ], - expect.objectContaining({ cwd: "/repo" }), - ); - }), + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), ); it.effect("lists pull requests with Azure status and source branch arguments", () => Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: 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", + 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", + }, + }, }, - }, - }, - ]), - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + ]), + ), + ), + ); - const result = yield* Effect.gen(function* () { - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - return yield* az.listPullRequests({ - cwd: "/repo", - headSelector: "origin:feature/merged", - state: "merged", - limit: 10, - }); + 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(mockedRunProcess).toHaveBeenCalledWith( - "az", - [ + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ "repos", "pr", "list", @@ -132,35 +138,34 @@ layer("AzureDevOpsCli.layer", (it) => { "--output", "json", ], - expect.objectContaining({ cwd: "/repo" }), - ); - }), + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), ); it.effect("reads repository clone URLs", () => Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: 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", - }, - }), - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + 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 result = yield* Effect.gen(function* () { - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - return yield* az.getRepositoryCloneUrls({ - cwd: "/repo", - repository: "repo", - }); + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "repo", }); assert.deepStrictEqual(result, { @@ -168,37 +173,32 @@ layer("AzureDevOpsCli.layer", (it) => { 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 pull requests using the body file as the Azure description", () => Effect.gen(function* () { - mockedReadFile.mockResolvedValueOnce("Generated body"); - mockedRunProcess.mockResolvedValueOnce({ - stdout: "{}", - stderr: "", - code: 0, - signal: null, - timedOut: false, + 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, }); - yield* Effect.gen(function* () { - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - yield* az.createPullRequest({ + expect(mockRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "az", cwd: "/repo", - baseBranch: "main", - headSelector: "feature/provider", - title: "Provider PR", - bodyFile: "/tmp/body.md", - }); - }); - - expect(mockedReadFile).toHaveBeenCalledWith("/tmp/body.md", "utf8"); - expect(mockedRunProcess).toHaveBeenCalledWith( - "az", - expect.arrayContaining(["--description", "Generated body"]), - expect.objectContaining({ cwd: "/repo" }), + args: expect.arrayContaining(["--description", "Generated body"]), + }), ); - }), + }).pipe(Effect.provide(layer)), ); }); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index a27bc1d198..02f749b842 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -1,10 +1,7 @@ -import { readFile } from "node:fs/promises"; +import { Context, Effect, FileSystem, Layer, Result, Schema, SchemaIssue } from "effect"; +import { TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts"; -import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; -import { TrimmedNonEmptyString } from "@t3tools/contracts"; - -import type { ProcessRunResult } from "../processRunner.ts"; -import { runProcess } from "../processRunner.ts"; +import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; import { decodeAzureDevOpsPullRequestJson, decodeAzureDevOpsPullRequestListJson, @@ -38,7 +35,7 @@ export interface AzureDevOpsCliShape { readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly listPullRequests: (input: { readonly cwd: string; @@ -80,56 +77,61 @@ export class AzureDevOpsCli extends Context.Service( ); } -export const make = Effect.sync(() => { +export const make = Effect.fn("makeAzureDevOpsCli")(function* () { + const process = yield* VcsProcess; + const fileSystem = yield* FileSystem.FileSystem; + const execute: AzureDevOpsCliShape["execute"] = (input) => - Effect.tryPromise({ - try: () => - runProcess("az", [...input.args, "--only-show-errors", "--output", "json"], { - cwd: input.cwd, - timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, - }), - catch: (error) => normalizeAzureDevOpsCliError("execute", error), - }); + process + .run({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [...input.args, "--only-show-errors", "--output", "json"], + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }) + .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error))); const readBodyFile = (path: string) => - Effect.tryPromise({ - try: () => readFile(path, "utf8"), - catch: (error) => normalizeAzureDevOpsCliError("readBodyFile", error), - }); + fileSystem + .readFileString(path) + .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("readBodyFile", error))); return AzureDevOpsCli.of({ execute, @@ -367,4 +372,4 @@ export const make = Effect.sync(() => { }); }); -export const layer = Layer.effect(AzureDevOpsCli, make); +export const layer = Layer.effect(AzureDevOpsCli, make()); From 264abe2fab2d36d000358ab1a8b35a9770e46713 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 02:04:58 -0700 Subject: [PATCH 3/9] Refine Azure DevOps CLI repository handling - Use CLI repository detection for default-branch lookup - Stop forcing JSON output on checkout commands - Normalize pull request timestamps from closed date --- .../src/sourceControl/AzureDevOpsCli.test.ts | 31 ++++++++++++++++ .../src/sourceControl/AzureDevOpsCli.ts | 21 +++++++---- .../AzureDevOpsSourceControlProvider.test.ts | 23 ++++-------- .../AzureDevOpsSourceControlProvider.ts | 36 ++----------------- .../sourceControl/azureDevOpsPullRequests.ts | 4 ++- 5 files changed, 57 insertions(+), 58 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 86f5f2d028..b2dd496742 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -43,6 +43,7 @@ describe("AzureDevOpsCli.layer", () => { 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", @@ -199,6 +200,36 @@ describe("AzureDevOpsCli.layer", () => { args: expect.arrayContaining(["--description", "Generated body"]), }), ); + 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", + "--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 index 02f749b842..d0517ccb60 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -64,7 +64,6 @@ export interface AzureDevOpsCliShape { readonly getDefaultBranch: (input: { readonly cwd: string; - readonly repository: string; }) => Effect.Effect; readonly checkoutPullRequest: (input: { @@ -217,12 +216,18 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { .run({ operation: "AzureDevOpsCli.execute", command: "az", - args: [...input.args, "--only-show-errors", "--output", "json"], + 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"], + }); + const readBodyFile = (path: string) => fileSystem .readFileString(path) @@ -231,7 +236,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { return AzureDevOpsCli.of({ execute, listPullRequests: (input) => - execute({ + executeJson({ cwd: input.cwd, args: [ "repos", @@ -269,7 +274,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { ), ), getPullRequest: (input) => - execute({ + executeJson({ cwd: input.cwd, args: [ "repos", @@ -301,7 +306,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { ), ), getRepositoryCloneUrls: (input) => - execute({ + executeJson({ cwd: input.cwd, args: ["repos", "show", "--detect", "true", "--repository", input.repository], }).pipe( @@ -325,6 +330,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { "repos", "pr", "create", + "--only-show-errors", "--detect", "true", "--target-branch", @@ -341,9 +347,9 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { Effect.asVoid, ), getDefaultBranch: (input) => - execute({ + executeJson({ cwd: input.cwd, - args: ["repos", "show", "--detect", "true", "--repository", input.repository], + args: ["repos", "show", "--detect", "true"], }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => @@ -363,6 +369,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { "repos", "pr", "checkout", + "--only-show-errors", "--id", normalizeChangeRequestId(input.reference), "--remote-name", diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 6c3d06245c..d1a56b6ca4 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -3,21 +3,10 @@ import { Effect, Layer, Option } from "effect"; import { AzureDevOpsCli, type AzureDevOpsCliShape } from "./AzureDevOpsCli.ts"; import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -function makeProvider(azure: Partial, originUrl?: string | null) { +function makeProvider(azure: Partial) { return AzureDevOpsSourceControlProvider.make().pipe( - Effect.provide( - Layer.mergeAll( - Layer.mock(AzureDevOpsCli)(azure), - Layer.mock(GitVcsDriver.GitVcsDriver)({ - readConfigValue: (_cwd, key) => - key === "remote.origin.url" - ? Effect.succeed(originUrl ?? "https://dev.azure.com/acme/project/_git/repo") - : Effect.succeed(null), - }), - ), - ), + Effect.provide(Layer.mock(AzureDevOpsCli)(azure)), ); } @@ -83,12 +72,12 @@ it.effect("creates Azure DevOps PRs through provider-neutral input names", () => }), ); -it.effect("uses the current Azure DevOps repository for default branch lookup", () => +it.effect("uses Azure CLI repository detection for default branch lookup", () => Effect.gen(function* () { - let repositoryInput: string | null = null; + let cwdInput: string | null = null; const provider = yield* makeProvider({ getDefaultBranch: (input) => { - repositoryInput = input.repository; + cwdInput = input.cwd; return Effect.succeed("main"); }, }); @@ -96,6 +85,6 @@ it.effect("uses the current Azure DevOps repository for default branch lookup", const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); assert.strictEqual(defaultBranch, "main"); - assert.strictEqual(repositoryInput, "repo"); + assert.strictEqual(cwdInput, "/repo"); }), ); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 2a6813ecf4..c10e37f723 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -10,7 +10,6 @@ import { type SourceControlAuthProbeInput, type SourceControlCliDiscoverySpec, } from "./SourceControlProviderDiscovery.ts"; -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; function providerError(operation: string, cause: AzureDevOpsCliError): SourceControlProviderError { return new SourceControlProviderError({ @@ -78,32 +77,8 @@ function toChangeRequest(summary: { }; } -function parseAzureDevOpsRepositoryNameFromRemoteUrl(url: string | null): string | null { - const trimmed = url?.trim() ?? ""; - if (trimmed.length === 0) { - return null; - } - - const httpsMatch = - /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/([^/\s]+?)(?:\.git)?\/?$/i.exec( - trimmed, - ); - const sshMatch = /^git@ssh\.dev\.azure\.com:v3\/[^/\s]+\/[^/\s]+\/([^/\s]+?)(?:\.git)?\/?$/i.exec( - trimmed, - ); - const repositoryName = (httpsMatch?.[1] ?? sshMatch?.[1] ?? "").trim(); - return repositoryName.length > 0 ? decodeURIComponent(repositoryName) : null; -} - export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* () { const azure = yield* AzureDevOpsCli; - const git = yield* GitVcsDriver.GitVcsDriver; - - const currentRepositoryName = (cwd: string) => - git.readConfigValue(cwd, "remote.origin.url").pipe( - Effect.map(parseAzureDevOpsRepositoryNameFromRemoteUrl), - Effect.catch(() => Effect.succeed(null)), - ); return SourceControlProvider.of({ kind: "azure-devops", @@ -132,14 +107,9 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* .getRepositoryCloneUrls(input) .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), getDefaultBranch: (input) => - currentRepositoryName(input.cwd).pipe( - Effect.flatMap((repository) => - repository - ? azure.getDefaultBranch({ cwd: input.cwd, repository }) - : Effect.succeed(null), - ), - Effect.mapError((error) => providerError("getDefaultBranch", error)), - ), + azure + .getDefaultBranch({ cwd: input.cwd }) + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), checkoutChangeRequest: (input) => azure .checkoutPullRequest(input) diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index 4431f4ba10..48c7a83611 100644 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -62,7 +62,9 @@ function normalizeAzureDevOpsPullRequestRecord( baseRefName: normalizeRefName(raw.targetRefName), headRefName: normalizeRefName(raw.sourceRefName), state: normalizeAzureDevOpsPullRequestState(raw.status), - updatedAt: raw.closedDate ?? raw.creationDate ?? Option.none(), + updatedAt: (raw.closedDate ?? Option.none()).pipe( + Option.orElse(() => raw.creationDate ?? Option.none()), + ), }; } From ee7f041f9540c57deb60099fcca803d807fdd422 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 09:32:57 -0700 Subject: [PATCH 4/9] Align Azure provider with shared routing core --- .../src/sourceControl/AzureDevOpsCli.ts | 71 ++++++++++--------- .../AzureDevOpsSourceControlProvider.ts | 52 +++++++++++--- 2 files changed, 78 insertions(+), 45 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index d0517ccb60..3983e14b4e 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -1,4 +1,4 @@ -import { Context, Effect, FileSystem, Layer, Result, Schema, SchemaIssue } from "effect"; +import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; import { TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts"; import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; @@ -8,6 +8,7 @@ import { formatAzureDevOpsJsonDecodeError, type NormalizedAzureDevOpsPullRequestRecord, } from "./azureDevOpsPullRequests.ts"; +import type { SourceControlRefSelector } from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -40,6 +41,7 @@ export interface AzureDevOpsCliShape { readonly listPullRequests: (input: { readonly cwd: string; readonly headSelector: string; + readonly source?: SourceControlRefSelector; readonly state: "open" | "closed" | "merged" | "all"; readonly limit?: number; }) => Effect.Effect, AzureDevOpsCliError>; @@ -58,6 +60,8 @@ export interface AzureDevOpsCliShape { readonly cwd: string; readonly baseBranch: string; readonly headSelector: string; + readonly source?: SourceControlRefSelector; + readonly target?: SourceControlRefSelector; readonly title: string; readonly bodyFile: string; }) => Effect.Effect; @@ -69,6 +73,7 @@ export interface AzureDevOpsCliShape { readonly checkoutPullRequest: (input: { readonly cwd: string; readonly reference: string; + readonly remoteName?: string; }) => Effect.Effect; } @@ -88,7 +93,7 @@ function errorText(error: VcsError | unknown): string { } function normalizeAzureDevOpsCliError( - operation: "execute" | "readBodyFile", + operation: "execute", error: VcsError | unknown, ): AzureDevOpsCliError { const text = errorText(error); @@ -130,7 +135,7 @@ function normalizeAzureDevOpsCliError( return new AzureDevOpsCliError({ operation, - detail: operation === "readBodyFile" ? "Failed to read pull request body file." : text, + detail: text, cause: error, }); } @@ -147,6 +152,13 @@ function normalizeSourceBranch(headSelector: string): string { 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": @@ -209,7 +221,6 @@ function decodeAzureDevOpsJson( export const make = Effect.fn("makeAzureDevOpsCli")(function* () { const process = yield* VcsProcess; - const fileSystem = yield* FileSystem.FileSystem; const execute: AzureDevOpsCliShape["execute"] = (input) => process @@ -228,11 +239,6 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { args: [...input.args, "--only-show-errors", "--output", "json"], }); - const readBodyFile = (path: string) => - fileSystem - .readFileString(path) - .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("readBodyFile", error))); - return AzureDevOpsCli.of({ execute, listPullRequests: (input) => @@ -245,7 +251,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { "--detect", "true", "--source-branch", - normalizeSourceBranch(input.headSelector), + sourceBranch(input), "--status", toAzureStatus(input.state), "--top", @@ -322,30 +328,25 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { Effect.map(normalizeRepositoryCloneUrls), ), createPullRequest: (input) => - readBodyFile(input.bodyFile).pipe( - Effect.flatMap((description) => - execute({ - cwd: input.cwd, - args: [ - "repos", - "pr", - "create", - "--only-show-errors", - "--detect", - "true", - "--target-branch", - input.baseBranch, - "--source-branch", - normalizeSourceBranch(input.headSelector), - "--title", - input.title, - "--description", - description, - ], - }), - ), - Effect.asVoid, - ), + 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, @@ -373,7 +374,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { "--id", normalizeChangeRequestId(input.reference), "--remote-name", - "origin", + input.remoteName ?? "origin", ], }).pipe(Effect.asVoid), }); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index c10e37f723..e5564367ae 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -2,7 +2,7 @@ import { Effect, Layer } from "effect"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; import { AzureDevOpsCli, type AzureDevOpsCliError } from "./AzureDevOpsCli.ts"; -import { SourceControlProvider } from "./SourceControlProvider.ts"; +import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts"; import { combinedAuthOutput, firstSafeAuthLine, @@ -77,31 +77,59 @@ function toChangeRequest(summary: { }; } +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) => - azure.listPullRequests(input).pipe( - Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), - ), + 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) => - azure + 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))), + .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + }, getRepositoryCloneUrls: (input) => azure .getRepositoryCloneUrls(input) @@ -112,7 +140,11 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), checkoutChangeRequest: (input) => azure - .checkoutPullRequest(input) + .checkoutPullRequest({ + cwd: input.cwd, + reference: input.reference, + ...(input.context ? { remoteName: input.context.remoteName } : {}), + }) .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), }); }); From 0d8abab47a3bff88618a546a870d9cf9b1e556c3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 09:52:24 -0700 Subject: [PATCH 5/9] Fix Azure provider test expectations --- apps/server/src/sourceControl/AzureDevOpsCli.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index b2dd496742..7e4ced1f23 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -197,7 +197,7 @@ describe("AzureDevOpsCli.layer", () => { expect.objectContaining({ command: "az", cwd: "/repo", - args: expect.arrayContaining(["--description", "Generated body"]), + args: expect.arrayContaining(["--description", `@${bodyFile}`]), }), ); expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--output"); From fa711c20c6b3799c1c742a4ddf8330d15d20dfc1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 12:18:18 -0700 Subject: [PATCH 6/9] Enable Azure DevOps source control discovery --- apps/server/src/sourceControl/SourceControlDiscovery.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index e304a3f727..d61a38c9ef 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -139,7 +139,7 @@ Logged in to github.com account juliusmarminge (keyring) }, { kind: "azure-devops", - implemented: false, + implemented: true, status: "missing", auth: "unknown", account: Option.none(), From e036c71e708a1e463e17413ce23867eb47a3f655 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 3 May 2026 00:39:40 -0700 Subject: [PATCH 7/9] Finish Azure DevOps provider rebase --- .../SourceControlDiscovery.test.ts | 2 + apps/server/src/ws.ts | 8 ++- docs/source-control-providers.md | 52 +++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index d61a38c9ef..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)({}), 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/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 8f4c162d9a464f8b073dcebbb734d8324b4559b1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 3 May 2026 00:45:28 -0700 Subject: [PATCH 8/9] Fix Azure checkout parsing and detection --- apps/server/src/sourceControl/AzureDevOpsCli.test.ts | 2 ++ apps/server/src/sourceControl/AzureDevOpsCli.ts | 2 ++ apps/web/src/pullRequestReference.test.ts | 4 ++++ apps/web/src/pullRequestReference.ts | 10 +++++++--- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 7e4ced1f23..96989a9c08 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -222,6 +222,8 @@ describe("AzureDevOpsCli.layer", () => { "pr", "checkout", "--only-show-errors", + "--detect", + "true", "--id", "42", "--remote-name", diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index 3983e14b4e..ad90bc8b8a 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -371,6 +371,8 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { "pr", "checkout", "--only-show-errors", + "--detect", + "true", "--id", normalizeChangeRequestId(input.reference), "--remote-name", 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; } From 3c18599d3cb167073fd6d89f82a89adad49ca5ff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 3 May 2026 18:26:21 -0700 Subject: [PATCH 9/9] Support Azure DevOps repository publishing --- .../src/sourceControl/AzureDevOpsCli.test.ts | 54 ++++++++++++++++- .../src/sourceControl/AzureDevOpsCli.ts | 58 ++++++++++++++++++- .../AzureDevOpsSourceControlProvider.ts | 4 ++ 3 files changed, 113 insertions(+), 3 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 96989a9c08..500ec7bc6a 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -66,7 +66,7 @@ describe("AzureDevOpsCli.layer", () => { assert.strictEqual(result.headRefName, "feature/source-control"); assert.strictEqual(result.state, "open"); assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag); - expect(mockRun).toHaveBeenCalledWith({ + assert.deepStrictEqual(mockRun.mock.calls.at(-1)?.[0], { operation: "AzureDevOpsCli.execute", command: "az", args: [ @@ -177,6 +177,58 @@ describe("AzureDevOpsCli.layer", () => { }).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; diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index ad90bc8b8a..0699b94c88 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -1,5 +1,9 @@ import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; -import { TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts"; +import { + TrimmedNonEmptyString, + type SourceControlRepositoryVisibility, + type VcsError, +} from "@t3tools/contracts"; import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; import { @@ -56,6 +60,12 @@ export interface AzureDevOpsCliShape { 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; @@ -201,10 +211,24 @@ function normalizeRepositoryCloneUrls( }; } +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", + operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", invalidDetail: string, ): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( @@ -327,6 +351,36 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { ), 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, diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index e5564367ae..ee1ae8faa6 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -134,6 +134,10 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* 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 })