import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { NonNegativeInt, TrimmedNonEmptyString, type SourceControlRepositoryVisibility, type VcsError, type VcsProcessExitFailureKind, } from "@t3tools/contracts"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import { decodeAzureDevOpsPullRequestJson, decodeAzureDevOpsPullRequestListJson, type NormalizedAzureDevOpsPullRequestRecord, } from "./azureDevOpsPullRequests.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; const DEFAULT_TIMEOUT_MS = 30_000; const azureDevOpsCommandErrorFields = { operation: Schema.Literal("execute"), command: Schema.Literal("az"), cwd: Schema.String, argumentCount: NonNegativeInt, cause: Schema.Defect(), }; export class AzureDevOpsCliUnavailableError extends Schema.TaggedError()( "AzureDevOpsCliUnavailableError", azureDevOpsCommandErrorFields, ) { get detail(): string { return "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH."; } override get message(): string { return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } } export class AzureDevOpsCliAuthenticationError extends Schema.TaggedError()( "AzureDevOpsCliAuthenticationError", azureDevOpsCommandErrorFields, ) { get detail(): string { return "Azure DevOps CLI is not authenticated. Run `az devops login` and retry."; } override get message(): string { return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } } export class AzureDevOpsCliRateLimitError extends Schema.TaggedError()( "AzureDevOpsCliRateLimitError", azureDevOpsCommandErrorFields, ) { get detail(): string { return "Azure DevOps API rate limit exceeded."; } override get message(): string { return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } } export class AzureDevOpsPullRequestNotFoundError extends Schema.TaggedError()( "AzureDevOpsPullRequestNotFoundError", azureDevOpsCommandErrorFields, ) { get detail(): string { return "Pull request not found. Check the PR number or URL and try again."; } override get message(): string { return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } } export class AzureDevOpsCommandFailedError extends Schema.TaggedError()( "AzureDevOpsCommandFailedError", azureDevOpsCommandErrorFields, ) { get detail(): string { return "Azure DevOps CLI command failed."; } override get message(): string { return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } static fromVcsError( context: { readonly operation: "execute"; readonly command: "az"; readonly cwd: string; readonly argumentCount: number; }, cause: VcsError, ): AzureDevOpsCliError { const fields = { ...context, cause }; if ( cause._tag === "VcsProcessSpawnError" && cause.cause instanceof PlatformError.PlatformError && cause.cause.reason._tag === "NotFound" && cause.cause.reason.pathOrDescriptor !== context.cwd && cause.cause.reason.syscall !== "chdir" ) { return new AzureDevOpsCliUnavailableError(fields); } if (cause._tag === "VcsProcessExitError") { if (cause.failureKind === "authentication") { return new AzureDevOpsCliAuthenticationError(fields); } if (cause.failureKind === "rate-limited") { return new AzureDevOpsCliRateLimitError(fields); } if (cause.failureKind === "not-found") { return new AzureDevOpsPullRequestNotFoundError(fields); } } return new AzureDevOpsCommandFailedError(fields); } } const azureDevOpsDecodeErrorFields = { command: Schema.Literal("az"), cwd: Schema.String, outputLength: NonNegativeInt, cause: Schema.Defect(), }; export class AzureDevOpsPullRequestListDecodeError extends Schema.TaggedError()( "AzureDevOpsPullRequestListDecodeError", { operation: Schema.Literal("listPullRequests"), ...azureDevOpsDecodeErrorFields, }, ) { get detail(): string { return "Azure DevOps CLI returned invalid PR list JSON."; } override get message(): string { return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } } export class AzureDevOpsPullRequestDecodeError extends Schema.TaggedError()( "AzureDevOpsPullRequestDecodeError", { operation: Schema.Literal("getPullRequest"), ...azureDevOpsDecodeErrorFields, }, ) { get detail(): string { return "Azure DevOps CLI returned invalid pull request JSON."; } override get message(): string { return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } } const AzureDevOpsRepositoryDecodeOperation = Schema.Literals([ "getRepositoryCloneUrls", "getDefaultBranch", "createRepository", ]); export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedError()( "AzureDevOpsRepositoryDecodeError", { operation: AzureDevOpsRepositoryDecodeOperation, ...azureDevOpsDecodeErrorFields, }, ) { get detail(): string { return "Azure DevOps CLI returned invalid repository JSON."; } override get message(): string { return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } } export const AzureDevOpsCliError = Schema.Union([ AzureDevOpsCliUnavailableError, AzureDevOpsCliAuthenticationError, AzureDevOpsCliRateLimitError, AzureDevOpsPullRequestNotFoundError, AzureDevOpsCommandFailedError, AzureDevOpsPullRequestListDecodeError, AzureDevOpsPullRequestDecodeError, AzureDevOpsRepositoryDecodeError, ]); export type AzureDevOpsCliError = typeof AzureDevOpsCliError.Type; export interface AzureDevOpsRepositoryCloneUrls { readonly nameWithOwner: string; readonly url: string; readonly sshUrl: string; } export class AzureDevOpsCli extends Context.Service< AzureDevOpsCli, { readonly execute: (input: { readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; readonly maxOutputBytes?: number; }) => Effect.Effect; readonly listPullRequests: (input: { readonly cwd: string; readonly headSelector: string; readonly source?: SourceControlProvider.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?: SourceControlProvider.SourceControlRefSelector; readonly target?: SourceControlProvider.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; } >()("@t3tools/source-control-azure-devops/server/AzureDevOpsCli") {} 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 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: Array = []; for (const part of repository.split("/")) { const trimmed = part.trim(); if (trimmed.length > 0) { parts.push(trimmed); } } return { project: parts.length > 1 ? (parts.at(-2) ?? null) : null, name: parts.at(-1) ?? repository.trim(), }; } function decodeAzureDevOpsJson( raw: string, schema: S, operation: typeof AzureDevOpsRepositoryDecodeOperation.Type, cwd: string, ): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( Effect.mapError( (cause) => new AzureDevOpsRepositoryDecodeError({ operation, command: "az", cwd, outputLength: raw.length, cause, }), ), ); } const classifyAzFailure = (stderr: string): VcsProcessExitFailureKind | null => { const normalized = stderr.toLowerCase(); if (normalized.includes("az devops login") || /please run '?az login'?/.test(normalized)) { return "authentication"; } return normalized.includes("pull request") && (normalized.includes("not found") || normalized.includes("does not exist")) ? "not-found" : null; }; /** @public */ export const make = Effect.gen(function* () { const { process } = yield* SourceControlHost.SourceControlHost; const execute: AzureDevOpsCli["Service"]["execute"] = (input) => process .run({ operation: "AzureDevOpsCli.execute", command: "az", args: input.args, cwd: input.cwd, classifyFailure: classifyAzFailure, notFoundDetail: "Pull request not found.", timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), }) .pipe( Effect.mapError((error) => AzureDevOpsCommandFailedError.fromVcsError( { operation: "execute", command: "az", cwd: input.cwd, argumentCount: input.args.length, }, error, ), ), ); const executeJson = (input: Parameters[0]) => execute({ ...input, args: [...input.args, "--only-show-errors", "--output", "json"], }); return AzureDevOpsCli.of({ execute, listPullRequests: Effect.fn("AzureDevOpsCli.listPullRequests")(function (input) { return executeJson({ cwd: input.cwd, args: [ "repos", "pr", "list", "--detect", "true", "--source-branch", SourceControlProvider.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 AzureDevOpsPullRequestListDecodeError({ operation: "listPullRequests", command: "az", cwd: input.cwd, outputLength: raw.length, cause: decoded.failure, }), ); } return Effect.succeed(decoded.success); }), ), ), ); }), getPullRequest: Effect.fn("AzureDevOpsCli.getPullRequest")(function (input) { return 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 AzureDevOpsPullRequestDecodeError({ operation: "getPullRequest", command: "az", cwd: input.cwd, outputLength: raw.length, cause: decoded.failure, }), ); } return Effect.succeed(decoded.success); }), ), ), ); }), getRepositoryCloneUrls: Effect.fn("AzureDevOpsCli.getRepositoryCloneUrls")(function (input) { const repository = parseRepositorySpecifier(input.repository); return executeJson({ cwd: input.cwd, args: [ "repos", "show", "--detect", "true", "--repository", repository.name, ...(repository.project ? ["--project", repository.project] : []), ], }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => decodeAzureDevOpsJson( raw, RawAzureDevOpsRepositorySchema, "getRepositoryCloneUrls", input.cwd, ), ), Effect.map(normalizeRepositoryCloneUrls), ); }), createRepository: Effect.fn("AzureDevOpsCli.createRepository")(function (input) { const repository = parseRepositorySpecifier(input.repository); // Project/organization permissions control visibility; `az repos create` has no flag. 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", input.cwd), ), Effect.map(normalizeRepositoryCloneUrls), ); }), createPullRequest: Effect.fn("AzureDevOpsCli.createPullRequest")(function (input) { return execute({ cwd: input.cwd, args: [ "repos", "pr", "create", "--only-show-errors", "--detect", "true", "--target-branch", input.target?.refName ?? input.baseBranch, "--source-branch", SourceControlProvider.sourceBranch(input), "--title", input.title, "--description", `@${input.bodyFile}`, ], }).pipe(Effect.asVoid); }), getDefaultBranch: Effect.fn("AzureDevOpsCli.getDefaultBranch")(function (input) { return executeJson({ cwd: input.cwd, args: ["repos", "show", "--detect", "true"], }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "getDefaultBranch", input.cwd), ), Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), ); }), checkoutPullRequest: Effect.fn("AzureDevOpsCli.checkoutPullRequest")(function (input) { return 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);