1import { assert, it, afterEach, expect, vi } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { ChildProcessSpawner } from "effect/process";
5
6import { VcsProcessExitError } from "@t3tools/contracts";
7
8import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
9import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
10import * as GitLabCli from "./GitLabCli.ts";
11
12const mockedRun = vi.fn<SourceControlHost.SourceControlHost["Service"]["process"]["run"]>();
13const layer = it.layer(
14 GitLabCli.layer.pipe(Layer.provide(TestSourceControlHost.layer({ process: { run: mockedRun } }))),
15);
16
17function processOutput(stdout: string): SourceControlHost.SourceControlProcessOutput {
18 return {
19 exitCode: ChildProcessSpawner.ExitCode(0),
20 stdout,
21 stderr: "",
22 stdoutTruncated: false,
23 stderrTruncated: false,
24 };
25}
26
27afterEach(() => {
28 mockedRun.mockReset();
29});
30
31layer("GitLabCli.layer", (it) => {
32 it.effect("parses merge request view output", () =>
33 Effect.gen(function* () {
34 mockedRun.mockReturnValueOnce(
35 Effect.succeed(
36 processOutput(
37 JSON.stringify({
38 iid: 42,
39 title: "Add MR thread creation",
40 web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
41 target_branch: "main",
42 source_branch: "feature/mr-threads",
43 sha: "a".repeat(40),
44 state: "closed",
45 closed_at: "2026-08-23T10:00:00Z",
46 source_project_id: 101,
47 target_project_id: 100,
48 source_project: {
49 path_with_namespace: "octocat/t3code",
50 },
51 }),
52 ),
53 ),
54 );
55
56 const result = yield* Effect.gen(function* () {
57 const glab = yield* GitLabCli.GitLabCli;
58 return yield* glab.getMergeRequest({
59 cwd: "/repo",
60 reference: "42",
61 });
62 });
63
64 assert.deepStrictEqual(result, {
65 number: 42,
66 title: "Add MR thread creation",
67 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
68 baseRefName: "main",
69 headRefName: "feature/mr-threads",
70 headSha: "a".repeat(40),
71 state: "closed",
72 closedAt: "2026-08-23T10:00:00Z",
73 mergedAt: null,
74 isCrossRepository: true,
75 headRepositoryNameWithOwner: "octocat/t3code",
76 headRepositoryOwnerLogin: "octocat",
77 });
78 expect(mockedRun).toHaveBeenCalledWith(
79 expect.objectContaining({
80 command: "glab",
81 cwd: "/repo",
82 args: ["mr", "view", "--output", "json", "--", "42"],
83 }),
84 );
85 const classifyFailure = mockedRun.mock.calls.at(-1)?.[0].classifyFailure;
86 assert.strictEqual(
87 classifyFailure?.(
88 "x gitlab.example.test has not been authenticated with glab. Run `glab auth login`",
89 ),
90 "authentication",
91 );
92 assert.strictEqual(
93 classifyFailure?.(
94 'Failed to get merge request 42: oauth2: "invalid_grant" "The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client."',
95 ),
96 "authentication",
97 );
98 }),
99 );
100
101 it.effect("skips invalid entries when parsing MR lists", () =>
102 Effect.gen(function* () {
103 mockedRun.mockReturnValueOnce(
104 Effect.succeed(
105 processOutput(
106 JSON.stringify([
107 {
108 iid: 0,
109 title: "invalid",
110 web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/0",
111 target_branch: "main",
112 source_branch: "feature/invalid",
113 },
114 {
115 iid: 43,
116 title: " Valid MR ",
117 web_url: " https://gitlab.com/pingdotgg/t3code/-/merge_requests/43 ",
118 target_branch: " main ",
119 source_branch: " feature/mr-list ",
120 state: "merged",
121 merged_at: "2026-08-23T11:00:00Z",
122 },
123 ]),
124 ),
125 ),
126 );
127
128 const result = yield* Effect.gen(function* () {
129 const glab = yield* GitLabCli.GitLabCli;
130 return yield* glab.listMergeRequests({
131 cwd: "/repo",
132 headSelector: "feature/mr-list",
133 state: "all",
134 });
135 });
136
137 assert.deepStrictEqual(result, [
138 {
139 number: 43,
140 title: "Valid MR",
141 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/43",
142 baseRefName: "main",
143 headRefName: "feature/mr-list",
144 state: "merged",
145 closedAt: null,
146 mergedAt: "2026-08-23T11:00:00Z",
147 },
148 ]);
149 expect(mockedRun).toHaveBeenCalledWith(
150 expect.objectContaining({
151 command: "glab",
152 cwd: "/repo",
153 args: [
154 "mr",
155 "list",
156 "--source-branch",
157 "feature/mr-list",
158 "--all",
159 "--per-page",
160 "20",
161 "--output",
162 "json",
163 ],
164 }),
165 );
166 }),
167 );
168
169 it.effect("surfaces a friendly error when the merge request is not found", () =>
170 Effect.gen(function* () {
171 const cause = new VcsProcessExitError({
172 operation: "GitLabCli.execute",
173 command: "glab",
174 cwd: "/repo",
175 exitCode: 1,
176 detail: "GET 404 merge request not found",
177 failureKind: "not-found",
178 });
179 mockedRun.mockReturnValueOnce(Effect.fail(cause));
180
181 const error = yield* Effect.gen(function* () {
182 const glab = yield* GitLabCli.GitLabCli;
183 return yield* glab.getMergeRequest({
184 cwd: "/repo",
185 reference: "4888",
186 });
187 }).pipe(Effect.flip);
188
189 assert.equal(error.message.includes("Merge request 4888 was not found"), true);
190 assert.strictEqual(error._tag, "GitLabMergeRequestNotFoundError");
191 assert.strictEqual(error.command, "glab");
192 assert.strictEqual(error.cwd, "/repo");
193 assert.strictEqual(error.cause, cause);
194 assert.equal(error.message.includes(cause.detail), false);
195 }),
196 );
197
198 it.effect("keeps non-merge-request not-found failures generic", () =>
199 Effect.gen(function* () {
200 const cause = new VcsProcessExitError({
201 operation: "GitLabCli.execute",
202 command: "glab",
203 cwd: "/repo",
204 exitCode: 1,
205 detail: "GET 404 project not found",
206 failureKind: "not-found",
207 });
208 mockedRun.mockReturnValueOnce(Effect.fail(cause));
209
210 const error = yield* Effect.gen(function* () {
211 const glab = yield* GitLabCli.GitLabCli;
212 return yield* glab.getRepositoryCloneUrls({
213 cwd: "/repo",
214 repository: "missing/project",
215 });
216 }).pipe(Effect.flip);
217
218 assert.strictEqual(error._tag, "GitLabCliCommandError");
219 assert.strictEqual(error.cause, cause);
220 }),
221 );
222
223 it.effect("preserves rate-limit failures as a distinct error", () =>
224 Effect.gen(function* () {
225 const cause = new VcsProcessExitError({
226 operation: "GitLabCli.execute",
227 command: "glab",
228 cwd: "/repo",
229 exitCode: 1,
230 detail: "API rate limit exceeded.",
231 failureKind: "rate-limited",
232 });
233 mockedRun.mockReturnValueOnce(Effect.fail(cause));
234
235 const glab = yield* GitLabCli.GitLabCli;
236 const error = yield* glab
237 .execute({ cwd: "/repo", args: ["api", "projects"] })
238 .pipe(Effect.flip);
239
240 assert.strictEqual(error._tag, "GitLabCliRateLimitError");
241 assert.strictEqual(error.cause, cause);
242 }),
243 );
244});