import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility, type VcsError, } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsPullRequests from "./azureDevOpsPullRequests.ts"; import * as SourceControlProvider 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?: SourceControlProvider.SourceControlRefSelector; readonly state: "open" | "closed" | "merged" | "all"; readonly limit?: number; }) => Effect.Effect< ReadonlyArray, AzureDevOpsCliError >; readonly getPullRequest: (input: { readonly cwd: string; readonly reference: string; }) => Effect.Effect< AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord, AzureDevOpsCliError >; 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; } 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 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.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", 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(() => AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestListJson(raw), ).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new AzureDevOpsCliError({ operation: "listPullRequests", detail: `Azure DevOps CLI returned invalid PR list JSON: ${AzureDevOpsPullRequests.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(() => AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new AzureDevOpsCliError({ operation: "getPullRequest", detail: `Azure DevOps CLI returned invalid pull request JSON: ${AzureDevOpsPullRequests.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", SourceControlProvider.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());