1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Match from "effect/Match";
5import * as Option from "effect/Option";
6import * as Result from "effect/Result";
7import * as Schema from "effect/Schema";
8import type * as DateTime from "effect/DateTime";
9
10import {
11 TrimmedNonEmptyString,
12 type SourceControlRepositoryVisibility,
13 type VcsError,
14} from "@t3tools/contracts";
15
16import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
17import {
18 decodeGitLabMergeRequestJson,
19 decodeGitLabMergeRequestListJson,
20} from "./gitLabMergeRequests.ts";
21import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
22
23const DEFAULT_TIMEOUT_MS = 30_000;
24
25const gitLabCliExecutionErrorContext = {
26 operation: Schema.Literal("execute"),
27 command: Schema.Literal("glab"),
28 cwd: Schema.String,
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.TaggedError<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.TaggedError<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 GitLabCliRateLimitError extends Schema.TaggedError<GitLabCliRateLimitError>()(
65 "GitLabCliRateLimitError",
66 gitLabCliExecutionErrorContext,
67) {
68 get detail(): string {
69 return "GitLab API rate limit exceeded.";
70 }
71
72 override get message(): string {
73 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
74 }
75}
76
77export class GitLabMergeRequestNotFoundError extends Schema.TaggedError<GitLabMergeRequestNotFoundError>()(
78 "GitLabMergeRequestNotFoundError",
79 {
80 ...gitLabCliExecutionErrorContext,
81 reference: Schema.String,
82 },
83) {
84 get detail(): string {
85 return `Merge request ${this.reference} was not found. Check the MR number or URL and try again.`;
86 }
87
88 override get message(): string {
89 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
90 }
91
92 static fromVcsError(
93 context: {
94 readonly operation: "execute";
95 readonly command: "glab";
96 readonly cwd: string;
97 readonly reference: string;
98 },
99 error: VcsError,
100 ): GitLabCliError {
101 if (error._tag === "VcsProcessExitError" && error.failureKind === "not-found") {
102 return new GitLabMergeRequestNotFoundError({ ...context, cause: error });
103 }
104
105 return GitLabCliCommandError.fromVcsError(
106 {
107 operation: context.operation,
108 command: context.command,
109 cwd: context.cwd,
110 },
111 error,
112 );
113 }
114}
115
116export class GitLabCliCommandError extends Schema.TaggedError<GitLabCliCommandError>()(
117 "GitLabCliCommandError",
118 gitLabCliExecutionErrorContext,
119) {
120 get detail(): string {
121 return "GitLab CLI command failed.";
122 }
123
124 override get message(): string {
125 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
126 }
127
128 static fromVcsError(
129 context: {
130 readonly operation: "execute";
131 readonly command: "glab";
132 readonly cwd: string;
133 },
134 error: VcsError,
135 ): GitLabCliError {
136 return Match.valueTags(error, {
137 VcsProcessSpawnError: (cause) => new GitLabCliUnavailableError({ ...context, cause }),
138 VcsProcessExitError: (cause) => {
139 switch (cause.failureKind) {
140 case "authentication":
141 return new GitLabCliAuthenticationError({ ...context, cause });
142 case "rate-limited":
143 return new GitLabCliRateLimitError({ ...context, cause });
144 case "not-found":
145 case "command-failed":
146 case undefined:
147 return new GitLabCliCommandError({ ...context, cause });
148 }
149 },
150 VcsProcessTimeoutError: (cause) => new GitLabCliCommandError({ ...context, cause }),
151 VcsProcessStdinWriteError: (cause) => new GitLabCliCommandError({ ...context, cause }),
152 VcsProcessOutputReadError: (cause) => new GitLabCliCommandError({ ...context, cause }),
153 VcsProcessOutputLimitError: (cause) => new GitLabCliCommandError({ ...context, cause }),
154 VcsProcessMissingExitCodeError: (cause) => new GitLabCliCommandError({ ...context, cause }),
155 VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }),
156 VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }),
157 });
158 }
159}
160
161export class GitLabMergeRequestListDecodeError extends Schema.TaggedError<GitLabMergeRequestListDecodeError>()(
162 "GitLabMergeRequestListDecodeError",
163 {
164 ...gitLabCliDecodeErrorContext,
165 operation: Schema.Literal("listMergeRequests"),
166 },
167) {
168 get detail(): string {
169 return "GitLab CLI returned invalid MR list JSON.";
170 }
171
172 override get message(): string {
173 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
174 }
175}
176
177export class GitLabMergeRequestDecodeError extends Schema.TaggedError<GitLabMergeRequestDecodeError>()(
178 "GitLabMergeRequestDecodeError",
179 {
180 ...gitLabCliDecodeErrorContext,
181 operation: Schema.Literal("getMergeRequest"),
182 reference: Schema.String,
183 },
184) {
185 get detail(): string {
186 return "GitLab CLI returned invalid merge request JSON.";
187 }
188
189 override get message(): string {
190 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
191 }
192}
193
194export class GitLabRepositoryDecodeError extends Schema.TaggedError<GitLabRepositoryDecodeError>()(
195 "GitLabRepositoryDecodeError",
196 {
197 ...gitLabCliDecodeErrorContext,
198 operation: Schema.Literals(["getRepositoryCloneUrls", "createRepository", "getDefaultBranch"]),
199 repository: Schema.optional(Schema.String),
200 },
201) {
202 get detail(): string {
203 return "GitLab CLI returned invalid repository JSON.";
204 }
205
206 override get message(): string {
207 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
208 }
209}
210
211export class GitLabNamespaceDecodeError extends Schema.TaggedError<GitLabNamespaceDecodeError>()(
212 "GitLabNamespaceDecodeError",
213 {
214 ...gitLabCliDecodeErrorContext,
215 operation: Schema.Literal("createRepository"),
216 namespacePath: Schema.String,
217 },
218) {
219 get detail(): string {
220 return "GitLab CLI returned invalid namespace JSON.";
221 }
222
223 override get message(): string {
224 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
225 }
226}
227
228export const GitLabCliError = Schema.Union([
229 GitLabCliUnavailableError,
230 GitLabCliAuthenticationError,
231 GitLabCliRateLimitError,
232 GitLabMergeRequestNotFoundError,
233 GitLabCliCommandError,
234 GitLabMergeRequestListDecodeError,
235 GitLabMergeRequestDecodeError,
236 GitLabRepositoryDecodeError,
237 GitLabNamespaceDecodeError,
238]);
239export type GitLabCliError = typeof GitLabCliError.Type;
240
241export interface GitLabMergeRequestSummary {
242 readonly number: number;
243 readonly title: string;
244 readonly url: string;
245 readonly baseRefName: string;
246 readonly headRefName: string;
247 readonly state?: "open" | "closed" | "merged";
248 readonly isDraft?: boolean;
249 readonly closedAt?: string | null;
250 readonly mergedAt?: string | null;
251 readonly updatedAt?: Option.Option<DateTime.Utc>;
252 readonly isCrossRepository?: boolean;
253 readonly headRepositoryNameWithOwner?: string | null;
254 readonly headRepositoryOwnerLogin?: string | null;
255}
256
257export interface GitLabRepositoryCloneUrls {
258 readonly nameWithOwner: string;
259 readonly url: string;
260 readonly sshUrl: string;
261}
262
263export class GitLabCli extends Context.Service<
264 GitLabCli,
265 {
266 readonly execute: (input: {
267 readonly cwd: string;
268 readonly args: ReadonlyArray<string>;
269 readonly timeoutMs?: number;
270 /** Piped to the child's stdin, for payloads that must never appear in argv. */
271 readonly stdin?: string;
272 readonly maxOutputBytes?: number;
273 }) => Effect.Effect<SourceControlHost.SourceControlProcessOutput, GitLabCliError>;
274
275 readonly listMergeRequests: (input: {
276 readonly cwd: string;
277 readonly headSelector: string;
278 readonly source?: SourceControlProvider.SourceControlRefSelector;
279 readonly state: "open" | "closed" | "merged" | "all";
280 readonly limit?: number;
281 }) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>;
282
283 readonly getMergeRequest: (input: {
284 readonly cwd: string;
285 readonly reference: string;
286 }) => Effect.Effect<GitLabMergeRequestSummary, GitLabCliError>;
287
288 readonly getRepositoryCloneUrls: (input: {
289 readonly cwd: string;
290 readonly repository: string;
291 }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;
292
293 readonly createRepository: (input: {
294 readonly cwd: string;
295 readonly repository: string;
296 readonly visibility: SourceControlRepositoryVisibility;
297 }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;
298
299 readonly createMergeRequest: (input: {
300 readonly cwd: string;
301 readonly baseBranch: string;
302 readonly headSelector: string;
303 readonly source?: SourceControlProvider.SourceControlRefSelector;
304 readonly target?: SourceControlProvider.SourceControlRefSelector;
305 readonly title: string;
306 readonly bodyFile: string;
307 }) => Effect.Effect<void, GitLabCliError>;
308
309 readonly getDefaultBranch: (input: {
310 readonly cwd: string;
311 }) => Effect.Effect<string | null, GitLabCliError>;
312
313 readonly checkoutMergeRequest: (input: {
314 readonly cwd: string;
315 readonly reference: string;
316 readonly force?: boolean;
317 }) => Effect.Effect<void, GitLabCliError>;
318 }
319>()("@t3tools/source-control-gitlab/server/GitLabCli") {}
320
321const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({
322 path_with_namespace: TrimmedNonEmptyString,
323 web_url: TrimmedNonEmptyString,
324 http_url_to_repo: TrimmedNonEmptyString,
325 ssh_url_to_repo: TrimmedNonEmptyString,
326});
327
328const RawGitLabDefaultBranchSchema = Schema.Struct({
329 default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
330});
331
332const RawGitLabNamespaceSchema = Schema.Struct({
333 id: Schema.Number,
334});
335
336const decodeGitLabRepositoryCloneUrls = Schema.decodeEffect(
337 Schema.fromJsonString(RawGitLabRepositoryCloneUrlsSchema),
338);
339const decodeGitLabDefaultBranch = Schema.decodeEffect(
340 Schema.fromJsonString(RawGitLabDefaultBranchSchema),
341);
342const decodeGitLabNamespace = Schema.decodeEffect(Schema.fromJsonString(RawGitLabNamespaceSchema));
343
344function normalizeRepositoryCloneUrls(
345 raw: Schema.Schema.Type<typeof RawGitLabRepositoryCloneUrlsSchema>,
346): GitLabRepositoryCloneUrls {
347 return {
348 nameWithOwner: raw.path_with_namespace,
349 url: raw.web_url,
350 sshUrl: raw.ssh_url_to_repo,
351 };
352}
353
354function stateArgs(state: "open" | "closed" | "merged" | "all"): ReadonlyArray<string> {
355 switch (state) {
356 case "open":
357 return [];
358 case "closed":
359 return ["--closed"];
360 case "merged":
361 return ["--merged"];
362 case "all":
363 return ["--all"];
364 }
365}
366
367function normalizeHeadSelector(headSelector: string): string {
368 const trimmed = headSelector.trim();
369 const ownerBranch = /^[^:]+:(.+)$/.exec(trimmed);
370 return ownerBranch?.[1]?.trim() || trimmed;
371}
372
373function sourceRefName(input: {
374 readonly headSelector: string;
375 readonly source?: SourceControlProvider.SourceControlRefSelector;
376}): string {
377 return input.source?.refName ?? normalizeHeadSelector(input.headSelector);
378}
379
380function sourceProjectIdentifier(
381 source: SourceControlProvider.SourceControlRefSelector | undefined,
382): string | null {
383 return source?.repository ?? source?.owner ?? null;
384}
385
386function toSummaryWithOptionalUpdatedAt(
387 record: GitLabMergeRequestSummary & {
388 readonly updatedAt: Option.Option<DateTime.Utc>;
389 },
390): GitLabMergeRequestSummary {
391 const { updatedAt, ...summary } = record;
392 return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary;
393}
394
395function parseRepositoryPath(repository: string): {
396 readonly namespacePath: string | null;
397 readonly projectPath: string;
398} {
399 const parts: Array<string> = [];
400 for (const part of repository.split("/")) {
401 const trimmed = part.trim();
402 if (trimmed.length > 0) {
403 parts.push(trimmed);
404 }
405 }
406 const projectPath = parts.at(-1) ?? repository.trim();
407 const namespacePath = parts.length > 1 ? parts.slice(0, -1).join("/") : null;
408 return { namespacePath, projectPath };
409}
410
411/** @public Service construction is part of the canonical Effect module API. */
412export const make = Effect.gen(function* () {
413 const { process } = yield* SourceControlHost.SourceControlHost;
414
415 const run = (
416 input: Parameters<GitLabCli["Service"]["execute"]>[0],
417 mapError: (error: VcsError) => GitLabCliError,
418 ) =>
419 process
420 .run({
421 operation: "GitLabCli.execute",
422 command: "glab",
423 args: input.args,
424 cwd: input.cwd,
425 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
426 ...(input.stdin === undefined ? {} : { stdin: input.stdin }),
427 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
428 })
429 .pipe(Effect.mapError(mapError));
430
431 const execute: GitLabCli["Service"]["execute"] = (input) =>
432 run(input, (error) =>
433 GitLabCliCommandError.fromVcsError(
434 { operation: "execute", command: "glab", cwd: input.cwd },
435 error,
436 ),
437 );
438
439 const executeMergeRequest = (input: {
440 readonly cwd: string;
441 readonly reference: string;
442 readonly args: ReadonlyArray<string>;
443 }) =>
444 run(input, (error) =>
445 GitLabMergeRequestNotFoundError.fromVcsError(
446 {
447 operation: "execute",
448 command: "glab",
449 cwd: input.cwd,
450 reference: input.reference,
451 },
452 error,
453 ),
454 );
455
456 return GitLabCli.of({
457 execute,
458 listMergeRequests: (input) =>
459 execute({
460 cwd: input.cwd,
461 args: [
462 "mr",
463 "list",
464 "--source-branch",
465 sourceRefName(input),
466 ...stateArgs(input.state),
467 "--per-page",
468 String(input.limit ?? 20),
469 "--output",
470 "json",
471 ],
472 }).pipe(
473 Effect.map((result) => result.stdout.trim()),
474 Effect.flatMap((raw) =>
475 raw.length === 0
476 ? Effect.succeed([])
477 : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe(
478 Effect.flatMap((decoded) => {
479 if (!Result.isSuccess(decoded)) {
480 return Effect.fail(
481 new GitLabMergeRequestListDecodeError({
482 operation: "listMergeRequests",
483 command: "glab",
484 cwd: input.cwd,
485 cause: decoded.failure,
486 }),
487 );
488 }
489
490 return Effect.succeed(decoded.success.map(toSummaryWithOptionalUpdatedAt));
491 }),
492 ),
493 ),
494 ),
495 getMergeRequest: (input) =>
496 executeMergeRequest({
497 cwd: input.cwd,
498 reference: input.reference,
499 args: ["mr", "view", input.reference, "--output", "json"],
500 }).pipe(
501 Effect.map((result) => result.stdout.trim()),
502 Effect.flatMap((raw) =>
503 Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe(
504 Effect.flatMap((decoded) => {
505 if (!Result.isSuccess(decoded)) {
506 return Effect.fail(
507 new GitLabMergeRequestDecodeError({
508 operation: "getMergeRequest",
509 command: "glab",
510 cwd: input.cwd,
511 reference: input.reference,
512 cause: decoded.failure,
513 }),
514 );
515 }
516
517 return Effect.succeed(toSummaryWithOptionalUpdatedAt(decoded.success));
518 }),
519 ),
520 ),
521 ),
522 getRepositoryCloneUrls: (input) =>
523 execute({
524 cwd: input.cwd,
525 args: ["api", `projects/${encodeURIComponent(input.repository)}`],
526 }).pipe(
527 Effect.map((result) => result.stdout.trim()),
528 Effect.flatMap((raw) =>
529 decodeGitLabRepositoryCloneUrls(raw).pipe(
530 Effect.mapError(
531 (cause) =>
532 new GitLabRepositoryDecodeError({
533 operation: "getRepositoryCloneUrls",
534 command: "glab",
535 cwd: input.cwd,
536 repository: input.repository,
537 cause,
538 }),
539 ),
540 ),
541 ),
542 Effect.map(normalizeRepositoryCloneUrls),
543 ),
544 createRepository: (input) => {
545 const { namespacePath, projectPath } = parseRepositoryPath(input.repository);
546 const namespaceId: Effect.Effect<number | null, GitLabCliError> = namespacePath
547 ? execute({
548 cwd: input.cwd,
549 args: ["api", `namespaces/${encodeURIComponent(namespacePath)}`],
550 }).pipe(
551 Effect.map((result) => result.stdout.trim()),
552 Effect.flatMap((raw) =>
553 decodeGitLabNamespace(raw).pipe(
554 Effect.mapError(
555 (cause) =>
556 new GitLabNamespaceDecodeError({
557 operation: "createRepository",
558 command: "glab",
559 cwd: input.cwd,
560 namespacePath,
561 cause,
562 }),
563 ),
564 ),
565 ),
566 Effect.map((namespace) => namespace.id),
567 )
568 : Effect.succeed(null);
569
570 return namespaceId.pipe(
571 Effect.flatMap((resolvedNamespaceId) =>
572 execute({
573 cwd: input.cwd,
574 args: [
575 "api",
576 "--method",
577 "POST",
578 "projects",
579 "--raw-field",
580 `path=${projectPath}`,
581 "--raw-field",
582 `name=${projectPath}`,
583 "--raw-field",
584 `visibility=${input.visibility}`,
585 ...(resolvedNamespaceId === null
586 ? []
587 : ["--raw-field", `namespace_id=${resolvedNamespaceId}`]),
588 ],
589 }),
590 ),
591 Effect.map((result) => result.stdout.trim()),
592 Effect.flatMap((raw) =>
593 decodeGitLabRepositoryCloneUrls(raw).pipe(
594 Effect.mapError(
595 (cause) =>
596 new GitLabRepositoryDecodeError({
597 operation: "createRepository",
598 command: "glab",
599 cwd: input.cwd,
600 repository: input.repository,
601 cause,
602 }),
603 ),
604 ),
605 ),
606 Effect.map(normalizeRepositoryCloneUrls),
607 );
608 },
609 createMergeRequest: (input) => {
610 const sourceProject = sourceProjectIdentifier(input.source);
611 return execute({
612 cwd: input.cwd,
613 args: [
614 "api",
615 "--method",
616 "POST",
617 "projects/:fullpath/merge_requests",
618 "--raw-field",
619 `source_branch=${sourceRefName(input)}`,
620 "--raw-field",
621 `target_branch=${input.target?.refName ?? input.baseBranch}`,
622 ...(sourceProject ? ["--raw-field", `source_project_id=${sourceProject}`] : []),
623 "--raw-field",
624 `title=${input.title}`,
625 "--field",
626 `description=@${input.bodyFile}`,
627 ],
628 }).pipe(Effect.asVoid);
629 },
630 getDefaultBranch: (input) =>
631 execute({
632 cwd: input.cwd,
633 args: ["api", "projects/:fullpath"],
634 }).pipe(
635 Effect.map((result) => result.stdout.trim()),
636 Effect.flatMap((raw) =>
637 decodeGitLabDefaultBranch(raw).pipe(
638 Effect.mapError(
639 (cause) =>
640 new GitLabRepositoryDecodeError({
641 operation: "getDefaultBranch",
642 command: "glab",
643 cwd: input.cwd,
644 cause,
645 }),
646 ),
647 ),
648 ),
649 Effect.map((value) => value.default_branch ?? null),
650 ),
651 checkoutMergeRequest: (input) =>
652 executeMergeRequest({
653 cwd: input.cwd,
654 reference: input.reference,
655 args: ["mr", "checkout", input.reference],
656 }).pipe(Effect.asVoid),
657 });
658});
659
660export const layer = Layer.effect(GitLabCli, make);