diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index 792e3a82b13..87621e5c8bc 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -315,10 +315,11 @@ layer("GitLabCli.layer", (it) => { Effect.gen(function* () { const cause = new VcsProcessExitError({ operation: "GitLabCli.execute", - command: "glab mr view 4888", + command: "glab", cwd: "/repo", exitCode: 1, detail: "GET 404 merge request not found", + failureKind: "not-found", }); mockedRun.mockReturnValueOnce(Effect.fail(cause)); @@ -330,11 +331,37 @@ layer("GitLabCli.layer", (it) => { }); }).pipe(Effect.flip); - assert.equal(error.message.includes("Merge request not found"), true); + assert.equal(error.message.includes("Merge request 4888 was not found"), true); + assert.strictEqual(error._tag, "GitLabMergeRequestNotFoundError"); assert.strictEqual(error.command, "glab"); assert.strictEqual(error.cwd, "/repo"); assert.strictEqual(error.cause, cause); assert.equal(error.message.includes(cause.detail), false); }), ); + + it.effect("keeps non-merge-request not-found failures generic", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "GitLabCli.execute", + command: "glab", + cwd: "/repo", + exitCode: 1, + detail: "GET 404 project not found", + failureKind: "not-found", + }); + mockedRun.mockReturnValueOnce(Effect.fail(cause)); + + const error = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "missing/project", + }); + }).pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitLabCliCommandError"); + assert.strictEqual(error.cause, cause); + }), + ); }); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index b34e72ffc95..3e3bbe742c1 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -1,6 +1,7 @@ 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"; @@ -21,13 +22,56 @@ import type * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; -export class GitLabCliError extends Schema.TaggedErrorClass()("GitLabCliError", { - operation: Schema.String, - command: Schema.String, +const gitLabCliExecutionErrorContext = { + operation: Schema.Literal("execute"), + command: Schema.Literal("glab"), cwd: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) { + cause: Schema.Defect(), +}; + +const gitLabCliDecodeErrorContext = { + command: Schema.Literal("glab"), + cwd: Schema.String, + cause: Schema.Defect(), +}; + +export class GitLabCliUnavailableError extends Schema.TaggedErrorClass()( + "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.TaggedErrorClass()( + "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 GitLabMergeRequestNotFoundError extends Schema.TaggedErrorClass()( + "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}`; } @@ -37,52 +81,145 @@ export class GitLabCliError extends Schema.TaggedErrorClass()("G readonly operation: "execute"; readonly command: "glab"; readonly cwd: string; + readonly reference: string; }, - error: VcsError | unknown, + error: VcsError, ): GitLabCliError { - const lower = errorText(error).toLowerCase(); - - if (lower.includes("command not found: glab") || isVcsProcessSpawnError(error)) { - return new GitLabCliError({ - ...context, - detail: "GitLab CLI (`glab`) is required but not available on PATH.", - cause: error, - }); + if (error._tag === "VcsProcessExitError" && error.failureKind === "not-found") { + return new GitLabMergeRequestNotFoundError({ ...context, cause: error }); } - if ( - lower.includes("authentication failed") || - lower.includes("not logged in") || - lower.includes("glab auth login") || - lower.includes("token") - ) { - return new GitLabCliError({ - ...context, - detail: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", - cause: error, - }); - } + return GitLabCliCommandError.fromVcsError( + { + operation: context.operation, + command: context.command, + cwd: context.cwd, + }, + error, + ); + } +} - if ( - lower.includes("merge request not found") || - lower.includes("not found") || - lower.includes("404") - ) { - return new GitLabCliError({ - ...context, - detail: "Merge request not found. Check the MR number or URL and try again.", - cause: error, - }); - } +export class GitLabCliCommandError extends Schema.TaggedErrorClass()( + "GitLabCliCommandError", + gitLabCliExecutionErrorContext, +) { + get detail(): string { + return "GitLab CLI command failed."; + } - return new GitLabCliError({ - ...context, - detail: "GitLab CLI command failed.", - cause: error, + 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 "not-found": + case "command-failed": + case undefined: + return new GitLabCliCommandError({ ...context, cause }); + } + }, + VcsProcessTimeoutError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsOutputDecodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), }); } } +export class GitLabMergeRequestListDecodeError extends Schema.TaggedErrorClass()( + "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.TaggedErrorClass()( + "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.TaggedErrorClass()( + "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.TaggedErrorClass()( + "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, + GitLabMergeRequestNotFoundError, + GitLabCliCommandError, + GitLabMergeRequestListDecodeError, + GitLabMergeRequestDecodeError, + GitLabRepositoryDecodeError, + GitLabNamespaceDecodeError, +]); +export type GitLabCliError = typeof GitLabCliError.Type; +export const isGitLabCliError = Schema.is(GitLabCliError); + export interface GitLabMergeRequestSummary { readonly number: number; readonly title: string; @@ -102,17 +239,6 @@ export interface GitLabRepositoryCloneUrls { readonly sshUrl: string; } -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); -} - export class GitLabCli extends Context.Service< GitLabCli, { @@ -168,15 +294,6 @@ export class GitLabCli extends Context.Service< } >()("t3/sourceControl/GitLabCli") {} -function isVcsProcessSpawnError(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - error._tag === "VcsProcessSpawnError" - ); -} - const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({ path_with_namespace: TrimmedNonEmptyString, web_url: TrimmedNonEmptyString, @@ -192,6 +309,14 @@ 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, ): GitLabRepositoryCloneUrls { @@ -202,27 +327,6 @@ function normalizeRepositoryCloneUrls( }; } -function decodeGitLabJson( - raw: string, - schema: S, - operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", - invalidDetail: string, - cwd: string, -): Effect.Effect { - return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( - Effect.mapError( - (error) => - new GitLabCliError({ - operation, - command: "glab", - cwd, - detail: invalidDetail, - cause: error, - }), - ), - ); -} - function stateArgs(state: "open" | "closed" | "merged" | "all"): ReadonlyArray { switch (state) { case "open": @@ -283,7 +387,10 @@ function parseRepositoryPath(repository: string): { export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; - const execute: GitLabCli["Service"]["execute"] = (input) => + const run = ( + input: Parameters[0], + mapError: (error: VcsError) => GitLabCliError, + ) => process .run({ operation: "GitLabCli.execute", @@ -292,14 +399,32 @@ export const make = Effect.gen(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe( - Effect.mapError((error) => - GitLabCliError.fromVcsError( - { operation: "execute", command: "glab", cwd: input.cwd }, - error, - ), - ), - ); + .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; + }) => + run(input, (error) => + GitLabMergeRequestNotFoundError.fromVcsError( + { + operation: "execute", + command: "glab", + cwd: input.cwd, + reference: input.reference, + }, + error, + ), + ); return GitLabCli.of({ execute, @@ -326,11 +451,10 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitLabCliError({ + new GitLabMergeRequestListDecodeError({ operation: "listMergeRequests", command: "glab", cwd: input.cwd, - detail: "GitLab CLI returned invalid MR list JSON.", cause: decoded.failure, }), ); @@ -342,8 +466,9 @@ export const make = Effect.gen(function* () { ), ), getMergeRequest: (input) => - execute({ + executeMergeRequest({ cwd: input.cwd, + reference: input.reference, args: ["mr", "view", input.reference, "--output", "json"], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -352,11 +477,11 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitLabCliError({ + new GitLabMergeRequestDecodeError({ operation: "getMergeRequest", command: "glab", cwd: input.cwd, - detail: "GitLab CLI returned invalid merge request JSON.", + reference: input.reference, cause: decoded.failure, }), ); @@ -374,12 +499,17 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabRepositoryCloneUrlsSchema, - "getRepositoryCloneUrls", - "GitLab CLI returned invalid repository JSON.", - input.cwd, + decodeGitLabRepositoryCloneUrls(raw).pipe( + Effect.mapError( + (cause) => + new GitLabRepositoryDecodeError({ + operation: "getRepositoryCloneUrls", + command: "glab", + cwd: input.cwd, + repository: input.repository, + cause, + }), + ), ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -393,12 +523,17 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabNamespaceSchema, - "createRepository", - "GitLab CLI returned invalid namespace JSON.", - input.cwd, + decodeGitLabNamespace(raw).pipe( + Effect.mapError( + (cause) => + new GitLabNamespaceDecodeError({ + operation: "createRepository", + command: "glab", + cwd: input.cwd, + namespacePath, + cause, + }), + ), ), ), Effect.map((namespace) => namespace.id), @@ -428,12 +563,17 @@ export const make = Effect.gen(function* () { ), Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabRepositoryCloneUrlsSchema, - "createRepository", - "GitLab CLI returned invalid repository JSON.", - input.cwd, + decodeGitLabRepositoryCloneUrls(raw).pipe( + Effect.mapError( + (cause) => + new GitLabRepositoryDecodeError({ + operation: "createRepository", + command: "glab", + cwd: input.cwd, + repository: input.repository, + cause, + }), + ), ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -467,19 +607,24 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabDefaultBranchSchema, - "getDefaultBranch", - "GitLab CLI returned invalid repository JSON.", - input.cwd, + decodeGitLabDefaultBranch(raw).pipe( + Effect.mapError( + (cause) => + new GitLabRepositoryDecodeError({ + operation: "getDefaultBranch", + command: "glab", + cwd: input.cwd, + cause, + }), + ), ), ), Effect.map((value) => value.default_branch ?? null), ), checkoutMergeRequest: (input) => - execute({ + executeMergeRequest({ cwd: input.cwd, + reference: input.reference, args: ["mr", "checkout", input.reference], }).pipe(Effect.asVoid), }); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 6ab3f23b150..0d06e066521 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -54,11 +54,10 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = it.effect("adds repository context while retaining GitLab CLI causes", () => Effect.gen(function* () { - const cause = new GitLabCli.GitLabCliError({ + const cause = new GitLabCli.GitLabCliCommandError({ operation: "execute", command: "glab", cwd: "/repo", - detail: "GitLab CLI command failed.", cause: new Error("raw upstream detail that should remain in the cause"), }); const provider = yield* makeProvider({