453 lines · 13.5 KB · TypeScript
RawDownload
import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Result from "effect/Result";import * as Schema from "effect/Schema";import {  TrimmedNonEmptyString,  type SourceControlRepositoryVisibility,  type VcsError,} from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts";import {  decodeAzureDevOpsPullRequestJson,  decodeAzureDevOpsPullRequestListJson,  type NormalizedAzureDevOpsPullRequestRecord,} from "./azureDevOpsPullRequests.ts";import * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliError>()(  "AzureDevOpsCliError",  {    operation: Schema.String,    command: Schema.String,    cwd: Schema.String,    detail: Schema.String,    cause: Schema.optional(Schema.Defect()),  },) {  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;    },    error: VcsError | unknown,  ): AzureDevOpsCliError {    const lower = errorText(error).toLowerCase();     if (lower.includes("command not found: az") || lower.includes("enoent")) {      return new AzureDevOpsCliError({        ...context,        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({        ...context,        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({        ...context,        detail: "Pull request not found. Check the PR number or URL and try again.",        cause: error,      });    }     return new AzureDevOpsCliError({      ...context,      detail: "Azure DevOps CLI command failed.",      cause: error,    });  }} 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<string>;      readonly timeoutMs?: number;    }) => Effect.Effect<VcsProcess.VcsProcessOutput, AzureDevOpsCliError>;     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<NormalizedAzureDevOpsPullRequestRecord>, AzureDevOpsCliError>;     readonly getPullRequest: (input: {      readonly cwd: string;      readonly reference: string;    }) => Effect.Effect<NormalizedAzureDevOpsPullRequestRecord, AzureDevOpsCliError>;     readonly getRepositoryCloneUrls: (input: {      readonly cwd: string;      readonly repository: string;    }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;     readonly createRepository: (input: {      readonly cwd: string;      readonly repository: string;      readonly visibility: SourceControlRepositoryVisibility;    }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;     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<void, AzureDevOpsCliError>;     readonly getDefaultBranch: (input: {      readonly cwd: string;    }) => Effect.Effect<string | null, AzureDevOpsCliError>;     readonly checkoutPullRequest: (input: {      readonly cwd: string;      readonly reference: string;      readonly remoteName?: string;    }) => Effect.Effect<void, AzureDevOpsCliError>;  }>()("t3/sourceControl/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 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<typeof RawAzureDevOpsRepositorySchema>,): 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<string> = [];  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<S extends Schema.Top>(  raw: string,  schema: S,  operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository",  invalidDetail: string,  cwd: string,): Effect.Effect<S["Type"], AzureDevOpsCliError, S["DecodingServices"]> {  return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(    Effect.mapError(      (error) =>        new AzureDevOpsCliError({          operation,          command: "az",          cwd,          detail: invalidDetail,          cause: error,        }),    ),  );} export const make = Effect.gen(function* () {  const process = yield* VcsProcess.VcsProcess;   const execute: AzureDevOpsCli["Service"]["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) =>          AzureDevOpsCliError.fromVcsError(            { operation: "execute", command: "az", cwd: input.cwd },            error,          ),        ),      );   const executeJson = (input: Parameters<AzureDevOpsCli["Service"]["execute"]>[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(() => decodeAzureDevOpsPullRequestListJson(raw)).pipe(                Effect.flatMap((decoded) => {                  if (!Result.isSuccess(decoded)) {                    return Effect.fail(                      new AzureDevOpsCliError({                        operation: "listPullRequests",                        command: "az",                        cwd: input.cwd,                        detail: "Azure DevOps CLI returned invalid PR list JSON.",                        cause: decoded.failure,                      }),                    );                  }                   return Effect.succeed(decoded.success);                }),              ),        ),      ),    getPullRequest: (input) =>      executeJson({        cwd: input.cwd,        args: [          "repos",          "pr",          "show",          "--detect",          "true",          "--id",          normalizeChangeRequestId(input.reference),        ],      }).pipe(        Effect.map((result) => result.stdout.trim()),        Effect.flatMap((raw) =>          Effect.sync(() => decodeAzureDevOpsPullRequestJson(raw)).pipe(            Effect.flatMap((decoded) => {              if (!Result.isSuccess(decoded)) {                return Effect.fail(                  new AzureDevOpsCliError({                    operation: "getPullRequest",                    command: "az",                    cwd: input.cwd,                    detail: "Azure DevOps CLI returned invalid pull request JSON.",                    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.",            input.cwd,          ),        ),        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.",            input.cwd,          ),        ),        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.",            input.cwd,          ),        ),        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);