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 state: "closed",
44 closed_at: "2026-08-23T10:00:00Z",
45 source_project_id: 101,
46 target_project_id: 100,
47 source_project: {
48 path_with_namespace: "octocat/t3code",
49 },
50 }),
51 ),
52 ),
53 );
54
55 const result = yield* Effect.gen(function* () {
56 const glab = yield* GitLabCli.GitLabCli;
57 return yield* glab.getMergeRequest({
58 cwd: "/repo",
59 reference: "42",
60 });
61 });
62
63 assert.deepStrictEqual(result, {
64 number: 42,
65 title: "Add MR thread creation",
66 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
67 baseRefName: "main",
68 headRefName: "feature/mr-threads",
69 state: "closed",
70 closedAt: "2026-08-23T10:00:00Z",
71 mergedAt: null,
72 isCrossRepository: true,
73 headRepositoryNameWithOwner: "octocat/t3code",
74 headRepositoryOwnerLogin: "octocat",
75 });
76 expect(mockedRun).toHaveBeenCalledWith(
77 expect.objectContaining({
78 command: "glab",
79 cwd: "/repo",
80 args: ["mr", "view", "42", "--output", "json"],
81 }),
82 );
83 }),
84 );
85
86 it.effect("skips invalid entries when parsing MR lists", () =>
87 Effect.gen(function* () {
88 mockedRun.mockReturnValueOnce(
89 Effect.succeed(
90 processOutput(
91 JSON.stringify([
92 {
93 iid: 0,
94 title: "invalid",
95 web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/0",
96 target_branch: "main",
97 source_branch: "feature/invalid",
98 },
99 {
100 iid: 43,
101 title: " Valid MR ",
102 web_url: " https://gitlab.com/pingdotgg/t3code/-/merge_requests/43 ",
103 target_branch: " main ",
104 source_branch: " feature/mr-list ",
105 state: "merged",
106 merged_at: "2026-08-23T11:00:00Z",
107 },
108 ]),
109 ),
110 ),
111 );
112
113 const result = yield* Effect.gen(function* () {
114 const glab = yield* GitLabCli.GitLabCli;
115 return yield* glab.listMergeRequests({
116 cwd: "/repo",
117 headSelector: "feature/mr-list",
118 state: "all",
119 });
120 });
121
122 assert.deepStrictEqual(result, [
123 {
124 number: 43,
125 title: "Valid MR",
126 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/43",
127 baseRefName: "main",
128 headRefName: "feature/mr-list",
129 state: "merged",
130 closedAt: null,
131 mergedAt: "2026-08-23T11:00:00Z",
132 },
133 ]);
134 expect(mockedRun).toHaveBeenCalledWith(
135 expect.objectContaining({
136 command: "glab",
137 cwd: "/repo",
138 args: [
139 "mr",
140 "list",
141 "--source-branch",
142 "feature/mr-list",
143 "--all",
144 "--per-page",
145 "20",
146 "--output",
147 "json",
148 ],
149 }),
150 );
151 }),
152 );
153
154 it.effect("reads repository clone URLs", () =>
155 Effect.gen(function* () {
156 mockedRun.mockReturnValueOnce(
157 Effect.succeed(
158 processOutput(
159 JSON.stringify({
160 path_with_namespace: "octocat/t3code",
161 web_url: "https://gitlab.com/octocat/t3code",
162 http_url_to_repo: "https://gitlab.com/octocat/t3code.git",
163 ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git",
164 }),
165 ),
166 ),
167 );
168
169 const result = yield* Effect.gen(function* () {
170 const glab = yield* GitLabCli.GitLabCli;
171 return yield* glab.getRepositoryCloneUrls({
172 cwd: "/repo",
173 repository: "octocat/t3code",
174 });
175 });
176
177 assert.deepStrictEqual(result, {
178 nameWithOwner: "octocat/t3code",
179 url: "https://gitlab.com/octocat/t3code",
180 sshUrl: "git@gitlab.com:octocat/t3code.git",
181 });
182 }),
183 );
184
185 it.effect("creates merge requests through the GitLab API without placing the body in argv", () =>
186 Effect.gen(function* () {
187 mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("{}")));
188
189 const glab = yield* GitLabCli.GitLabCli;
190 yield* glab.createMergeRequest({
191 cwd: "/repo",
192 baseBranch: "main",
193 headSelector: "owner:feature/provider",
194 title: "Provider MR",
195 bodyFile: "/tmp/t3-mr-body.md",
196 });
197
198 expect(mockedRun).toHaveBeenCalledWith(
199 expect.objectContaining({
200 command: "glab",
201 cwd: "/repo",
202 args: [
203 "api",
204 "--method",
205 "POST",
206 "projects/:fullpath/merge_requests",
207 "--raw-field",
208 "source_branch=feature/provider",
209 "--raw-field",
210 "target_branch=main",
211 "--raw-field",
212 "title=Provider MR",
213 "--field",
214 "description=@/tmp/t3-mr-body.md",
215 ],
216 }),
217 );
218 }),
219 );
220
221 it.effect("creates repositories under an explicit namespace", () =>
222 Effect.gen(function* () {
223 mockedRun
224
225 .mockReturnValueOnce(Effect.succeed(processOutput(JSON.stringify({ id: 1234 }))))
226 .mockReturnValueOnce(
227 Effect.succeed(
228 processOutput(
229 JSON.stringify({
230 path_with_namespace: "octocat/t3code",
231 web_url: "https://gitlab.com/octocat/t3code",
232 http_url_to_repo: "https://gitlab.com/octocat/t3code.git",
233 ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git",
234 }),
235 ),
236 ),
237 );
238
239 const glab = yield* GitLabCli.GitLabCli;
240 const result = yield* glab.createRepository({
241 cwd: "/repo",
242 repository: "octocat/t3code",
243 visibility: "public",
244 });
245
246 assert.deepStrictEqual(result, {
247 nameWithOwner: "octocat/t3code",
248 url: "https://gitlab.com/octocat/t3code",
249 sshUrl: "git@gitlab.com:octocat/t3code.git",
250 });
251 expect(mockedRun).toHaveBeenNthCalledWith(
252 1,
253 expect.objectContaining({
254 command: "glab",
255 cwd: "/repo",
256 args: ["api", "namespaces/octocat"],
257 }),
258 );
259 expect(mockedRun).toHaveBeenNthCalledWith(
260 2,
261 expect.objectContaining({
262 command: "glab",
263 cwd: "/repo",
264 args: [
265 "api",
266 "--method",
267 "POST",
268 "projects",
269 "--raw-field",
270 "path=t3code",
271 "--raw-field",
272 "name=t3code",
273 "--raw-field",
274 "visibility=public",
275 "--raw-field",
276 "namespace_id=1234",
277 ],
278 }),
279 );
280 }),
281 );
282
283 it.effect("does not pass unsupported force flags when checking out merge requests", () =>
284 Effect.gen(function* () {
285 mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
286
287 const glab = yield* GitLabCli.GitLabCli;
288 yield* glab.checkoutMergeRequest({
289 cwd: "/repo",
290 reference: "42",
291 force: true,
292 });
293
294 expect(mockedRun).toHaveBeenCalledWith(
295 expect.objectContaining({
296 command: "glab",
297 cwd: "/repo",
298 args: ["mr", "checkout", "42"],
299 }),
300 );
301 }),
302 );
303
304 it.effect("surfaces a friendly error when the merge request is not found", () =>
305 Effect.gen(function* () {
306 const cause = new VcsProcessExitError({
307 operation: "GitLabCli.execute",
308 command: "glab",
309 cwd: "/repo",
310 exitCode: 1,
311 detail: "GET 404 merge request not found",
312 failureKind: "not-found",
313 });
314 mockedRun.mockReturnValueOnce(Effect.fail(cause));
315
316 const error = yield* Effect.gen(function* () {
317 const glab = yield* GitLabCli.GitLabCli;
318 return yield* glab.getMergeRequest({
319 cwd: "/repo",
320 reference: "4888",
321 });
322 }).pipe(Effect.flip);
323
324 assert.equal(error.message.includes("Merge request 4888 was not found"), true);
325 assert.strictEqual(error._tag, "GitLabMergeRequestNotFoundError");
326 assert.strictEqual(error.command, "glab");
327 assert.strictEqual(error.cwd, "/repo");
328 assert.strictEqual(error.cause, cause);
329 assert.equal(error.message.includes(cause.detail), false);
330 }),
331 );
332
333 it.effect("keeps non-merge-request not-found failures generic", () =>
334 Effect.gen(function* () {
335 const cause = new VcsProcessExitError({
336 operation: "GitLabCli.execute",
337 command: "glab",
338 cwd: "/repo",
339 exitCode: 1,
340 detail: "GET 404 project not found",
341 failureKind: "not-found",
342 });
343 mockedRun.mockReturnValueOnce(Effect.fail(cause));
344
345 const error = yield* Effect.gen(function* () {
346 const glab = yield* GitLabCli.GitLabCli;
347 return yield* glab.getRepositoryCloneUrls({
348 cwd: "/repo",
349 repository: "missing/project",
350 });
351 }).pipe(Effect.flip);
352
353 assert.strictEqual(error._tag, "GitLabCliCommandError");
354 assert.strictEqual(error.cause, cause);
355 }),
356 );
357
358 it.effect("preserves rate-limit failures as a distinct error", () =>
359 Effect.gen(function* () {
360 const cause = new VcsProcessExitError({
361 operation: "GitLabCli.execute",
362 command: "glab",
363 cwd: "/repo",
364 exitCode: 1,
365 detail: "API rate limit exceeded.",
366 failureKind: "rate-limited",
367 });
368 mockedRun.mockReturnValueOnce(Effect.fail(cause));
369
370 const glab = yield* GitLabCli.GitLabCli;
371 const error = yield* glab
372 .execute({ cwd: "/repo", args: ["api", "projects"] })
373 .pipe(Effect.flip);
374
375 assert.strictEqual(error._tag, "GitLabCliRateLimitError");
376 assert.strictEqual(error.cause, cause);
377 }),
378 );
379});