1import { afterEach, assert, expect, it, vi } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { ChildProcessSpawner } from "effect/process";
5
6import * as GitLabCli from "./GitLabCli.ts";
7import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
8
9const mockedExecute = vi.fn<GitLabCli.GitLabCli["Service"]["execute"]>();
10
11const layer = it.layer(
12 GitLabPullRequestCli.layer.pipe(
13 Layer.provide(
14 Layer.mock(GitLabCli.GitLabCli)({
15 execute: mockedExecute,
16 }),
17 ),
18 ),
19);
20
21function output(stdout: string, stdoutTruncated = false, stdoutInvalidUtf8 = false) {
22 return {
23 exitCode: ChildProcessSpawner.ExitCode(0),
24 stdout,
25 stderr: "",
26 stdoutTruncated,
27 stderrTruncated: false,
28 stdoutInvalidUtf8,
29 };
30}
31
32function mergeRequests(count: number, firstNumber: number): string {
33 return JSON.stringify(
34 Array.from({ length: count }, (_, index) => ({
35 iid: firstNumber + index,
36 title: `Merge request ${firstNumber + index}`,
37 web_url: `https://gitlab.com/acme/web/-/merge_requests/${firstNumber + index}`,
38 source_branch: "feat/page",
39 target_branch: "main",
40 created_at: "2026-07-01T00:00:00Z",
41 updated_at: "2026-07-02T00:00:00Z",
42 })),
43 );
44}
45
46/** A page of `/diffs` as GitLab serves it, a full one unless the count says otherwise. */
47function diffPage(firstIndex: number, count = 100): string {
48 return JSON.stringify(
49 Array.from({ length: count }, (_, index) => ({
50 old_path: `src/${firstIndex + index}.ts`,
51 new_path: `src/${firstIndex + index}.ts`,
52 diff: "@@ -1 +1 @@\n-a\n+b\n",
53 })),
54 );
55}
56
57/** A page of merge request notes, which is what the flat conversation is read from. */
58function notes(count: number, firstId: number): string {
59 return JSON.stringify(
60 Array.from({ length: count }, (_, index) => ({
61 id: firstId + index,
62 body: `note ${firstId + index}`,
63 author: { username: "bilal" },
64 created_at: "2026-07-01T00:00:00Z",
65 })),
66 );
67}
68
69/** Who opened the merge request, and somebody already reviewing it. */
70const author = { id: 1, username: "bilal" };
71const reviewer = { id: 5, username: "octocat" };
72
73/** One merge request as `/merge_requests/:iid` answers with it. */
74function mergeRequestJson(overrides: Record<string, unknown>): string {
75 return JSON.stringify({
76 iid: 7,
77 title: "Merge request 7",
78 web_url: "https://gitlab.com/acme/web/-/merge_requests/7",
79 source_branch: "feat/page",
80 target_branch: "main",
81 created_at: "2026-07-01T00:00:00Z",
82 updated_at: "2026-07-02T00:00:00Z",
83 author,
84 ...overrides,
85 });
86}
87
88/** The endpoint or subcommand of the nth glab invocation. */
89function argsOfCall(index: number): ReadonlyArray<string> {
90 return callAt(index).args;
91}
92
93/** The whole nth invocation, so a request body can be asserted alongside its path. */
94function callAt(index: number) {
95 const call = mockedExecute.mock.calls[index];
96 assert.isDefined(call);
97 return call[0];
98}
99
100afterEach(() => {
101 mockedExecute.mockReset();
102});
103
104layer("GitLabPullRequestCli.layer", (it) => {
105 it.effect("asks GitLab for one row more than the page, to probe for a next page", () =>
106 Effect.gen(function* () {
107 mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1))));
108 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
109
110 const batch = yield* cli.listMergeRequests({
111 cwd: "/w",
112 repository: "acme/web",
113 state: "open",
114 involvement: "all",
115 viewer: "bilal",
116 limit: 10,
117 });
118
119 assert.strictEqual(batch.items.length, 3);
120 assert.isFalse(batch.truncated);
121 assert.strictEqual(batch.cursorAdvance, 3);
122 const path = argsOfCall(0)[1] ?? "";
123 expect(path).toContain("projects/acme%2Fweb/merge_requests");
124 expect(path).toContain("per_page=11");
125 expect(path).toContain("state=opened");
126 }),
127 );
128
129 it.effect("walks pages at a fixed size, because GitLab pages by offset", () =>
130 Effect.gen(function* () {
131 mockedExecute
132 .mockReturnValueOnce(Effect.succeed(output(mergeRequests(100, 1))))
133 .mockReturnValueOnce(Effect.succeed(output(mergeRequests(100, 101))));
134 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
135
136 const batch = yield* cli.listMergeRequests({
137 cwd: "/w",
138 repository: "acme/web",
139 state: "open",
140 involvement: "all",
141 viewer: "bilal",
142 limit: 150,
143 });
144
145 assert.strictEqual(batch.items.length, 150);
146 assert.isTrue(batch.truncated);
147 for (const index of [0, 1]) {
148 expect(argsOfCall(index)[1]).toContain("per_page=100");
149 }
150 expect(argsOfCall(0)[1]).toContain("page=1");
151 expect(argsOfCall(1)[1]).toContain("page=2");
152 }),
153 );
154
155 it.effect("hands a search to GitLab's own search parameter", () =>
156 Effect.gen(function* () {
157 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
158 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
159
160 yield* cli.listMergeRequests({
161 cwd: "/w",
162 repository: "acme/web",
163 state: "open",
164 involvement: "all",
165 viewer: "bilal",
166 limit: 10,
167 query: "page",
168 });
169
170 // GitLab matches `search` against title and description, which is more than the row shows.
171 expect(argsOfCall(0)[1]).toContain("search=page");
172 }),
173 );
174
175 it.effect("carries on from the number of rows already delivered", () =>
176 Effect.gen(function* () {
177 mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1))));
178 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
179
180 yield* cli.listMergeRequests({
181 cwd: "/w",
182 repository: "acme/web",
183 state: "open",
184 involvement: "all",
185 viewer: "bilal",
186 limit: 10,
187 cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 },
188 });
189
190 // GitLab's timestamp filter has no tie-breaker, so an offset is what advances through a
191 // boundary shared by more rows than one page can hold.
192 const path = argsOfCall(0)[1] ?? "";
193 expect(path).not.toContain("updated_before=");
194 expect(path).toContain("order_by=updated_at");
195 expect(path).toContain("per_page=11");
196 expect(path).toContain("page=1");
197 }),
198 );
199
200 it.effect("advances beyond several pages sharing the cursor timestamp", () =>
201 Effect.gen(function* () {
202 mockedExecute
203 .mockReturnValueOnce(Effect.succeed(output(mergeRequests(11, 144))))
204 .mockReturnValueOnce(Effect.succeed(output(mergeRequests(11, 155))));
205 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
206
207 const batch = yield* cli.listMergeRequests({
208 cwd: "/w",
209 repository: "acme/web",
210 state: "open",
211 involvement: "all",
212 viewer: "bilal",
213 limit: 10,
214 cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 150 },
215 });
216
217 expect(argsOfCall(0)[1]).toContain("per_page=11");
218 expect(argsOfCall(0)[1]).toContain("page=14");
219 expect(argsOfCall(1)[1]).toContain("page=15");
220 expect(batch.items.map((item) => item.number)).toEqual([
221 151, 152, 153, 154, 155, 156, 157, 158, 159, 160,
222 ]);
223 assert.isTrue(batch.truncated);
224 }),
225 );
226
227 it.effect("advances the cursor through malformed raw rows", () =>
228 Effect.gen(function* () {
229 const rows = JSON.parse(mergeRequests(2, 1)) as ReadonlyArray<unknown>;
230 mockedExecute.mockReturnValueOnce(
231 Effect.succeed(output(JSON.stringify([{ iid: "malformed" }, ...rows]))),
232 );
233 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
234
235 const batch = yield* cli.listMergeRequests({
236 cwd: "/w",
237 repository: "acme/web",
238 state: "open",
239 involvement: "all",
240 viewer: "bilal",
241 limit: 2,
242 });
243
244 expect(batch.items.map((item) => item.number)).toEqual([1, 2]);
245 assert.strictEqual(batch.cursorAdvance, 3);
246 assert.isTrue(batch.truncated);
247 }),
248 );
249
250 it.effect("URL-encodes a search, so it cannot add a parameter of its own", () =>
251 Effect.gen(function* () {
252 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
253 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
254
255 yield* cli.listMergeRequests({
256 cwd: "/w",
257 repository: "acme/web",
258 state: "open",
259 involvement: "all",
260 viewer: "bilal",
261 limit: 10,
262 query: '-a&per_page=1 "b"',
263 });
264
265 const path = argsOfCall(0)[1] ?? "";
266 expect(path).toContain("search=-a%26per_page%3D1%20%22b%22");
267 // The page size the walk fixed is still the only one in the query.
268 assert.strictEqual(path.match(/per_page=/g)?.length, 1);
269 }),
270 );
271
272 it.effect("asks for no search at all when the reader typed only spaces", () =>
273 Effect.gen(function* () {
274 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
275 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
276
277 yield* cli.listMergeRequests({
278 cwd: "/w",
279 repository: "acme/web",
280 state: "open",
281 involvement: "all",
282 viewer: "bilal",
283 limit: 10,
284 query: " ",
285 });
286
287 expect(argsOfCall(0)[1]).not.toContain("search=");
288 }),
289 );
290
291 it.effect("stops walking on a short page", () =>
292 Effect.gen(function* () {
293 mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(40, 1))));
294 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
295
296 const batch = yield* cli.listMergeRequests({
297 cwd: "/w",
298 repository: "acme/web",
299 state: "open",
300 involvement: "all",
301 viewer: "bilal",
302 limit: 150,
303 });
304
305 assert.strictEqual(batch.items.length, 40);
306 assert.isFalse(batch.truncated);
307 assert.strictEqual(mockedExecute.mock.calls.length, 1);
308 }),
309 );
310
311 it.effect("stops walking when every row on a page fails to decode", () =>
312 Effect.gen(function* () {
313 // Full pages of unusable rows: nothing is collected, so the collected-count bound never
314 // trips and only the page bound can end the walk.
315 const unusable = JSON.stringify(Array.from({ length: 100 }, () => ({ iid: "nope" })));
316 mockedExecute.mockReturnValue(Effect.succeed(output(unusable)));
317 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
318
319 const batch = yield* cli.listMergeRequests({
320 cwd: "/w",
321 repository: "acme/web",
322 state: "open",
323 involvement: "all",
324 viewer: "bilal",
325 limit: 150,
326 });
327
328 assert.strictEqual(batch.items.length, 0);
329 // ceil((150 + 1) / 100) pages, not one request per page forever.
330 assert.strictEqual(mockedExecute.mock.calls.length, 2);
331 }),
332 );
333
334 it.effect("asks GitLab for every state on the All tab", () =>
335 Effect.gen(function* () {
336 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
337 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
338
339 yield* cli.listMergeRequests({
340 cwd: "/w",
341 repository: "acme/web",
342 state: "all",
343 involvement: "all",
344 viewer: "bilal",
345 limit: 10,
346 });
347
348 expect(argsOfCall(0)[1]).toContain("state=all");
349 }),
350 );
351
352 it.effect("filters by the reviewer when the viewer is reviewing", () =>
353 Effect.gen(function* () {
354 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
355 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
356
357 yield* cli.listMergeRequests({
358 cwd: "/w",
359 repository: "acme/web",
360 state: "open",
361 involvement: "reviewing",
362 viewer: "bilal",
363 limit: 10,
364 });
365
366 expect(argsOfCall(0)[1]).toContain("reviewer_username=bilal");
367 }),
368 );
369
370 it.effect("addresses a nested group project by its encoded full path", () =>
371 Effect.gen(function* () {
372 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
373 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
374
375 yield* cli.listMergeRequests({
376 cwd: "/w",
377 repository: "acme/platform/web",
378 state: "open",
379 involvement: "all",
380 viewer: "bilal",
381 limit: 10,
382 });
383
384 expect(argsOfCall(0)[1]).toContain("projects/acme%2Fplatform%2Fweb/merge_requests");
385 }),
386 );
387
388 it.effect("merges immediately rather than leaving auto-merge armed", () =>
389 Effect.gen(function* () {
390 mockedExecute.mockReturnValueOnce(Effect.succeed(output("")));
391 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
392
393 yield* cli.runMergeRequestAction({
394 cwd: "/w",
395 repository: "acme/web",
396 number: 7,
397 action: "merge",
398 mergeMethod: "squash",
399 });
400
401 expect(argsOfCall(0)).toEqual([
402 "mr",
403 "merge",
404 "7",
405 "--repo",
406 "acme/web",
407 "--auto-merge=false",
408 "--yes",
409 "--squash",
410 ]);
411 }),
412 );
413
414 it.effect("arms auto-merge with the same strategy a merge would have used", () =>
415 Effect.gen(function* () {
416 mockedExecute.mockReturnValueOnce(Effect.succeed(output("")));
417 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
418
419 yield* cli.runMergeRequestAction({
420 cwd: "/w",
421 repository: "acme/web",
422 number: 7,
423 action: "enable-auto-merge",
424 mergeMethod: "squash",
425 });
426
427 expect(argsOfCall(0)).toEqual([
428 "mr",
429 "merge",
430 "7",
431 "--repo",
432 "acme/web",
433 "--auto-merge=true",
434 "--yes",
435 "--squash",
436 ]);
437 }),
438 );
439
440 it.effect("cancels an armed auto-merge through the API glab has no flag for", () =>
441 Effect.gen(function* () {
442 mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}")));
443 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
444
445 yield* cli.runMergeRequestAction({
446 cwd: "/w",
447 repository: "acme/platform/web",
448 number: 7,
449 action: "disable-auto-merge",
450 });
451
452 expect(argsOfCall(0)).toEqual([
453 "api",
454 "projects/acme%2Fplatform%2Fweb/merge_requests/7/cancel_merge_when_pipeline_succeeds",
455 "--method",
456 "POST",
457 ]);
458 }),
459 );
460
461 it.effect("brings a stale branch up to date by rebasing it, the only way GitLab has", () =>
462 Effect.gen(function* () {
463 mockedExecute.mockReturnValueOnce(Effect.succeed(output("")));
464 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
465
466 yield* cli.runMergeRequestAction({
467 cwd: "/w",
468 repository: "acme/web",
469 number: 7,
470 action: "update-branch",
471 });
472
473 expect(argsOfCall(0)).toEqual(["mr", "rebase", "7", "--repo", "acme/web"]);
474 }),
475 );
476
477 it.effect("moves a merge request back to draft through glab", () =>
478 Effect.gen(function* () {
479 mockedExecute.mockReturnValueOnce(Effect.succeed(output("")));
480 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
481
482 yield* cli.runMergeRequestAction({
483 cwd: "/w",
484 repository: "acme/web",
485 number: 7,
486 action: "draft",
487 });
488
489 expect(argsOfCall(0)).toEqual(["mr", "update", "7", "--repo", "acme/web", "--draft"]);
490 }),
491 );
492
493 it.effect("sends a comment body over stdin, never in argv", () =>
494 Effect.gen(function* () {
495 mockedExecute.mockReturnValueOnce(Effect.succeed(output("")));
496 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
497
498 yield* cli.commentOnMergeRequest({
499 cwd: "/w",
500 repository: "acme/web",
501 number: 7,
502 body: "true",
503 });
504
505 const call = mockedExecute.mock.calls[0];
506 assert.isDefined(call);
507 expect(call[0].args).toEqual([
508 "api",
509 "projects/acme%2Fweb/merge_requests/7/notes",
510 "--method",
511 "POST",
512 "--input",
513 "-",
514 "--header",
515 "Content-Type: application/json",
516 ]);
517 // A JSON body, so a comment reading as a literal `true` stays text.
518 expect(call[0].stdin).toBe('{"body":"true"}');
519 }),
520 );
521
522 it.effect("reads one diff page and hands back the cursor for the next", () =>
523 Effect.gen(function* () {
524 mockedExecute.mockReturnValueOnce(Effect.succeed(output(diffPage(0))));
525 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
526
527 const diff = yield* cli.getMergeRequestDiff({
528 cwd: "/w",
529 repository: "acme/web",
530 number: 7,
531 });
532
533 // One page per call: the reader asks for the rest, the walk does not run on by itself.
534 assert.strictEqual(mockedExecute.mock.calls.length, 1);
535 assert.isNotNull(diff.nextCursor);
536 // A full page means more files, not a slice with something missing from it.
537 assert.isFalse(diff.truncated);
538 expect(argsOfCall(0)[1]).toContain("merge_requests/7/diffs?per_page=100&page=1");
539 }),
540 );
541
542 it.effect("carries on from a cursor at the page it names", () =>
543 Effect.gen(function* () {
544 mockedExecute
545 .mockReturnValueOnce(Effect.succeed(output(diffPage(0))))
546 .mockReturnValueOnce(Effect.succeed(output(diffPage(100, 3))));
547 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
548 const target = { cwd: "/w", repository: "acme/web", number: 7 };
549
550 const first = yield* cli.getMergeRequestDiff(target);
551 assert.isNotNull(first.nextCursor);
552 const second = yield* cli.getMergeRequestDiff({ ...target, cursor: first.nextCursor });
553
554 expect(argsOfCall(1)[1]).toContain("page=2");
555 // A short page is the end of the change set, so there is nothing to carry on from.
556 assert.isNull(second.nextCursor);
557 expect(second.patch).toContain("diff --git a/src/100.ts b/src/100.ts");
558 }),
559 );
560
561 it.effect("refuses a cursor it never handed out rather than reading it into a query", () =>
562 Effect.gen(function* () {
563 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
564
565 const error = yield* Effect.flip(
566 cli.getMergeRequestDiff({
567 cwd: "/w",
568 repository: "acme/web",
569 number: 7,
570 cursor: "1&per_page=1",
571 }),
572 );
573
574 assert.strictEqual(error._tag, "GitLabDiffCursorError");
575 assert.strictEqual(mockedExecute.mock.calls.length, 0);
576 }),
577 );
578
579 it.effect("reads a named commit from its own diff, and pages inside it", () =>
580 Effect.gen(function* () {
581 mockedExecute
582 .mockReturnValueOnce(Effect.succeed(output(diffPage(0))))
583 .mockReturnValueOnce(Effect.succeed(output(diffPage(100, 3))));
584 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
585 const target = {
586 cwd: "/w",
587 repository: "acme/web",
588 number: 7,
589 commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
590 };
591
592 const first = yield* cli.getMergeRequestDiff(target);
593 assert.isNotNull(first.nextCursor);
594 const second = yield* cli.getMergeRequestDiff({ ...target, cursor: first.nextCursor });
595
596 const commitPath =
597 "projects/acme%2Fweb/repository/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0/diff";
598 expect(argsOfCall(0)[1]).toBe(`${commitPath}?per_page=100&page=1`);
599 // The whole path, not just the page: a cursor branch that dropped the commit would still
600 // ask for page 2, of the merge request's own diff.
601 expect(argsOfCall(1)[1]).toBe(`${commitPath}?per_page=100&page=2`);
602 assert.isNull(second.nextCursor);
603 }),
604 );
605
606 it.effect("refuses a commit that is not a sha rather than reading it into a path", () =>
607 Effect.gen(function* () {
608 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
609
610 const error = yield* Effect.flip(
611 cli.getMergeRequestDiff({
612 cwd: "/w",
613 repository: "acme/web",
614 number: 7,
615 commit: "../../merge_requests/8/diffs",
616 }),
617 );
618
619 assert.strictEqual(error._tag, "GitLabDiffCommitError");
620 assert.strictEqual(mockedExecute.mock.calls.length, 0);
621 }),
622 );
623
624 it.effect("reports a commit with no parent as a structured error", () =>
625 Effect.gen(function* () {
626 mockedExecute.mockReturnValueOnce(
627 Effect.succeed(output(JSON.stringify({ id: "a1b2c3d", parent_ids: [] }))),
628 );
629 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
630
631 const error = yield* Effect.flip(
632 cli.getMergeRequestDiffFileContents({
633 cwd: "/w",
634 repository: "acme/web",
635 number: 7,
636 commit: "a1b2c3d",
637 changeType: "change",
638 oldPath: "src/a.ts",
639 newPath: "src/a.ts",
640 }),
641 );
642
643 assert.strictEqual(error._tag, "GitLabDiffCommitParentUnavailableError");
644 if (error._tag === "GitLabDiffCommitParentUnavailableError") {
645 assert.strictEqual(error.commit, "a1b2c3d");
646 }
647 }),
648 );
649
650 it.effect("expands a new file from a root commit without requiring a parent", () =>
651 Effect.gen(function* () {
652 mockedExecute.mockReturnValueOnce(
653 Effect.succeed(output(JSON.stringify({ id: "a1b2c3d", parent_ids: [] }))),
654 );
655 mockedExecute.mockReturnValueOnce(Effect.succeed(output("first contents\n")));
656 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
657
658 const contents = yield* cli.getMergeRequestDiffFileContents({
659 cwd: "/w",
660 repository: "acme/web",
661 number: 7,
662 commit: "a1b2c3d",
663 changeType: "new",
664 oldPath: "src/first.ts",
665 newPath: "src/first.ts",
666 });
667
668 expect(contents).toEqual({ oldContents: "", newContents: "first contents\n" });
669 expect(argsOfCall(1)[1]).toContain("raw?ref=a1b2c3d");
670 }),
671 );
672
673 it.effect("reports an oversized diff file with its path and reason", () =>
674 Effect.gen(function* () {
675 mockedExecute.mockReturnValueOnce(
676 Effect.succeed(
677 output(
678 JSON.stringify({
679 diff_refs: {
680 base_sha: "a1b2c3d",
681 head_sha: "b1c2d3e",
682 start_sha: "a1b2c3d",
683 },
684 }),
685 ),
686 ),
687 );
688 mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true)));
689 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
690
691 const error = yield* Effect.flip(
692 cli.getMergeRequestDiffFileContents({
693 cwd: "/w",
694 repository: "acme/web",
695 number: 7,
696 changeType: "deleted",
697 oldPath: "src/large.ts",
698 newPath: "src/large.ts",
699 }),
700 );
701
702 assert.strictEqual(error._tag, "GitLabDiffFileContentsUnavailableError");
703 if (error._tag === "GitLabDiffFileContentsUnavailableError") {
704 assert.strictEqual(error.path, "src/large.ts");
705 assert.strictEqual(error.reason, "oversized");
706 }
707 }),
708 );
709
710 it.effect("reports undecodable diff file contents as binary", () =>
711 Effect.gen(function* () {
712 mockedExecute.mockReturnValueOnce(
713 Effect.succeed(
714 output(
715 JSON.stringify({
716 diff_refs: {
717 base_sha: "a1b2c3d",
718 head_sha: "b1c2d3e",
719 start_sha: "a1b2c3d",
720 },
721 }),
722 ),
723 ),
724 );
725 mockedExecute.mockReturnValueOnce(
726 Effect.succeed(output("binary\uFFFDcontents", false, true)),
727 );
728 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
729
730 const error = yield* Effect.flip(
731 cli.getMergeRequestDiffFileContents({
732 cwd: "/w",
733 repository: "acme/web",
734 number: 7,
735 changeType: "deleted",
736 oldPath: "assets/logo.png",
737 newPath: "assets/logo.png",
738 }),
739 );
740
741 assert.strictEqual(error._tag, "GitLabDiffFileContentsUnavailableError");
742 if (error._tag === "GitLabDiffFileContentsUnavailableError") {
743 assert.strictEqual(error.path, "assets/logo.png");
744 assert.strictEqual(error.reason, "binary");
745 }
746 }),
747 );
748
749 it.effect("returns valid text containing a literal replacement character", () =>
750 Effect.gen(function* () {
751 mockedExecute.mockReturnValueOnce(
752 Effect.succeed(
753 output(
754 JSON.stringify({
755 diff_refs: {
756 base_sha: "a1b2c3d",
757 head_sha: "b1c2d3e",
758 start_sha: "a1b2c3d",
759 },
760 }),
761 ),
762 ),
763 );
764 mockedExecute.mockReturnValueOnce(Effect.succeed(output("before\uFFFDafter")));
765 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
766
767 const contents = yield* cli.getMergeRequestDiffFileContents({
768 cwd: "/w",
769 repository: "acme/web",
770 number: 7,
771 changeType: "deleted",
772 oldPath: "docs/encoding.md",
773 newPath: "docs/encoding.md",
774 });
775
776 assert.strictEqual(contents.oldContents, "before\uFFFDafter");
777 }),
778 );
779
780 it.effect("ends the diff on a page with no files rather than asking for it again", () =>
781 Effect.gen(function* () {
782 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
783 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
784
785 const diff = yield* cli.getMergeRequestDiff({
786 cwd: "/w",
787 repository: "acme/web",
788 number: 7,
789 cursor: "4",
790 });
791
792 assert.strictEqual(diff.patch, "");
793 assert.isNull(diff.nextCursor);
794 }),
795 );
796
797 it.effect("fails a diff page cut off mid-JSON rather than calling the diff whole", () =>
798 Effect.gen(function* () {
799 mockedExecute.mockReturnValueOnce(
800 // A byte-truncated prefix: valid JSON never survives the cut.
801 Effect.succeed({ ...output('[{"old_path":"src/x.ts","new_p'), stdoutTruncated: true }),
802 );
803 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
804
805 const error = yield* Effect.flip(
806 cli.getMergeRequestDiff({ cwd: "/w", repository: "acme/web", number: 7 }),
807 );
808
809 // An empty slice with no cursor would report every file from this page on as already
810 // read, which is the one answer that loses a change without saying so.
811 assert.strictEqual(error._tag, "GitLabMergeRequestReadError");
812 }),
813 );
814
815 it.effect("offers no squash when the project does not say it allows one", () =>
816 Effect.gen(function* () {
817 mockedExecute.mockReturnValueOnce(
818 Effect.succeed(output(JSON.stringify({ merge_method: "merge" }))),
819 );
820 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
821
822 const capabilities = yield* cli.getProjectMergeCapabilities({
823 cwd: "/w",
824 repository: "acme/web",
825 });
826
827 assert.deepStrictEqual(capabilities, { merge: true, squash: false, rebase: false });
828 }),
829 );
830
831 it.effect("reads the project's merge settings as its merge capabilities", () =>
832 Effect.gen(function* () {
833 mockedExecute.mockReturnValueOnce(
834 Effect.succeed(output(JSON.stringify({ merge_method: "ff", squash_option: "never" }))),
835 );
836 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
837
838 const capabilities = yield* cli.getProjectMergeCapabilities({
839 cwd: "/w",
840 repository: "acme/web",
841 });
842
843 assert.deepStrictEqual(capabilities, { merge: false, squash: false, rebase: true });
844 }),
845 );
846
847 it.effect("asks the detail read for the divergence GitLab withholds by default", () =>
848 Effect.gen(function* () {
849 mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}')));
850 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
851
852 yield* Effect.ignore(
853 cli.getMergeRequestDetail({ cwd: "/w", repository: "acme/web", number: 7 }),
854 );
855
856 expect(argsOfCall(0)[1]).toBe(
857 "projects/acme%2Fweb/merge_requests/7?include_diverged_commits_count=true",
858 );
859 }),
860 );
861
862 it.effect("fails the read when GitLab returns something unreadable", () =>
863 Effect.gen(function* () {
864 mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}')));
865 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
866
867 const error = yield* Effect.flip(
868 cli.getMergeRequestDetail({ cwd: "/w", repository: "acme/web", number: 7 }),
869 );
870
871 assert.strictEqual(error._tag, "GitLabMergeRequestReadError");
872 }),
873 );
874
875 it.effect("fails when the authenticated account has no username", () =>
876 Effect.gen(function* () {
877 mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify({ username: "" }))));
878 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
879
880 const error = yield* Effect.flip(cli.getViewerUsername({ cwd: "/w" }));
881
882 assert.strictEqual(error._tag, "GitLabViewerUnavailableError");
883 }),
884 );
885
886 it.effect("walks the notes until GitLab answers with a short page", () =>
887 Effect.gen(function* () {
888 mockedExecute.mockReturnValueOnce(Effect.succeed(output(notes(100, 1))));
889 mockedExecute.mockReturnValueOnce(Effect.succeed(output(notes(2, 101))));
890 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
891
892 const { comments, truncated } = yield* cli.listNotes({
893 cwd: "/w",
894 repository: "acme/web",
895 number: 7,
896 });
897
898 expect(argsOfCall(0).join(" ")).toContain("page=1");
899 expect(argsOfCall(1).join(" ")).toContain("page=2");
900 assert.strictEqual(comments.length, 102);
901 assert.isFalse(truncated);
902 }),
903 );
904
905 it.effect("stops the note walk at its bound and says the conversation was cut short", () =>
906 Effect.gen(function* () {
907 // GitLab that never answers short: the walk has to end itself.
908 mockedExecute.mockReturnValue(Effect.succeed(output(notes(100, 1))));
909 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
910
911 const { truncated } = yield* cli.listNotes({
912 cwd: "/w",
913 repository: "acme/web",
914 number: 7,
915 });
916
917 assert.strictEqual(mockedExecute.mock.calls.length, 10);
918 assert.isTrue(truncated);
919 }),
920 );
921
922 it.effect("reads a positioned discussion as a thread anchored to its line", () =>
923 Effect.gen(function* () {
924 mockedExecute.mockReturnValueOnce(
925 Effect.succeed(
926 output(
927 JSON.stringify([
928 {
929 id: "abc123",
930 notes: [
931 {
932 id: 1,
933 body: "rename this",
934 author: { username: "bilal", avatar_url: "https://avatars/b.png" },
935 created_at: "2026-07-01T00:00:00Z",
936 resolvable: true,
937 resolved: true,
938 position: {
939 position_type: "text",
940 new_path: "src/a.ts",
941 old_path: "src/a.ts",
942 new_line: 12,
943 old_line: null,
944 },
945 },
946 {
947 id: 2,
948 body: "done",
949 author: { username: "julius" },
950 created_at: "2026-07-01T01:00:00Z",
951 },
952 ],
953 },
954 // A plain note is the timeline's business, not the diff's.
955 { id: "def456", notes: [{ id: 3, body: "ship it", created_at: "2026-07-01Z" }] },
956 ]),
957 ),
958 ),
959 );
960 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
961
962 const { threads } = yield* cli.listDiscussions({
963 cwd: "/w",
964 repository: "acme/web",
965 number: 7,
966 });
967
968 assert.strictEqual(threads.length, 1);
969 expect(threads[0]).toMatchObject({
970 id: "abc123",
971 path: "src/a.ts",
972 line: 12,
973 side: "right",
974 isResolved: true,
975 });
976 assert.strictEqual(threads[0]?.comments.length, 2);
977 }),
978 );
979
980 it.effect("sends a review as its comments, then its summary, then the verdict", () =>
981 Effect.gen(function* () {
982 mockedExecute.mockReturnValueOnce(
983 Effect.succeed(
984 output(
985 JSON.stringify({
986 iid: 7,
987 title: "t",
988 web_url: "https://gitlab.com/acme/web/-/merge_requests/7",
989 source_branch: "feat",
990 target_branch: "main",
991 created_at: "2026-07-01T00:00:00Z",
992 updated_at: "2026-07-01T00:00:00Z",
993 diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" },
994 }),
995 ),
996 ),
997 );
998 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
999 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1000
1001 yield* cli.submitReview({
1002 cwd: "/w",
1003 repository: "acme/web",
1004 number: 7,
1005 verdict: "approve",
1006 body: "Looks right.",
1007 comments: [
1008 {
1009 path: "src/b.ts",
1010 oldPath: "src/a.ts",
1011 position: { kind: "deleted", oldLine: 4 },
1012 body: "why remove?",
1013 },
1014 ],
1015 });
1016
1017 // The diff revisions first, because a positioned comment cannot be placed without them.
1018 expect(argsOfCall(0)[1]).toContain("merge_requests/7");
1019 expect(argsOfCall(1)[1]).toContain("/discussions");
1020 expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({
1021 body: "why remove?",
1022 position: {
1023 base_sha: "base",
1024 head_sha: "head",
1025 start_sha: "start",
1026 position_type: "text",
1027 // A renamed file is the only case the two differ, and GitLab cannot place a
1028 // position that names the same path on both sides of the rename.
1029 old_path: "src/a.ts",
1030 new_path: "src/b.ts",
1031 old_line: 4,
1032 },
1033 });
1034 expect(argsOfCall(2)[1]).toContain("/notes");
1035 // The verdict goes last, so a review that failed part-way is never an approval.
1036 expect(argsOfCall(3)[1]).toContain("/approve");
1037 }),
1038 );
1039
1040 it.effect("does not ask for diff revisions when a review carries no line comments", () =>
1041 Effect.gen(function* () {
1042 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
1043 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1044
1045 yield* cli.submitReview({
1046 cwd: "/w",
1047 repository: "acme/web",
1048 number: 7,
1049 verdict: "comment",
1050 body: "One thought.",
1051 comments: [],
1052 });
1053
1054 assert.strictEqual(mockedExecute.mock.calls.length, 1);
1055 expect(argsOfCall(0)[1]).toContain("/notes");
1056 }),
1057 );
1058
1059 it.effect("resolves a discussion in place rather than posting to it", () =>
1060 Effect.gen(function* () {
1061 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
1062 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1063
1064 yield* cli.setDiscussionResolution({
1065 cwd: "/w",
1066 repository: "acme/web",
1067 number: 7,
1068 discussionId: "abc123",
1069 resolved: true,
1070 });
1071
1072 expect(argsOfCall(0)).toContain("--method");
1073 expect(argsOfCall(0)).toContain("PUT");
1074 expect(argsOfCall(0)[1]).toContain("/discussions/abc123");
1075 expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ resolved: true });
1076 }),
1077 );
1078
1079 it.effect("awards an emoji through a POST naming it, not a body", () =>
1080 Effect.gen(function* () {
1081 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
1082 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1083
1084 yield* cli.setReaction({
1085 cwd: "/w",
1086 repository: "acme/web",
1087 number: 7,
1088 content: "thumbs-up",
1089 reacted: true,
1090 });
1091
1092 assert.strictEqual(mockedExecute.mock.calls.length, 1);
1093 expect(argsOfCall(0)).toEqual([
1094 "api",
1095 "projects/acme%2Fweb/merge_requests/7/award_emoji?name=thumbsup",
1096 "--method",
1097 "POST",
1098 ]);
1099 }),
1100 );
1101
1102 it.effect("removes an award by listing them and deleting the reader's own id", () =>
1103 Effect.gen(function* () {
1104 mockedExecute.mockReturnValueOnce(
1105 Effect.succeed(output(JSON.stringify({ username: "bilal" }))),
1106 );
1107 mockedExecute.mockReturnValueOnce(
1108 Effect.succeed(
1109 output(
1110 JSON.stringify([
1111 { id: 5, name: "thumbsup", user: { username: "bilal" } },
1112 { id: 6, name: "thumbsup", user: { username: "julius" } },
1113 ]),
1114 ),
1115 ),
1116 );
1117 mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}")));
1118 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1119
1120 yield* cli.setReaction({
1121 cwd: "/w",
1122 repository: "acme/web",
1123 number: 7,
1124 content: "thumbs-up",
1125 reacted: false,
1126 });
1127
1128 assert.strictEqual(mockedExecute.mock.calls.length, 3);
1129 expect(argsOfCall(2)).toEqual([
1130 "api",
1131 "projects/acme%2Fweb/merge_requests/7/award_emoji/5",
1132 "--method",
1133 "DELETE",
1134 ]);
1135 }),
1136 );
1137
1138 it.effect("does nothing when the reader has no award of that name to take back", () =>
1139 Effect.gen(function* () {
1140 mockedExecute.mockReturnValueOnce(
1141 Effect.succeed(output(JSON.stringify({ username: "bilal" }))),
1142 );
1143 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
1144 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1145
1146 yield* cli.setReaction({
1147 cwd: "/w",
1148 repository: "acme/web",
1149 number: 7,
1150 content: "thumbs-up",
1151 reacted: false,
1152 });
1153
1154 // Nothing to delete: the reaction the caller asked to take back is already gone.
1155 assert.strictEqual(mockedExecute.mock.calls.length, 2);
1156 }),
1157 );
1158
1159 it.effect("names a merge request with no diff revisions rather than calling it unreadable", () =>
1160 Effect.gen(function* () {
1161 mockedExecute.mockReturnValueOnce(
1162 Effect.succeed(
1163 output(
1164 JSON.stringify({
1165 iid: 7,
1166 title: "t",
1167 web_url: "https://gitlab.com/acme/web/-/merge_requests/7",
1168 source_branch: "feat",
1169 target_branch: "main",
1170 created_at: "2026-07-01T00:00:00Z",
1171 updated_at: "2026-07-01T00:00:00Z",
1172 diff_refs: null,
1173 }),
1174 ),
1175 ),
1176 );
1177 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1178
1179 const error = yield* Effect.flip(
1180 cli.submitReview({
1181 cwd: "/w",
1182 repository: "acme/web",
1183 number: 7,
1184 verdict: "comment",
1185 body: "",
1186 comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }],
1187 }),
1188 );
1189
1190 // Nothing failed to decode: GitLab answered, and the answer has nowhere to put a
1191 // positioned comment.
1192 assert.strictEqual(error._tag, "GitLabDiffRefsUnavailableError");
1193 }),
1194 );
1195
1196 it.effect("reads who has access to the project and who is already on the merge request", () =>
1197 Effect.gen(function* () {
1198 mockedExecute
1199 .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] }))))
1200 .mockReturnValueOnce(
1201 Effect.succeed(output(JSON.stringify([author, reviewer, { id: 9, username: "hubot" }]))),
1202 );
1203 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1204
1205 const list = yield* cli.listReviewerCandidates({
1206 cwd: "/w",
1207 repository: "acme/web",
1208 number: 7,
1209 });
1210
1211 expect(argsOfCall(1)[1]).toBe("projects/acme%2Fweb/users?per_page=100");
1212 // The author is left out, and whoever GitLab already has as a reviewer is marked.
1213 expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([
1214 ["5", true],
1215 ["9", false],
1216 ]);
1217 assert.isFalse(list.truncated);
1218 }),
1219 );
1220
1221 it.effect("writes the reviewer set back with the one being asked added to it", () =>
1222 Effect.gen(function* () {
1223 mockedExecute
1224 .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] }))))
1225 .mockReturnValueOnce(Effect.succeed(output("{}")));
1226 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1227
1228 yield* cli.setReviewerRequest({
1229 cwd: "/w",
1230 repository: "acme/web",
1231 number: 7,
1232 reviewers: [{ id: "9" }],
1233 requested: true,
1234 });
1235
1236 // GitLab replaces the whole set, so the reviewer already on the merge request has to be
1237 // sent back with the new one or the request would take them off it.
1238 expect(argsOfCall(1)).toContain("PUT");
1239 expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5, 9] });
1240 }),
1241 );
1242
1243 it.effect("takes a reviewer out of the set rather than clearing it", () =>
1244 Effect.gen(function* () {
1245 mockedExecute
1246 .mockReturnValueOnce(
1247 Effect.succeed(
1248 output(mergeRequestJson({ reviewers: [reviewer, { id: 9, username: "hubot" }] })),
1249 ),
1250 )
1251 .mockReturnValueOnce(Effect.succeed(output("{}")));
1252 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1253
1254 yield* cli.setReviewerRequest({
1255 cwd: "/w",
1256 repository: "acme/web",
1257 number: 7,
1258 reviewers: [{ id: "9" }],
1259 requested: false,
1260 });
1261
1262 expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] });
1263 }),
1264 );
1265
1266 it.effect("ignores an id GitLab could not have handed out, which names nobody", () =>
1267 Effect.gen(function* () {
1268 mockedExecute
1269 .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] }))))
1270 .mockReturnValueOnce(Effect.succeed(output("{}")));
1271 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1272
1273 yield* cli.setReviewerRequest({
1274 cwd: "/w",
1275 repository: "acme/web",
1276 number: 7,
1277 reviewers: [{ id: "octocat" }],
1278 requested: true,
1279 });
1280
1281 // Sending it as a number would rewrite the reviewer set around something nobody chose.
1282 expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] });
1283 }),
1284 );
1285
1286 it.effect("rewrites a title without touching the description", () =>
1287 Effect.gen(function* () {
1288 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
1289 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1290
1291 yield* cli.updateMergeRequest({
1292 cwd: "/w",
1293 repository: "acme/web",
1294 number: 7,
1295 title: "A better title",
1296 });
1297
1298 expect(argsOfCall(0)).toEqual([
1299 "api",
1300 "projects/acme%2Fweb/merge_requests/7",
1301 "--method",
1302 "PUT",
1303 "--input",
1304 "-",
1305 "--header",
1306 "Content-Type: application/json",
1307 ]);
1308 expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ title: "A better title" });
1309 }),
1310 );
1311
1312 it.effect("sends a rewritten body as GitLab's description, and nothing else", () =>
1313 Effect.gen(function* () {
1314 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
1315 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1316
1317 yield* cli.updateMergeRequest({
1318 cwd: "/w",
1319 repository: "acme/web",
1320 number: 7,
1321 description: "What this changes.",
1322 });
1323
1324 // A title sent as an empty string would wipe the one the merge request already has.
1325 expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ description: "What this changes." });
1326 }),
1327 );
1328
1329 it.effect("rewrites title and description together in one request", () =>
1330 Effect.gen(function* () {
1331 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
1332 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1333
1334 yield* cli.updateMergeRequest({
1335 cwd: "/w",
1336 repository: "acme/web",
1337 number: 7,
1338 title: "A better title",
1339 description: "What this changes.",
1340 });
1341
1342 assert.strictEqual(mockedExecute.mock.calls.length, 1);
1343 expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({
1344 title: "A better title",
1345 description: "What this changes.",
1346 });
1347 }),
1348 );
1349
1350 it.effect("rewrites a note in place through the note it names", () =>
1351 Effect.gen(function* () {
1352 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
1353 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1354
1355 yield* cli.updateNote({
1356 cwd: "/w",
1357 repository: "acme/web",
1358 number: 7,
1359 noteId: "42",
1360 body: "true",
1361 });
1362
1363 expect(argsOfCall(0)).toEqual([
1364 "api",
1365 "projects/acme%2Fweb/merge_requests/7/notes/42",
1366 "--method",
1367 "PUT",
1368 "--input",
1369 "-",
1370 "--header",
1371 "Content-Type: application/json",
1372 ]);
1373 // A JSON body, so a note rewritten to a literal `true` stays text.
1374 expect(callAt(0).stdin).toBe('{"body":"true"}');
1375 }),
1376 );
1377 it.effect("reads blob ids for the marked paths at the merge request's head", () =>
1378 Effect.gen(function* () {
1379 mockedExecute.mockReturnValueOnce(
1380 Effect.succeed(
1381 output(
1382 JSON.stringify({
1383 iid: 7,
1384 title: "t",
1385 web_url: "https://gitlab.com/acme/web/-/merge_requests/7",
1386 source_branch: "feat",
1387 target_branch: "main",
1388 created_at: "2026-07-01T00:00:00Z",
1389 updated_at: "2026-07-01T00:00:00Z",
1390 diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" },
1391 }),
1392 ),
1393 ),
1394 );
1395 mockedExecute.mockReturnValueOnce(
1396 Effect.succeed(
1397 output(
1398 JSON.stringify({
1399 data: {
1400 project: { repository: { blobs: { nodes: [{ path: "src/a.ts", oid: "aaa" }] } } },
1401 },
1402 }),
1403 ),
1404 ),
1405 );
1406 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1407
1408 const revisions = yield* cli.getFileRevisions({
1409 cwd: "/w",
1410 repository: "acme/web",
1411 number: 7,
1412 paths: ["src/a.ts", "src/gone.ts"],
1413 });
1414
1415 // Every path was looked for at the head, so one the head does not have is one the merge
1416 // request removed: said as the empty version, which a mark on it was stamped with too.
1417 expect([...revisions]).toEqual([
1418 ["src/a.ts", "aaa"],
1419 ["src/gone.ts", ""],
1420 ]);
1421 // The head the reader is looking at, not whatever the source branch has moved on to.
1422 const body: unknown = JSON.parse(callAt(1).stdin ?? "{}");
1423 expect(body).toMatchObject({
1424 variables: { fullPath: "acme/web", ref: "head", paths: ["src/a.ts", "src/gone.ts"] },
1425 });
1426 }),
1427 );
1428
1429 it.effect("leaves the paths out when GitLab did not answer the blobs query", () =>
1430 Effect.gen(function* () {
1431 mockedExecute.mockReturnValueOnce(
1432 Effect.succeed(
1433 output(
1434 JSON.stringify({
1435 iid: 7,
1436 title: "t",
1437 web_url: "https://gitlab.com/acme/web/-/merge_requests/7",
1438 source_branch: "feat",
1439 target_branch: "main",
1440 created_at: "2026-07-01T00:00:00Z",
1441 updated_at: "2026-07-01T00:00:00Z",
1442 diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" },
1443 }),
1444 ),
1445 ),
1446 );
1447 // What GitLab says of a project the token cannot see. It is not the head having none of
1448 // these files, and reading it that way would report every file the reader has cleared as
1449 // changed on nothing worse than a permission.
1450 mockedExecute.mockReturnValueOnce(
1451 Effect.succeed(output(JSON.stringify({ data: { project: null } }))),
1452 );
1453 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1454
1455 const revisions = yield* cli.getFileRevisions({
1456 cwd: "/w",
1457 repository: "acme/web",
1458 number: 7,
1459 paths: ["src/a.ts", "src/b.ts"],
1460 });
1461
1462 expect([...revisions]).toEqual([]);
1463 }),
1464 );
1465
1466 it.effect("asks GitLab nothing when no file is marked", () =>
1467 Effect.gen(function* () {
1468 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1469
1470 const revisions = yield* cli.getFileRevisions({
1471 cwd: "/w",
1472 repository: "acme/web",
1473 number: 7,
1474 paths: [],
1475 });
1476
1477 expect([...revisions]).toEqual([]);
1478 expect(mockedExecute).not.toHaveBeenCalled();
1479 }),
1480 );
1481
1482 it.effect("splits the paths across requests, because GitLab charges the query by how many", () =>
1483 Effect.gen(function* () {
1484 const paths = Array.from({ length: 150 }, (_, index) => `src/${index}.ts`);
1485 mockedExecute.mockReturnValueOnce(
1486 Effect.succeed(
1487 output(
1488 JSON.stringify({
1489 iid: 7,
1490 title: "t",
1491 web_url: "https://gitlab.com/acme/web/-/merge_requests/7",
1492 source_branch: "feat",
1493 target_branch: "main",
1494 created_at: "2026-07-01T00:00:00Z",
1495 updated_at: "2026-07-01T00:00:00Z",
1496 diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" },
1497 }),
1498 ),
1499 ),
1500 );
1501 mockedExecute.mockImplementation((request) => {
1502 const body = JSON.parse(request.stdin ?? "{}") as {
1503 readonly variables: { readonly paths: ReadonlyArray<string> };
1504 };
1505 return Effect.succeed(
1506 output(
1507 JSON.stringify({
1508 data: {
1509 project: {
1510 repository: {
1511 blobs: {
1512 nodes: body.variables.paths.map((path) => ({ path, oid: `oid-${path}` })),
1513 },
1514 },
1515 },
1516 },
1517 }),
1518 ),
1519 );
1520 });
1521 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1522
1523 const revisions = yield* cli.getFileRevisions({
1524 cwd: "/w",
1525 repository: "acme/web",
1526 number: 7,
1527 paths,
1528 });
1529
1530 assert.strictEqual(revisions.size, 150);
1531 assert.strictEqual(revisions.get("src/149.ts"), "oid-src/149.ts");
1532 // The diff refs, then two batches: a hundred paths and the fifty left over.
1533 assert.strictEqual(mockedExecute.mock.calls.length, 3);
1534 }),
1535 );
1536});