434 lines · 13.2 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 * as SchemaIssue from "effect/SchemaIssue";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>()(  "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<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<AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord>,    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<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>;} export class AzureDevOpsCli extends Context.Service<AzureDevOpsCli, AzureDevOpsCliShape>()(  "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<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 = 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<S extends Schema.Top>(  raw: string,  schema: S,  operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository",  invalidDetail: string,): Effect.Effect<S["Type"], AzureDevOpsCliError, S["DecodingServices"]> {  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<AzureDevOpsCliShape["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(() =>                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());