660 lines · 20.3 KB · TypeScript
RawDownload
import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Match from "effect/Match";import * as Option from "effect/Option";import * as Result from "effect/Result";import * as Schema from "effect/Schema";import type * as DateTime from "effect/DateTime"; import {  TrimmedNonEmptyString,  type SourceControlRepositoryVisibility,  type VcsError,} from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts";import {  decodeGitLabMergeRequestJson,  decodeGitLabMergeRequestListJson,} from "./gitLabMergeRequests.ts";import type * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; const gitLabCliExecutionErrorContext = {  operation: Schema.Literal("execute"),  command: Schema.Literal("glab"),  cwd: Schema.String,  cause: Schema.Defect(),}; const gitLabCliDecodeErrorContext = {  command: Schema.Literal("glab"),  cwd: Schema.String,  cause: Schema.Defect(),}; export class GitLabCliUnavailableError extends Schema.TaggedError<GitLabCliUnavailableError>()(  "GitLabCliUnavailableError",  gitLabCliExecutionErrorContext,) {  get detail(): string {    return "GitLab CLI (`glab`) is required but not available on PATH.";  }   override get message(): string {    return `GitLab CLI failed in ${this.operation}: ${this.detail}`;  }} export class GitLabCliAuthenticationError extends Schema.TaggedError<GitLabCliAuthenticationError>()(  "GitLabCliAuthenticationError",  gitLabCliExecutionErrorContext,) {  get detail(): string {    return "GitLab CLI is not authenticated. Run `glab auth login` and retry.";  }   override get message(): string {    return `GitLab CLI failed in ${this.operation}: ${this.detail}`;  }} export class GitLabCliRateLimitError extends Schema.TaggedError<GitLabCliRateLimitError>()(  "GitLabCliRateLimitError",  gitLabCliExecutionErrorContext,) {  get detail(): string {    return "GitLab API rate limit exceeded.";  }   override get message(): string {    return `GitLab CLI failed in ${this.operation}: ${this.detail}`;  }} export class GitLabMergeRequestNotFoundError extends Schema.TaggedError<GitLabMergeRequestNotFoundError>()(  "GitLabMergeRequestNotFoundError",  {    ...gitLabCliExecutionErrorContext,    reference: Schema.String,  },) {  get detail(): string {    return `Merge request ${this.reference} was not found. Check the MR number or URL and try again.`;  }   override get message(): string {    return `GitLab CLI failed in ${this.operation}: ${this.detail}`;  }   static fromVcsError(    context: {      readonly operation: "execute";      readonly command: "glab";      readonly cwd: string;      readonly reference: string;    },    error: VcsError,  ): GitLabCliError {    if (error._tag === "VcsProcessExitError" && error.failureKind === "not-found") {      return new GitLabMergeRequestNotFoundError({ ...context, cause: error });    }     return GitLabCliCommandError.fromVcsError(      {        operation: context.operation,        command: context.command,        cwd: context.cwd,      },      error,    );  }} export class GitLabCliCommandError extends Schema.TaggedError<GitLabCliCommandError>()(  "GitLabCliCommandError",  gitLabCliExecutionErrorContext,) {  get detail(): string {    return "GitLab CLI command failed.";  }   override get message(): string {    return `GitLab CLI failed in ${this.operation}: ${this.detail}`;  }   static fromVcsError(    context: {      readonly operation: "execute";      readonly command: "glab";      readonly cwd: string;    },    error: VcsError,  ): GitLabCliError {    return Match.valueTags(error, {      VcsProcessSpawnError: (cause) => new GitLabCliUnavailableError({ ...context, cause }),      VcsProcessExitError: (cause) => {        switch (cause.failureKind) {          case "authentication":            return new GitLabCliAuthenticationError({ ...context, cause });          case "rate-limited":            return new GitLabCliRateLimitError({ ...context, cause });          case "not-found":          case "command-failed":          case undefined:            return new GitLabCliCommandError({ ...context, cause });        }      },      VcsProcessTimeoutError: (cause) => new GitLabCliCommandError({ ...context, cause }),      VcsProcessStdinWriteError: (cause) => new GitLabCliCommandError({ ...context, cause }),      VcsProcessOutputReadError: (cause) => new GitLabCliCommandError({ ...context, cause }),      VcsProcessOutputLimitError: (cause) => new GitLabCliCommandError({ ...context, cause }),      VcsProcessMissingExitCodeError: (cause) => new GitLabCliCommandError({ ...context, cause }),      VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }),      VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }),    });  }} export class GitLabMergeRequestListDecodeError extends Schema.TaggedError<GitLabMergeRequestListDecodeError>()(  "GitLabMergeRequestListDecodeError",  {    ...gitLabCliDecodeErrorContext,    operation: Schema.Literal("listMergeRequests"),  },) {  get detail(): string {    return "GitLab CLI returned invalid MR list JSON.";  }   override get message(): string {    return `GitLab CLI failed in ${this.operation}: ${this.detail}`;  }} export class GitLabMergeRequestDecodeError extends Schema.TaggedError<GitLabMergeRequestDecodeError>()(  "GitLabMergeRequestDecodeError",  {    ...gitLabCliDecodeErrorContext,    operation: Schema.Literal("getMergeRequest"),    reference: Schema.String,  },) {  get detail(): string {    return "GitLab CLI returned invalid merge request JSON.";  }   override get message(): string {    return `GitLab CLI failed in ${this.operation}: ${this.detail}`;  }} export class GitLabRepositoryDecodeError extends Schema.TaggedError<GitLabRepositoryDecodeError>()(  "GitLabRepositoryDecodeError",  {    ...gitLabCliDecodeErrorContext,    operation: Schema.Literals(["getRepositoryCloneUrls", "createRepository", "getDefaultBranch"]),    repository: Schema.optional(Schema.String),  },) {  get detail(): string {    return "GitLab CLI returned invalid repository JSON.";  }   override get message(): string {    return `GitLab CLI failed in ${this.operation}: ${this.detail}`;  }} export class GitLabNamespaceDecodeError extends Schema.TaggedError<GitLabNamespaceDecodeError>()(  "GitLabNamespaceDecodeError",  {    ...gitLabCliDecodeErrorContext,    operation: Schema.Literal("createRepository"),    namespacePath: Schema.String,  },) {  get detail(): string {    return "GitLab CLI returned invalid namespace JSON.";  }   override get message(): string {    return `GitLab CLI failed in ${this.operation}: ${this.detail}`;  }} export const GitLabCliError = Schema.Union([  GitLabCliUnavailableError,  GitLabCliAuthenticationError,  GitLabCliRateLimitError,  GitLabMergeRequestNotFoundError,  GitLabCliCommandError,  GitLabMergeRequestListDecodeError,  GitLabMergeRequestDecodeError,  GitLabRepositoryDecodeError,  GitLabNamespaceDecodeError,]);export type GitLabCliError = typeof GitLabCliError.Type; export interface GitLabMergeRequestSummary {  readonly number: number;  readonly title: string;  readonly url: string;  readonly baseRefName: string;  readonly headRefName: string;  readonly state?: "open" | "closed" | "merged";  readonly isDraft?: boolean;  readonly closedAt?: string | null;  readonly mergedAt?: string | null;  readonly updatedAt?: Option.Option<DateTime.Utc>;  readonly isCrossRepository?: boolean;  readonly headRepositoryNameWithOwner?: string | null;  readonly headRepositoryOwnerLogin?: string | null;} export interface GitLabRepositoryCloneUrls {  readonly nameWithOwner: string;  readonly url: string;  readonly sshUrl: string;} export class GitLabCli extends Context.Service<  GitLabCli,  {    readonly execute: (input: {      readonly cwd: string;      readonly args: ReadonlyArray<string>;      readonly timeoutMs?: number;      /** Piped to the child's stdin, for payloads that must never appear in argv. */      readonly stdin?: string;      readonly maxOutputBytes?: number;    }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitLabCliError>;     readonly listMergeRequests: (input: {      readonly cwd: string;      readonly headSelector: string;      readonly source?: SourceControlProvider.SourceControlRefSelector;      readonly state: "open" | "closed" | "merged" | "all";      readonly limit?: number;    }) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>;     readonly getMergeRequest: (input: {      readonly cwd: string;      readonly reference: string;    }) => Effect.Effect<GitLabMergeRequestSummary, GitLabCliError>;     readonly getRepositoryCloneUrls: (input: {      readonly cwd: string;      readonly repository: string;    }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;     readonly createRepository: (input: {      readonly cwd: string;      readonly repository: string;      readonly visibility: SourceControlRepositoryVisibility;    }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;     readonly createMergeRequest: (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, GitLabCliError>;     readonly getDefaultBranch: (input: {      readonly cwd: string;    }) => Effect.Effect<string | null, GitLabCliError>;     readonly checkoutMergeRequest: (input: {      readonly cwd: string;      readonly reference: string;      readonly force?: boolean;    }) => Effect.Effect<void, GitLabCliError>;  }>()("t3/sourceControl/GitLabCli") {} const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({  path_with_namespace: TrimmedNonEmptyString,  web_url: TrimmedNonEmptyString,  http_url_to_repo: TrimmedNonEmptyString,  ssh_url_to_repo: TrimmedNonEmptyString,}); const RawGitLabDefaultBranchSchema = Schema.Struct({  default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),}); const RawGitLabNamespaceSchema = Schema.Struct({  id: Schema.Number,}); const decodeGitLabRepositoryCloneUrls = Schema.decodeEffect(  Schema.fromJsonString(RawGitLabRepositoryCloneUrlsSchema),);const decodeGitLabDefaultBranch = Schema.decodeEffect(  Schema.fromJsonString(RawGitLabDefaultBranchSchema),);const decodeGitLabNamespace = Schema.decodeEffect(Schema.fromJsonString(RawGitLabNamespaceSchema)); function normalizeRepositoryCloneUrls(  raw: Schema.Schema.Type<typeof RawGitLabRepositoryCloneUrlsSchema>,): GitLabRepositoryCloneUrls {  return {    nameWithOwner: raw.path_with_namespace,    url: raw.web_url,    sshUrl: raw.ssh_url_to_repo,  };} function stateArgs(state: "open" | "closed" | "merged" | "all"): ReadonlyArray<string> {  switch (state) {    case "open":      return [];    case "closed":      return ["--closed"];    case "merged":      return ["--merged"];    case "all":      return ["--all"];  }} function normalizeHeadSelector(headSelector: string): string {  const trimmed = headSelector.trim();  const ownerBranch = /^[^:]+:(.+)$/.exec(trimmed);  return ownerBranch?.[1]?.trim() || trimmed;} function sourceRefName(input: {  readonly headSelector: string;  readonly source?: SourceControlProvider.SourceControlRefSelector;}): string {  return input.source?.refName ?? normalizeHeadSelector(input.headSelector);} function sourceProjectIdentifier(  source: SourceControlProvider.SourceControlRefSelector | undefined,): string | null {  return source?.repository ?? source?.owner ?? null;} function toSummaryWithOptionalUpdatedAt(  record: GitLabMergeRequestSummary & {    readonly updatedAt: Option.Option<DateTime.Utc>;  },): GitLabMergeRequestSummary {  const { updatedAt, ...summary } = record;  return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary;} function parseRepositoryPath(repository: string): {  readonly namespacePath: string | null;  readonly projectPath: string;} {  const parts: Array<string> = [];  for (const part of repository.split("/")) {    const trimmed = part.trim();    if (trimmed.length > 0) {      parts.push(trimmed);    }  }  const projectPath = parts.at(-1) ?? repository.trim();  const namespacePath = parts.length > 1 ? parts.slice(0, -1).join("/") : null;  return { namespacePath, projectPath };} /** @public Service construction is part of the canonical Effect module API. */export const make = Effect.gen(function* () {  const process = yield* VcsProcess.VcsProcess;   const run = (    input: Parameters<GitLabCli["Service"]["execute"]>[0],    mapError: (error: VcsError) => GitLabCliError,  ) =>    process      .run({        operation: "GitLabCli.execute",        command: "glab",        args: input.args,        cwd: input.cwd,        timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,        ...(input.stdin === undefined ? {} : { stdin: input.stdin }),        ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),      })      .pipe(Effect.mapError(mapError));   const execute: GitLabCli["Service"]["execute"] = (input) =>    run(input, (error) =>      GitLabCliCommandError.fromVcsError(        { operation: "execute", command: "glab", cwd: input.cwd },        error,      ),    );   const executeMergeRequest = (input: {    readonly cwd: string;    readonly reference: string;    readonly args: ReadonlyArray<string>;  }) =>    run(input, (error) =>      GitLabMergeRequestNotFoundError.fromVcsError(        {          operation: "execute",          command: "glab",          cwd: input.cwd,          reference: input.reference,        },        error,      ),    );   return GitLabCli.of({    execute,    listMergeRequests: (input) =>      execute({        cwd: input.cwd,        args: [          "mr",          "list",          "--source-branch",          sourceRefName(input),          ...stateArgs(input.state),          "--per-page",          String(input.limit ?? 20),          "--output",          "json",        ],      }).pipe(        Effect.map((result) => result.stdout.trim()),        Effect.flatMap((raw) =>          raw.length === 0            ? Effect.succeed([])            : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe(                Effect.flatMap((decoded) => {                  if (!Result.isSuccess(decoded)) {                    return Effect.fail(                      new GitLabMergeRequestListDecodeError({                        operation: "listMergeRequests",                        command: "glab",                        cwd: input.cwd,                        cause: decoded.failure,                      }),                    );                  }                   return Effect.succeed(decoded.success.map(toSummaryWithOptionalUpdatedAt));                }),              ),        ),      ),    getMergeRequest: (input) =>      executeMergeRequest({        cwd: input.cwd,        reference: input.reference,        args: ["mr", "view", input.reference, "--output", "json"],      }).pipe(

Lines 1–500 of 660.