Skip to content
Source
Sign inSign up

GitLabCli.ts

687 lines21.6 KB

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