authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 18:41:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 18:41:25-07:00
log1a59277dc84105e2131c2bc828deb51c0b12d7f6
tree51f0ab8e4fadb3a3b9ce6d923db3522bc4c8b837
parent6424197a19a28d5992be555633a6e37fd0a58573
signature Signed by PGP key B5690EEEBB952194

[codex] Structure GitLab CLI failures (#3458)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 297 insertions(+), 126 deletions(-)

apps/server/src/sourceControl/GitLabCli.test.ts+29-2
......@@ -315,10 +315,11 @@ layer("GitLabCli.layer", (it) => {
315315 Effect.gen(function* () {
316316 const cause = new VcsProcessExitError({
317317 operation: "GitLabCli.execute",
318 command: "glab mr view 4888",
318 command: "glab",
319319 cwd: "/repo",
320320 exitCode: 1,
321321 detail: "GET 404 merge request not found",
322 failureKind: "not-found",
322323 });
323324 mockedRun.mockReturnValueOnce(Effect.fail(cause));
324325
......@@ -330,11 +331,37 @@ layer("GitLabCli.layer", (it) => {
330331 });
331332 }).pipe(Effect.flip);
332333
333 assert.equal(error.message.includes("Merge request not found"), true);
334 assert.equal(error.message.includes("Merge request 4888 was not found"), true);
335 assert.strictEqual(error._tag, "GitLabMergeRequestNotFoundError");
334336 assert.strictEqual(error.command, "glab");
335337 assert.strictEqual(error.cwd, "/repo");
336338 assert.strictEqual(error.cause, cause);
337339 assert.equal(error.message.includes(cause.detail), false);
338340 }),
339341 );
342
343 it.effect("keeps non-merge-request not-found failures generic", () =>
344 Effect.gen(function* () {
345 const cause = new VcsProcessExitError({
346 operation: "GitLabCli.execute",
347 command: "glab",
348 cwd: "/repo",
349 exitCode: 1,
350 detail: "GET 404 project not found",
351 failureKind: "not-found",
352 });
353 mockedRun.mockReturnValueOnce(Effect.fail(cause));
354
355 const error = yield* Effect.gen(function* () {
356 const glab = yield* GitLabCli.GitLabCli;
357 return yield* glab.getRepositoryCloneUrls({
358 cwd: "/repo",
359 repository: "missing/project",
360 });
361 }).pipe(Effect.flip);
362
363 assert.strictEqual(error._tag, "GitLabCliCommandError");
364 assert.strictEqual(error.cause, cause);
365 }),
366 );
340367});
apps/server/src/sourceControl/GitLabCli.ts+267-122
......@@ -1,6 +1,7 @@
11import * as Context from "effect/Context";
22import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
4import * as Match from "effect/Match";
45import * as Option from "effect/Option";
56import * as Result from "effect/Result";
67import * as Schema from "effect/Schema";
......@@ -21,13 +22,56 @@ import type * as SourceControlProvider from "./SourceControlProvider.ts";
2122
2223const DEFAULT_TIMEOUT_MS = 30_000;
2324
24export class GitLabCliError extends Schema.TaggedErrorClass<GitLabCliError>()("GitLabCliError", {
25 operation: Schema.String,
26 command: Schema.String,
25const gitLabCliExecutionErrorContext = {
26 operation: Schema.Literal("execute"),
27 command: Schema.Literal("glab"),
2728 cwd: Schema.String,
28 detail: Schema.String,
29 cause: Schema.optional(Schema.Defect()),
30}) {
29 cause: Schema.Defect(),
30};
31
32const gitLabCliDecodeErrorContext = {
33 command: Schema.Literal("glab"),
34 cwd: Schema.String,
35 cause: Schema.Defect(),
36};
37
38export class GitLabCliUnavailableError extends Schema.TaggedErrorClass<GitLabCliUnavailableError>()(
39 "GitLabCliUnavailableError",
40 gitLabCliExecutionErrorContext,
41) {
42 get detail(): string {
43 return "GitLab CLI (`glab`) is required but not available on PATH.";
44 }
45
46 override get message(): string {
47 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
48 }
49}
50
51export class GitLabCliAuthenticationError extends Schema.TaggedErrorClass<GitLabCliAuthenticationError>()(
52 "GitLabCliAuthenticationError",
53 gitLabCliExecutionErrorContext,
54) {
55 get detail(): string {
56 return "GitLab CLI is not authenticated. Run `glab auth login` and retry.";
57 }
58
59 override get message(): string {
60 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
61 }
62}
63
64export class GitLabMergeRequestNotFoundError extends Schema.TaggedErrorClass<GitLabMergeRequestNotFoundError>()(
65 "GitLabMergeRequestNotFoundError",
66 {
67 ...gitLabCliExecutionErrorContext,
68 reference: Schema.String,
69 },
70) {
71 get detail(): string {
72 return `Merge request ${this.reference} was not found. Check the MR number or URL and try again.`;
73 }
74
3175 override get message(): string {
3276 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
3377 }
......@@ -37,52 +81,145 @@ export class GitLabCliError extends Schema.TaggedErrorClass<GitLabCliError>()("G
3781 readonly operation: "execute";
3882 readonly command: "glab";
3983 readonly cwd: string;
84 readonly reference: string;
4085 },
41 error: VcsError | unknown,
86 error: VcsError,
4287 ): GitLabCliError {
43 const lower = errorText(error).toLowerCase();
44
45 if (lower.includes("command not found: glab") || isVcsProcessSpawnError(error)) {
46 return new GitLabCliError({
47 ...context,
48 detail: "GitLab CLI (`glab`) is required but not available on PATH.",
49 cause: error,
50 });
88 if (error._tag === "VcsProcessExitError" && error.failureKind === "not-found") {
89 return new GitLabMergeRequestNotFoundError({ ...context, cause: error });
5190 }
5291
53 if (
54 lower.includes("authentication failed") ||
55 lower.includes("not logged in") ||
56 lower.includes("glab auth login") ||
57 lower.includes("token")
58 ) {
59 return new GitLabCliError({
60 ...context,
61 detail: "GitLab CLI is not authenticated. Run `glab auth login` and retry.",
62 cause: error,
63 });
64 }
92 return GitLabCliCommandError.fromVcsError(
93 {
94 operation: context.operation,
95 command: context.command,
96 cwd: context.cwd,
97 },
98 error,
99 );
100 }
101}
65102
66 if (
67 lower.includes("merge request not found") ||
68 lower.includes("not found") ||
69 lower.includes("404")
70 ) {
71 return new GitLabCliError({
72 ...context,
73 detail: "Merge request not found. Check the MR number or URL and try again.",
74 cause: error,
75 });
76 }
103export class GitLabCliCommandError extends Schema.TaggedErrorClass<GitLabCliCommandError>()(
104 "GitLabCliCommandError",
105 gitLabCliExecutionErrorContext,
106) {
107 get detail(): string {
108 return "GitLab CLI command failed.";
109 }
77110
78 return new GitLabCliError({
79 ...context,
80 detail: "GitLab CLI command failed.",
81 cause: error,
111 override get message(): string {
112 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
113 }
114
115 static fromVcsError(
116 context: {
117 readonly operation: "execute";
118 readonly command: "glab";
119 readonly cwd: string;
120 },
121 error: VcsError,
122 ): GitLabCliError {
123 return Match.valueTags(error, {
124 VcsProcessSpawnError: (cause) => new GitLabCliUnavailableError({ ...context, cause }),
125 VcsProcessExitError: (cause) => {
126 switch (cause.failureKind) {
127 case "authentication":
128 return new GitLabCliAuthenticationError({ ...context, cause });
129 case "not-found":
130 case "command-failed":
131 case undefined:
132 return new GitLabCliCommandError({ ...context, cause });
133 }
134 },
135 VcsProcessTimeoutError: (cause) => new GitLabCliCommandError({ ...context, cause }),
136 VcsOutputDecodeError: (cause) => new GitLabCliCommandError({ ...context, cause }),
137 VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }),
138 VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }),
82139 });
83140 }
84141}
85142
143export class GitLabMergeRequestListDecodeError extends Schema.TaggedErrorClass<GitLabMergeRequestListDecodeError>()(
144 "GitLabMergeRequestListDecodeError",
145 {
146 ...gitLabCliDecodeErrorContext,
147 operation: Schema.Literal("listMergeRequests"),
148 },
149) {
150 get detail(): string {
151 return "GitLab CLI returned invalid MR list JSON.";
152 }
153
154 override get message(): string {
155 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
156 }
157}
158
159export class GitLabMergeRequestDecodeError extends Schema.TaggedErrorClass<GitLabMergeRequestDecodeError>()(
160 "GitLabMergeRequestDecodeError",
161 {
162 ...gitLabCliDecodeErrorContext,
163 operation: Schema.Literal("getMergeRequest"),
164 reference: Schema.String,
165 },
166) {
167 get detail(): string {
168 return "GitLab CLI returned invalid merge request JSON.";
169 }
170
171 override get message(): string {
172 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
173 }
174}
175
176export class GitLabRepositoryDecodeError extends Schema.TaggedErrorClass<GitLabRepositoryDecodeError>()(
177 "GitLabRepositoryDecodeError",
178 {
179 ...gitLabCliDecodeErrorContext,
180 operation: Schema.Literals(["getRepositoryCloneUrls", "createRepository", "getDefaultBranch"]),
181 repository: Schema.optional(Schema.String),
182 },
183) {
184 get detail(): string {
185 return "GitLab CLI returned invalid repository JSON.";
186 }
187
188 override get message(): string {
189 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
190 }
191}
192
193export class GitLabNamespaceDecodeError extends Schema.TaggedErrorClass<GitLabNamespaceDecodeError>()(
194 "GitLabNamespaceDecodeError",
195 {
196 ...gitLabCliDecodeErrorContext,
197 operation: Schema.Literal("createRepository"),
198 namespacePath: Schema.String,
199 },
200) {
201 get detail(): string {
202 return "GitLab CLI returned invalid namespace JSON.";
203 }
204
205 override get message(): string {
206 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
207 }
208}
209
210export const GitLabCliError = Schema.Union([
211 GitLabCliUnavailableError,
212 GitLabCliAuthenticationError,
213 GitLabMergeRequestNotFoundError,
214 GitLabCliCommandError,
215 GitLabMergeRequestListDecodeError,
216 GitLabMergeRequestDecodeError,
217 GitLabRepositoryDecodeError,
218 GitLabNamespaceDecodeError,
219]);
220export type GitLabCliError = typeof GitLabCliError.Type;
221export const isGitLabCliError = Schema.is(GitLabCliError);
222
86223export interface GitLabMergeRequestSummary {
87224 readonly number: number;
88225 readonly title: string;
......@@ -102,17 +239,6 @@ export interface GitLabRepositoryCloneUrls {
102239 readonly sshUrl: string;
103240}
104241
105function errorText(error: VcsError | unknown): string {
106 if (typeof error === "object" && error !== null) {
107 const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : "";
108 const detail = "detail" in error && typeof error.detail === "string" ? error.detail : "";
109 const message = "message" in error && typeof error.message === "string" ? error.message : "";
110 return [tag, detail, message].filter(Boolean).join("\n");
111 }
112
113 return String(error);
114}
115
116242export class GitLabCli extends Context.Service<
117243 GitLabCli,
118244 {
......@@ -168,15 +294,6 @@ export class GitLabCli extends Context.Service<
168294 }
169295>()("t3/sourceControl/GitLabCli") {}
170296
171function isVcsProcessSpawnError(error: unknown): boolean {
172 return (
173 typeof error === "object" &&
174 error !== null &&
175 "_tag" in error &&
176 error._tag === "VcsProcessSpawnError"
177 );
178}
179
180297const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({
181298 path_with_namespace: TrimmedNonEmptyString,
182299 web_url: TrimmedNonEmptyString,
......@@ -192,6 +309,14 @@ const RawGitLabNamespaceSchema = Schema.Struct({
192309 id: Schema.Number,
193310});
194311
312const decodeGitLabRepositoryCloneUrls = Schema.decodeEffect(
313 Schema.fromJsonString(RawGitLabRepositoryCloneUrlsSchema),
314);
315const decodeGitLabDefaultBranch = Schema.decodeEffect(
316 Schema.fromJsonString(RawGitLabDefaultBranchSchema),
317);
318const decodeGitLabNamespace = Schema.decodeEffect(Schema.fromJsonString(RawGitLabNamespaceSchema));
319
195320function normalizeRepositoryCloneUrls(
196321 raw: Schema.Schema.Type<typeof RawGitLabRepositoryCloneUrlsSchema>,
197322): GitLabRepositoryCloneUrls {
......@@ -202,27 +327,6 @@ function normalizeRepositoryCloneUrls(
202327 };
203328}
204329
205function decodeGitLabJson<S extends Schema.Top>(
206 raw: string,
207 schema: S,
208 operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository",
209 invalidDetail: string,
210 cwd: string,
211): Effect.Effect<S["Type"], GitLabCliError, S["DecodingServices"]> {
212 return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
213 Effect.mapError(
214 (error) =>
215 new GitLabCliError({
216 operation,
217 command: "glab",
218 cwd,
219 detail: invalidDetail,
220 cause: error,
221 }),
222 ),
223 );
224}
225
226330function stateArgs(state: "open" | "closed" | "merged" | "all"): ReadonlyArray<string> {
227331 switch (state) {
228332 case "open":
......@@ -283,7 +387,10 @@ function parseRepositoryPath(repository: string): {
283387export const make = Effect.gen(function* () {
284388 const process = yield* VcsProcess.VcsProcess;
285389
286 const execute: GitLabCli["Service"]["execute"] = (input) =>
390 const run = (
391 input: Parameters<GitLabCli["Service"]["execute"]>[0],
392 mapError: (error: VcsError) => GitLabCliError,
393 ) =>
287394 process
288395 .run({
289396 operation: "GitLabCli.execute",
......@@ -292,14 +399,32 @@ export const make = Effect.gen(function* () {
292399 cwd: input.cwd,
293400 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
294401 })
295 .pipe(
296 Effect.mapError((error) =>
297 GitLabCliError.fromVcsError(
298 { operation: "execute", command: "glab", cwd: input.cwd },
299 error,
300 ),
301 ),
302 );
402 .pipe(Effect.mapError(mapError));
403
404 const execute: GitLabCli["Service"]["execute"] = (input) =>
405 run(input, (error) =>
406 GitLabCliCommandError.fromVcsError(
407 { operation: "execute", command: "glab", cwd: input.cwd },
408 error,
409 ),
410 );
411
412 const executeMergeRequest = (input: {
413 readonly cwd: string;
414 readonly reference: string;
415 readonly args: ReadonlyArray<string>;
416 }) =>
417 run(input, (error) =>
418 GitLabMergeRequestNotFoundError.fromVcsError(
419 {
420 operation: "execute",
421 command: "glab",
422 cwd: input.cwd,
423 reference: input.reference,
424 },
425 error,
426 ),
427 );
303428
304429 return GitLabCli.of({
305430 execute,
......@@ -326,11 +451,10 @@ export const make = Effect.gen(function* () {
326451 Effect.flatMap((decoded) => {
327452 if (!Result.isSuccess(decoded)) {
328453 return Effect.fail(
329 new GitLabCliError({
454 new GitLabMergeRequestListDecodeError({
330455 operation: "listMergeRequests",
331456 command: "glab",
332457 cwd: input.cwd,
333 detail: "GitLab CLI returned invalid MR list JSON.",
334458 cause: decoded.failure,
335459 }),
336460 );
......@@ -342,8 +466,9 @@ export const make = Effect.gen(function* () {
342466 ),
343467 ),
344468 getMergeRequest: (input) =>
345 execute({
469 executeMergeRequest({
346470 cwd: input.cwd,
471 reference: input.reference,
347472 args: ["mr", "view", input.reference, "--output", "json"],
348473 }).pipe(
349474 Effect.map((result) => result.stdout.trim()),
......@@ -352,11 +477,11 @@ export const make = Effect.gen(function* () {
352477 Effect.flatMap((decoded) => {
353478 if (!Result.isSuccess(decoded)) {
354479 return Effect.fail(
355 new GitLabCliError({
480 new GitLabMergeRequestDecodeError({
356481 operation: "getMergeRequest",
357482 command: "glab",
358483 cwd: input.cwd,
359 detail: "GitLab CLI returned invalid merge request JSON.",
484 reference: input.reference,
360485 cause: decoded.failure,
361486 }),
362487 );
......@@ -374,12 +499,17 @@ export const make = Effect.gen(function* () {
374499 }).pipe(
375500 Effect.map((result) => result.stdout.trim()),
376501 Effect.flatMap((raw) =>
377 decodeGitLabJson(
378 raw,
379 RawGitLabRepositoryCloneUrlsSchema,
380 "getRepositoryCloneUrls",
381 "GitLab CLI returned invalid repository JSON.",
382 input.cwd,
502 decodeGitLabRepositoryCloneUrls(raw).pipe(
503 Effect.mapError(
504 (cause) =>
505 new GitLabRepositoryDecodeError({
506 operation: "getRepositoryCloneUrls",
507 command: "glab",
508 cwd: input.cwd,
509 repository: input.repository,
510 cause,
511 }),
512 ),
383513 ),
384514 ),
385515 Effect.map(normalizeRepositoryCloneUrls),
......@@ -393,12 +523,17 @@ export const make = Effect.gen(function* () {
393523 }).pipe(
394524 Effect.map((result) => result.stdout.trim()),
395525 Effect.flatMap((raw) =>
396 decodeGitLabJson(
397 raw,
398 RawGitLabNamespaceSchema,
399 "createRepository",
400 "GitLab CLI returned invalid namespace JSON.",
401 input.cwd,
526 decodeGitLabNamespace(raw).pipe(
527 Effect.mapError(
528 (cause) =>
529 new GitLabNamespaceDecodeError({
530 operation: "createRepository",
531 command: "glab",
532 cwd: input.cwd,
533 namespacePath,
534 cause,
535 }),
536 ),
402537 ),
403538 ),
404539 Effect.map((namespace) => namespace.id),
......@@ -428,12 +563,17 @@ export const make = Effect.gen(function* () {
428563 ),
429564 Effect.map((result) => result.stdout.trim()),
430565 Effect.flatMap((raw) =>
431 decodeGitLabJson(
432 raw,
433 RawGitLabRepositoryCloneUrlsSchema,
434 "createRepository",
435 "GitLab CLI returned invalid repository JSON.",
436 input.cwd,
566 decodeGitLabRepositoryCloneUrls(raw).pipe(
567 Effect.mapError(
568 (cause) =>
569 new GitLabRepositoryDecodeError({
570 operation: "createRepository",
571 command: "glab",
572 cwd: input.cwd,
573 repository: input.repository,
574 cause,
575 }),
576 ),
437577 ),
438578 ),
439579 Effect.map(normalizeRepositoryCloneUrls),
......@@ -467,19 +607,24 @@ export const make = Effect.gen(function* () {
467607 }).pipe(
468608 Effect.map((result) => result.stdout.trim()),
469609 Effect.flatMap((raw) =>
470 decodeGitLabJson(
471 raw,
472 RawGitLabDefaultBranchSchema,
473 "getDefaultBranch",
474 "GitLab CLI returned invalid repository JSON.",
475 input.cwd,
610 decodeGitLabDefaultBranch(raw).pipe(
611 Effect.mapError(
612 (cause) =>
613 new GitLabRepositoryDecodeError({
614 operation: "getDefaultBranch",
615 command: "glab",
616 cwd: input.cwd,
617 cause,
618 }),
619 ),
476620 ),
477621 ),
478622 Effect.map((value) => value.default_branch ?? null),
479623 ),
480624 checkoutMergeRequest: (input) =>
481 execute({
625 executeMergeRequest({
482626 cwd: input.cwd,
627 reference: input.reference,
483628 args: ["mr", "checkout", input.reference],
484629 }).pipe(Effect.asVoid),
485630 });
apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts+1-2
......@@ -54,11 +54,10 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () =
5454
5555it.effect("adds repository context while retaining GitLab CLI causes", () =>
5656 Effect.gen(function* () {
57 const cause = new GitLabCli.GitLabCliError({
57 const cause = new GitLabCli.GitLabCliCommandError({
5858 operation: "execute",
5959 command: "glab",
6060 cwd: "/repo",
61 detail: "GitLab CLI command failed.",
6261 cause: new Error("raw upstream detail that should remain in the cause"),
6362 });
6463 const provider = yield* makeProvider({