authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 14:50:01-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 14:50:01-07:00
logeb459b5afaf42621a6cb2e698715fa017e26e5d7
tree78bb581272aad3f8c5324cef792debf4b08e9a0e
parentd01febb509a376d93af2d298a7787449792b5fcb
signature Signed by PGP key B5690EEEBB952194

refactor(source-control): GitLab lives in @t3tools/source-control-gitlab (#17594)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

42 files changed, 7431 insertions(+), 7324 deletions(-)

apps/server/package.json+2
......@@ -48,6 +48,7 @@
4848 "@t3tools/source-control-azure-devops": "workspace:*",
4949 "@t3tools/source-control-core": "workspace:*",
5050 "@t3tools/source-control-forgejo": "workspace:*",
51 "@t3tools/source-control-gitlab": "workspace:*",
5152 "effect": "catalog:",
5253 "jose": "catalog:",
5354 "node-pty": "^1.2.0-beta.15",
......@@ -64,6 +65,7 @@
6465 "@t3tools/contracts": "workspace:*",
6566 "@t3tools/provider-testing": "workspace:*",
6667 "@t3tools/shared": "workspace:*",
68 "@t3tools/source-control-testing": "workspace:*",
6769 "@t3tools/ssh": "workspace:*",
6870 "@t3tools/tailscale": "workspace:*",
6971 "@t3tools/web": "workspace:*",
apps/server/scripts/evaluate-thread-titles.ts-2
......@@ -24,7 +24,6 @@ import {
2424import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts";
2525import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts";
2626import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";
27import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";
2827import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts";
2928import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts";
3029import * as ServerSettings from "../src/serverSettings.ts";
......@@ -152,7 +151,6 @@ await Effect.runPromise(
152151 Layer.provide(
153152 Layer.mergeAll(
154153 GitHubApi.layerWithDependencies,
155 GitLabCli.layer,
156154 SourceControlBuiltInDrivers.layer,
157155 // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables.
158156 BitbucketApi.layer,
apps/server/src/git/GitManager.test.ts+13-9
......@@ -44,7 +44,9 @@ import {
4444} from "@t3tools/contracts";
4545import * as DateTime from "effect/DateTime";
4646import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts";
47import * as GitLabCli from "../sourceControl/GitLabCli.ts";
47import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
48import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
49import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
4850import * as TextGeneration from "../textGeneration/TextGeneration.ts";
4951import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
5052import * as VcsProcess from "../vcs/VcsProcess.ts";
......@@ -53,7 +55,7 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts";
5355import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
5456import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
5557import * as GitWorkflowService from "./GitWorkflowService.ts";
56import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts";
58import * as GitLabSourceControlProvider from "@t3tools/source-control-gitlab/server/GitLabSourceControlProvider";
5759import {
5860 ForgejoPullRequestSchema,
5961 toForgejoChangeRequest,
......@@ -2108,17 +2110,19 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
21082110 source_project: { path_with_namespace: "Group/Subgroup/Fork" },
21092111 },
21102112 ]);
2111 const calls: VcsProcess.VcsProcessInput[] = [];
2113 const calls: SourceControlHost.SourceControlProcessInput[] = [];
21122114 const provider = yield* GitLabSourceControlProvider.make.pipe(
21132115 Effect.provide(
21142116 GitLabCli.layer.pipe(
21152117 Layer.provide(
2116 Layer.mock(VcsProcess.VcsProcess)({
2117 run: (input) =>
2118 Effect.sync(() => {
2119 calls.push(input);
2120 return fakeGhOutput(output);
2121 }),
2118 TestSourceControlHost.layer({
2119 process: {
2120 run: (input) =>
2121 Effect.sync(() => {
2122 calls.push(input);
2123 return fakeGhOutput(output);
2124 }),
2125 },
21222126 }),
21232127 ),
21242128 ),
apps/server/src/pullRequest/GitLabPullRequestCli.test.ts deleted-1536
......@@ -1,1536 +0,0 @@
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 "../sourceControl/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});
apps/server/src/pullRequest/GitLabPullRequestCli.ts deleted-1495
......@@ -1,1495 +0,0 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Result from "effect/Result";
5import * as Schema from "effect/Schema";
6import type {
7 PullRequestAction,
8 PullRequestComment,
9 PullRequestCommit,
10 PullRequestInvolvement,
11 PullRequestListState,
12 PullRequestMergeCapabilities,
13 PullRequestMergeMethod,
14 PullRequestReaction,
15 PullRequestReactionContent,
16 PullRequestReviewCommentDraft,
17 PullRequestReviewPosition,
18 PullRequestReviewThread,
19 PullRequestReviewVerdict,
20 PullRequestReviewerCandidateList,
21} from "@t3tools/contracts";
22
23import * as GitLabCli from "../sourceControl/GitLabCli.ts";
24import {
25 AWARD_EMOJI_GRAPHQL_QUERY,
26 decodeAwardEmojiJson,
27 decodeCommitDiffRefsJson,
28 decodeCommitsJson,
29 decodeDiffRefsJson,
30 decodeDiscussionsJson,
31 decodeMergeRequestDetailJson,
32 decodeMergeRequestDiffsJson,
33 decodeMergeRequestListJson,
34 decodeNotesJson,
35 decodeOwnAwardIdJson,
36 decodeProjectMergeCapabilitiesJson,
37 decodeProjectUsersJson,
38 decodeRepositoryBlobsJson,
39 decodeViewerJson,
40 gitLabAwardName,
41 REPOSITORY_BLOBS_GRAPHQL_QUERY,
42 type GitLabDiffRefs,
43 type GitLabMergeRequestDetail,
44 type GitLabMergeRequestListItem,
45 type GitLabProjectUsers,
46} from "./gitLabMergeRequestJson.ts";
47import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider";
48
49/**
50 * Names the read that produced unusable output, so a failure reports the call it came from
51 * rather than borrowing another operation's message.
52 */
53export class GitLabMergeRequestReadError extends Schema.TaggedError<GitLabMergeRequestReadError>()(
54 "GitLabMergeRequestReadError",
55 {
56 command: Schema.Literal("glab"),
57 cwd: Schema.String,
58 operation: Schema.String,
59 cause: Schema.Defect(),
60 },
61) {
62 get detail(): string {
63 return `GitLab CLI returned an unreadable ${this.operation} response.`;
64 }
65
66 override get message(): string {
67 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
68 }
69}
70
71/** Not a decode failure: glab answered, the account it answered for just has no username. */
72export class GitLabViewerUnavailableError extends Schema.TaggedError<GitLabViewerUnavailableError>()(
73 "GitLabViewerUnavailableError",
74 {
75 command: Schema.Literal("glab"),
76 cwd: Schema.String,
77 },
78) {
79 get detail(): string {
80 return "GitLab CLI returned no username for the authenticated account.";
81 }
82
83 override get message(): string {
84 return `GitLab CLI failed in getViewerUsername: ${this.detail}`;
85 }
86}
87
88/** Not a decode failure: GitLab answered, the merge request just has no revisions to place a
89 * comment against. */
90export class GitLabDiffRefsUnavailableError extends Schema.TaggedError<GitLabDiffRefsUnavailableError>()(
91 "GitLabDiffRefsUnavailableError",
92 {
93 command: Schema.Literal("glab"),
94 cwd: Schema.String,
95 number: Schema.Int,
96 },
97) {
98 get detail(): string {
99 return "The merge request reported no diff revisions.";
100 }
101
102 override get message(): string {
103 return `GitLab CLI failed in getDiffRefs: ${this.detail}`;
104 }
105}
106
107/** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */
108export class GitLabDiffCursorError extends Schema.TaggedError<GitLabDiffCursorError>()(
109 "GitLabDiffCursorError",
110 {
111 command: Schema.Literal("glab"),
112 cwd: Schema.String,
113 },
114) {
115 get detail(): string {
116 return "The diff cursor was not one this merge request handed out.";
117 }
118
119 override get message(): string {
120 return `GitLab CLI failed in getMergeRequestDiff: ${this.detail}`;
121 }
122}
123
124/** Not a decode failure: the reader named a commit that is not a sha this project could hold. */
125export class GitLabDiffCommitError extends Schema.TaggedError<GitLabDiffCommitError>()(
126 "GitLabDiffCommitError",
127 {
128 command: Schema.Literal("glab"),
129 cwd: Schema.String,
130 },
131) {
132 get detail(): string {
133 return "The named commit was not a commit sha.";
134 }
135
136 override get message(): string {
137 return `GitLab CLI failed in getMergeRequestDiff: ${this.detail}`;
138 }
139}
140
141/** The commit exists and decoded, but it has no parent to use as the old revision. */
142export class GitLabDiffCommitParentUnavailableError extends Schema.TaggedError<GitLabDiffCommitParentUnavailableError>()(
143 "GitLabDiffCommitParentUnavailableError",
144 {
145 command: Schema.Literal("glab"),
146 cwd: Schema.String,
147 commit: Schema.String,
148 },
149) {
150 get detail(): string {
151 return `Commit ${this.commit} reported no parent revision.`;
152 }
153
154 override get message(): string {
155 return `GitLab CLI failed in getMergeRequestDiffFileContents: ${this.detail}`;
156 }
157}
158
159/** A blob exists, but expanding it would be unsafe or would not produce text. */
160export class GitLabDiffFileContentsUnavailableError extends Schema.TaggedError<GitLabDiffFileContentsUnavailableError>()(
161 "GitLabDiffFileContentsUnavailableError",
162 {
163 command: Schema.Literal("glab"),
164 cwd: Schema.String,
165 path: Schema.String,
166 reason: Schema.Literals(["oversized", "binary"]),
167 },
168) {
169 get detail(): string {
170 return this.reason === "oversized"
171 ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.`
172 : `The diff file '${this.path}' is binary.`;
173 }
174
175 override get message(): string {
176 return `GitLab CLI failed in getMergeRequestDiffFileContents: ${this.detail}`;
177 }
178}
179
180export type GitLabPullRequestCliError =
181 | GitLabCli.GitLabCliError
182 | GitLabMergeRequestReadError
183 | GitLabDiffCursorError
184 | GitLabDiffCommitError
185 | GitLabDiffCommitParentUnavailableError
186 | GitLabDiffFileContentsUnavailableError
187 | GitLabDiffRefsUnavailableError
188 | GitLabViewerUnavailableError;
189
190/** GitLab's own ceiling on `per_page`, so a larger page has to be walked. */
191const MAX_PAGE_SIZE = 100;
192/** Commit history is read one page deep; the rest of a long history stays on GitLab. */
193const COMMIT_PAGE_SIZE = 100;
194/**
195 * Pages of the conversation to follow before it is reported as truncated. GitLab caps a page at
196 * a hundred, so this is a thousand notes and a thousand discussions — more than any merge
197 * request a person is reading holds, and a walk that ends whatever the host has.
198 */
199const CONVERSATION_PAGES = 10;
200const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
201const DIFF_TIMEOUT_MS = 60_000;
202const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;
203
204export interface GitLabMergeRequestListBatch {
205 readonly items: ReadonlyArray<GitLabMergeRequestListItem>;
206 readonly truncated: boolean;
207 /** Raw GitLab rows consumed to produce this page, including malformed rows. */
208 readonly cursorAdvance: number;
209}
210
211export interface GitLabMergeRequestDiffSlice {
212 readonly patch: string;
213 /** Files in this slice had their hunks withheld, as opposed to there being more slices. */
214 readonly truncated: boolean;
215 /** Where the next slice starts, or null once the patch is whole. */
216 readonly nextCursor: string | null;
217}
218
219export class GitLabPullRequestCli extends Context.Service<
220 GitLabPullRequestCli,
221 {
222 readonly getViewerUsername: (input: {
223 readonly cwd: string;
224 }) => Effect.Effect<string, GitLabPullRequestCliError>;
225
226 readonly listMergeRequests: (input: {
227 readonly cwd: string;
228 readonly repository: string;
229 readonly state: PullRequestListState;
230 readonly involvement: PullRequestInvolvement;
231 readonly viewer: string;
232 readonly limit: number;
233 /** Free text for GitLab's own `search`, which matches title and description. */
234 readonly query?: string | undefined;
235 /** Where to carry on from in GitLab's stable update-ordered row set. */
236 readonly cursor?: ProviderListCursor | undefined;
237 }) => Effect.Effect<GitLabMergeRequestListBatch, GitLabPullRequestCliError>;
238
239 readonly getMergeRequestDetail: (input: {
240 readonly cwd: string;
241 readonly repository: string;
242 readonly number: number;
243 }) => Effect.Effect<GitLabMergeRequestDetail, GitLabPullRequestCliError>;
244
245 readonly listNotes: (input: {
246 readonly cwd: string;
247 readonly repository: string;
248 readonly number: number;
249 }) => Effect.Effect<
250 { readonly comments: ReadonlyArray<PullRequestComment>; readonly truncated: boolean },
251 GitLabPullRequestCliError
252 >;
253
254 readonly listCommits: (input: {
255 readonly cwd: string;
256 readonly repository: string;
257 readonly number: number;
258 }) => Effect.Effect<ReadonlyArray<PullRequestCommit>, GitLabPullRequestCliError>;
259
260 readonly getMergeRequestDiff: (input: {
261 readonly cwd: string;
262 readonly repository: string;
263 readonly number: number;
264 /** Absent asks for the first slice; anything else is a cursor a slice handed back. */
265 readonly cursor?: string | undefined;
266 /** One commit's own changes, rather than everything the merge request carries. */
267 readonly commit?: string | undefined;
268 }) => Effect.Effect<GitLabMergeRequestDiffSlice, GitLabPullRequestCliError>;
269
270 readonly getMergeRequestDiffFileContents: (input: {
271 readonly cwd: string;
272 readonly repository: string;
273 readonly number: number;
274 readonly commit?: string | undefined;
275 readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted";
276 readonly oldPath: string;
277 readonly newPath: string;
278 }) => Effect.Effect<
279 { readonly oldContents: string; readonly newContents: string },
280 GitLabPullRequestCliError
281 >;
282
283 readonly getProjectMergeCapabilities: (input: {
284 readonly cwd: string;
285 readonly repository: string;
286 }) => Effect.Effect<PullRequestMergeCapabilities, GitLabPullRequestCliError>;
287
288 /**
289 * What the merge request's head has of each of these paths, as blob ids.
290 *
291 * The head sha comes from the merge request's own diff refs, so the answer is the version a
292 * reader is looking at rather than whatever the source branch has moved on to. A path the
293 * head does not have is answered as the empty revision, since the batch it was asked in was
294 * looked at; a batch GitLab did not answer for is left out instead.
295 */
296 readonly getFileRevisions: (input: {
297 readonly cwd: string;
298 readonly repository: string;
299 readonly number: number;
300 readonly paths: ReadonlyArray<string>;
301 }) => Effect.Effect<ReadonlyMap<string, string>, GitLabPullRequestCliError>;
302
303 /**
304 * Who this merge request may be sent to, and who it has already been sent to. Two reads at
305 * once, because GitLab keeps the people with access on the project and the reviewers on the
306 * merge request, and neither answers for the other.
307 */
308 readonly listReviewerCandidates: (input: {
309 readonly cwd: string;
310 readonly repository: string;
311 readonly number: number;
312 }) => Effect.Effect<PullRequestReviewerCandidateList, GitLabPullRequestCliError>;
313
314 readonly setReviewerRequest: (input: {
315 readonly cwd: string;
316 readonly repository: string;
317 readonly number: number;
318 readonly reviewers: ReadonlyArray<{ readonly id: string }>;
319 readonly requested: boolean;
320 }) => Effect.Effect<void, GitLabPullRequestCliError>;
321
322 readonly runMergeRequestAction: (input: {
323 readonly cwd: string;
324 readonly repository: string;
325 readonly number: number;
326 readonly action: PullRequestAction;
327 readonly mergeMethod?: PullRequestMergeMethod;
328 }) => Effect.Effect<void, GitLabPullRequestCliError>;
329
330 /** Whichever of the two is given is sent. GitLab calls a merge request's body its description. */
331 readonly updateMergeRequest: (input: {
332 readonly cwd: string;
333 readonly repository: string;
334 readonly number: number;
335 readonly title?: string | undefined;
336 readonly description?: string | undefined;
337 }) => Effect.Effect<void, GitLabPullRequestCliError>;
338
339 readonly commentOnMergeRequest: (input: {
340 readonly cwd: string;
341 readonly repository: string;
342 readonly number: number;
343 readonly body: string;
344 }) => Effect.Effect<void, GitLabPullRequestCliError>;
345
346 readonly updateNote: (input: {
347 readonly cwd: string;
348 readonly repository: string;
349 readonly number: number;
350 readonly noteId: string;
351 readonly body: string;
352 }) => Effect.Effect<void, GitLabPullRequestCliError>;
353
354 readonly listDiscussions: (input: {
355 readonly cwd: string;
356 readonly repository: string;
357 readonly number: number;
358 }) => Effect.Effect<
359 { readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly truncated: boolean },
360 GitLabPullRequestCliError
361 >;
362
363 readonly submitReview: (input: {
364 readonly cwd: string;
365 readonly repository: string;
366 readonly number: number;
367 readonly verdict: PullRequestReviewVerdict;
368 readonly body: string;
369 readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>;
370 }) => Effect.Effect<void, GitLabPullRequestCliError>;
371
372 readonly replyToDiscussion: (input: {
373 readonly cwd: string;
374 readonly repository: string;
375 readonly number: number;
376 readonly discussionId: string;
377 readonly body: string;
378 }) => Effect.Effect<void, GitLabPullRequestCliError>;
379
380 readonly setDiscussionResolution: (input: {
381 readonly cwd: string;
382 readonly repository: string;
383 readonly number: number;
384 readonly discussionId: string;
385 readonly resolved: boolean;
386 }) => Effect.Effect<void, GitLabPullRequestCliError>;
387
388 /** The awards on the merge request and on every note of it, keyed by the note's REST id. */
389 readonly listReactions: (input: {
390 readonly cwd: string;
391 readonly repository: string;
392 readonly number: number;
393 }) => Effect.Effect<
394 {
395 readonly reactions: ReadonlyArray<PullRequestReaction>;
396 readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
397 },
398 GitLabPullRequestCliError
399 >;
400
401 /**
402 * Awards an emoji, or takes the award back. `noteId` is a note of the merge request; absent
403 * awards the merge request itself, which is where its description's reactions live.
404 */
405 readonly setReaction: (input: {
406 readonly cwd: string;
407 readonly repository: string;
408 readonly number: number;
409 readonly noteId?: string | undefined;
410 readonly content: PullRequestReactionContent;
411 readonly reacted: boolean;
412 }) => Effect.Effect<void, GitLabPullRequestCliError>;
413 }
414>()("t3/pullRequest/GitLabPullRequestCli") {}
415
416/** The REST API addresses a project by its URL-encoded full path. */
417function projectPath(repository: string): string {
418 return encodeURIComponent(repository.trim());
419}
420
421function gitLabReviewPositionLines(
422 position: PullRequestReviewPosition,
423):
424 | { readonly new_line: number }
425 | { readonly old_line: number }
426 | { readonly old_line: number; readonly new_line: number } {
427 switch (position.kind) {
428 case "added":
429 return { new_line: position.newLine };
430 case "deleted":
431 return { old_line: position.oldLine };
432 case "context":
433 return { old_line: position.oldLine, new_line: position.newLine };
434 }
435}
436
437function stateParam(state: PullRequestListState): string {
438 // GitLab's `closed` already excludes merged merge requests, so no extra filter is needed,
439 // and it spans every state under `all`.
440 return state === "open" ? "opened" : state;
441}
442
443function involvementParams(input: {
444 readonly involvement: PullRequestInvolvement;
445 readonly viewer: string;
446}): ReadonlyArray<readonly [string, string]> {
447 switch (input.involvement) {
448 case "authored":
449 return [["author_username", input.viewer]];
450 case "reviewing":
451 return [["reviewer_username", input.viewer]];
452 case "all":
453 return [];
454 }
455}
456
457/**
458 * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor
459 * arrives from the reader as a string and goes straight into a query, so it is parsed rather
460 * than trusted; the length bound keeps a page number out of exponential notation.
461 */
462function diffCursorPage(cursor: string): number | null {
463 return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null;
464}
465
466/**
467 * A commit sha arrives from the reader and goes straight into a request path, so it is checked
468 * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a
469 * whole sha.
470 */
471function isCommitSha(value: string): boolean {
472 return /^[0-9a-f]{7,64}$/i.test(value);
473}
474
475function searchParams(search: string | undefined): ReadonlyArray<readonly [string, string]> {
476 const trimmed = search?.trim() ?? "";
477 return trimmed.length === 0 ? [] : [["search", trimmed]];
478}
479
480function query(params: ReadonlyArray<readonly [string, string]>): string {
481 return params.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");
482}
483
484function actionArgs(
485 action: PullRequestAction,
486 mergeMethod: PullRequestMergeMethod | undefined,
487): ReadonlyArray<string> {
488 switch (action) {
489 case "merge":
490 return [
491 "merge",
492 // glab turns on auto-merge whenever a pipeline is running. The button means merge now.
493 "--auto-merge=false",
494 "--yes",
495 ...(mergeMethod === "squash" ? ["--squash"] : []),
496 ...(mergeMethod === "rebase" ? ["--rebase"] : []),
497 ];
498 // The same command with the flag the other way up: here the wait is the whole point, so
499 // glab is told to arm the merge rather than talked out of it.
500 case "enable-auto-merge":
501 return [
502 "merge",
503 "--auto-merge=true",
504 "--yes",
505 ...(mergeMethod === "squash" ? ["--squash"] : []),
506 ...(mergeMethod === "rebase" ? ["--rebase"] : []),
507 ];
508 // Never reached: taking the arming back has no `glab mr` command, so it goes to the API.
509 case "disable-auto-merge":
510 return [];
511 case "ready":
512 return ["update", "--ready"];
513 case "draft":
514 return ["update", "--draft"];
515 case "close":
516 return ["close"];
517 // A rebase, because GitLab has no other way to move a branch onto its target: there is no
518 // merge-the-target-in equivalent of GitHub's update button, which is why this host declares
519 // `rebase` alone and never has to read the method it was handed.
520 case "update-branch":
521 return ["rebase"];
522 case "reopen":
523 return ["reopen"];
524 // Never reached: this host does not declare the action, so the service refuses it first.
525 case "revert":
526 case "approve-workflows":
527 throw new Error(`GitLab merge request action ${action} is unsupported`);
528 }
529}
530
531/** @public Service construction is part of the canonical Effect module API. */
532export const make = Effect.gen(function* () {
533 const gitlab = yield* GitLabCli.GitLabCli;
534
535 const api = (input: {
536 readonly cwd: string;
537 readonly path: string;
538 readonly method?: string;
539 readonly stdin?: string;
540 readonly maxOutputBytes?: number;
541 readonly timeoutMs?: number;
542 }) =>
543 gitlab.execute({
544 cwd: input.cwd,
545 args: [
546 "api",
547 input.path,
548 ...(input.method === undefined ? [] : ["--method", input.method]),
549 // A raw body from stdin: argv is visible in process listings and is echoed back
550 // inside process-runner failure messages. Unlike `gh`, `glab api --input` sends no
551 // Content-Type at all, and GitLab answers a bodyless content type with HTTP 415.
552 ...(input.stdin === undefined
553 ? []
554 : ["--input", "-", "--header", "Content-Type: application/json"]),
555 ],
556 ...(input.stdin === undefined ? {} : { stdin: input.stdin }),
557 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
558 ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
559 });
560
561 /**
562 * `per_page` stops at 100, so a larger page is walked one request at a time. The walk is
563 * bounded twice over: it stops on a short page or once the extra row that reveals a next
564 * page has been read, and it never asks for more pages than the caller's page needs. The
565 * second bound is what makes it terminate when every row on a page fails to decode, which
566 * leaves nothing collected but does not mean GitLab has run out of rows.
567 */
568 const listPage = (input: {
569 readonly cwd: string;
570 readonly repository: string;
571 readonly state: PullRequestListState;
572 readonly involvement: PullRequestInvolvement;
573 readonly viewer: string;
574 readonly limit: number;
575 readonly query?: string | undefined;
576 readonly cursor?: ProviderListCursor | undefined;
577 readonly page: number;
578 readonly collected: ReadonlyArray<GitLabMergeRequestListItem>;
579 readonly cursorAdvance: number;
580 }): Effect.Effect<GitLabMergeRequestListBatch, GitLabPullRequestCliError> => {
581 // A continuation uses GitLab's offset pagination. Its timestamp filter is inclusive and has
582 // no tie-breaker, so a page where many rows share the boundary would otherwise return the
583 // same prefix forever. `delivered` is the stable offset the service has already handed over.
584 const delivered = input.cursor?.delivered ?? 0;
585 const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE);
586 const firstPage = Math.floor(delivered / perPage) + 1;
587 const skipOnFirstPage = input.page === firstPage ? delivered % perPage : 0;
588 // A page made entirely of malformed rows has no item from which the service can build a
589 // continuation. Bound the walk to the raw span this request asked for rather than recursing
590 // forever on a host that keeps returning full unusable pages.
591 const lastPage = Math.floor((delivered + input.limit) / perPage) + 1;
592 return api({
593 cwd: input.cwd,
594 path: `projects/${projectPath(input.repository)}/merge_requests?${query([
595 ["state", stateParam(input.state)],
596 ...involvementParams(input),
597 // The listing is read through `glab api` rather than `glab mr list`, so the search is
598 // the REST API's own `search` parameter — the one `mr list --search` passes on. It
599 // matches title and description, and travels URL-encoded like every other value here,
600 // so no text in it can become a parameter of its own.
601 ...searchParams(input.query),
602 ["order_by", "updated_at"],
603 ["sort", "desc"],
604 ["per_page", String(perPage)],
605 ["page", String(input.page)],
606 ])}`,
607 }).pipe(
608 Effect.flatMap((result) => {
609 const raw = result.stdout.trim();
610 if (raw.length === 0) {
611 return Effect.succeed({
612 items: input.collected,
613 truncated: false,
614 cursorAdvance: input.cursorAdvance,
615 });
616 }
617 const decoded = decodeMergeRequestListJson(raw);
618 if (!Result.isSuccess(decoded)) {
619 return Effect.fail(
620 new GitLabMergeRequestReadError({
621 command: "glab",
622 cwd: input.cwd,
623 operation: "listMergeRequests",
624 cause: decoded.failure,
625 }),
626 );
627 }
628 const pageItems: GitLabMergeRequestListItem[] = [];
629 const pageRawIndexes: number[] = [];
630 for (const [index, item] of decoded.success.items.entries()) {
631 const rawIndex = decoded.success.rawIndexes[index]!;
632 if (rawIndex < skipOnFirstPage) continue;
633 pageItems.push(item);
634 pageRawIndexes.push(rawIndex);
635 }
636 const remaining = input.limit - input.collected.length;
637 const lastItemRawIndex = pageRawIndexes[remaining - 1];
638 if (lastItemRawIndex !== undefined) {
639 const consumed = lastItemRawIndex + 1 - skipOnFirstPage;
640 return Effect.succeed({
641 items: [...input.collected, ...pageItems.slice(0, remaining)],
642 truncated:
643 lastItemRawIndex + 1 < decoded.success.rawCount ||
644 decoded.success.rawCount === perPage,
645 cursorAdvance: input.cursorAdvance + consumed,
646 });
647 }
648 const collected = [...input.collected, ...pageItems];
649 const consumed = Math.max(0, decoded.success.rawCount - skipOnFirstPage);
650 // Counted before decoding, so a skipped malformed row cannot end paging early.
651 const exhausted = decoded.success.rawCount < perPage;
652 if (exhausted) {
653 return Effect.succeed({
654 items: collected,
655 truncated: false,
656 cursorAdvance: input.cursorAdvance + consumed,
657 });
658 }
659 if (input.page >= lastPage) {
660 return Effect.succeed({
661 items: collected,
662 truncated: true,
663 cursorAdvance: input.cursorAdvance + consumed,
664 });
665 }
666 return listPage({
667 ...input,
668 page: input.page + 1,
669 collected,
670 cursorAdvance: input.cursorAdvance + consumed,
671 });
672 }),
673 );
674 };
675
676 /**
677 * One page of a merge request's files, as a patch that stands on its own. GitLab pages
678 * `/diffs` by offset and has no cursor of its own, so the page number is the cursor; the
679 * caller carries on from it for as long as GitLab keeps handing full pages back.
680 *
681 * A named commit is read from the commit's own diff, which answers in the same shape and pages
682 * the same way.
683 */
684 const diffPage = (input: {
685 readonly cwd: string;
686 readonly repository: string;
687 readonly number: number;
688 readonly page: number;
689 readonly commit?: string | undefined;
690 }): Effect.Effect<GitLabMergeRequestDiffSlice, GitLabPullRequestCliError> =>
691 api({
692 cwd: input.cwd,
693 path: `projects/${projectPath(input.repository)}/${
694 input.commit === undefined
695 ? `merge_requests/${input.number}/diffs`
696 : `repository/commits/${input.commit}/diff`
697 }?${query([
698 ["per_page", String(MAX_PAGE_SIZE)],
699 ["page", String(input.page)],
700 ])}`,
701 maxOutputBytes: DIFF_MAX_OUTPUT_BYTES,
702 timeoutMs: DIFF_TIMEOUT_MS,
703 }).pipe(
704 Effect.flatMap((result) => {
705 // A byte-truncated response is a JSON prefix, so this page cannot be read at all.
706 // Answering with no cursor would call the diff whole while silently dropping this page
707 // and every one after it, so the read fails and says which page could not be had.
708 if (result.stdoutTruncated) {
709 return Effect.fail(
710 new GitLabMergeRequestReadError({
711 command: "glab",
712 cwd: input.cwd,
713 operation: "getMergeRequestDiff",
714 cause: new Error(
715 `Page ${input.page} of the merge request diff was too large to read.`,
716 ),
717 }),
718 );
719 }
720 const decoded = decodeMergeRequestDiffsJson(result.stdout.trim());
721 if (!Result.isSuccess(decoded)) {
722 return Effect.fail(
723 new GitLabMergeRequestReadError({
724 command: "glab",
725 cwd: input.cwd,
726 operation: "getMergeRequestDiff",
727 cause: decoded.failure,
728 }),
729 );
730 }
731 const patch = decoded.success.patch;
732 // Counted before decoding, so a page whose files all failed to decode still moves on
733 // rather than pointing the reader back at the page it just read.
734 const morePages = decoded.success.rawCount >= MAX_PAGE_SIZE;
735 return Effect.succeed({
736 // The slice ends on a newline, so a file GitLab gave a header and no hunks for does
737 // not run into the first line of the next slice.
738 patch: patch.length === 0 ? patch : patch.replace(/\n?$/, "\n"),
739 truncated: decoded.success.truncated,
740 nextCursor: morePages ? String(input.page + 1) : null,
741 });
742 }),
743 );
744
745 /**
746 * The conversation, a page at a time. GitLab pages by offset and reports no total, so a short
747 * page is the only thing that says it is done — and the raw count decides, not the kept one:
748 * the notes GitLab wrote itself are dropped, and a whole page of them still means there is
749 * more to read.
750 */
751 const notesPage = (input: {
752 readonly cwd: string;
753 readonly repository: string;
754 readonly number: number;
755 readonly page: number;
756 readonly collected: ReadonlyArray<PullRequestComment>;
757 }): Effect.Effect<
758 { readonly comments: ReadonlyArray<PullRequestComment>; readonly truncated: boolean },
759 GitLabPullRequestCliError
760 > =>
761 api({
762 cwd: input.cwd,
763 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes?${query(
764 [
765 ["per_page", String(MAX_PAGE_SIZE)],
766 ["page", String(input.page)],
767 ["order_by", "created_at"],
768 ["sort", "asc"],
769 ],
770 )}`,
771 }).pipe(
772 Effect.flatMap((result) => {
773 const decoded = decodeNotesJson(result.stdout.trim());
774 if (!Result.isSuccess(decoded)) {
775 return Effect.fail(
776 new GitLabMergeRequestReadError({
777 command: "glab",
778 cwd: input.cwd,
779 operation: "listNotes",
780 cause: decoded.failure,
781 }),
782 );
783 }
784 const collected = [...input.collected, ...decoded.success.comments];
785 if (decoded.success.rawCount < MAX_PAGE_SIZE) {
786 return Effect.succeed({ comments: collected, truncated: false });
787 }
788 return input.page >= CONVERSATION_PAGES
789 ? Effect.succeed({ comments: collected, truncated: true })
790 : notesPage({ ...input, page: input.page + 1, collected });
791 }),
792 );
793
794 /** The positioned discussions, walked the same way and stopped by the same bound. */
795 const discussionsPage = (input: {
796 readonly cwd: string;
797 readonly repository: string;
798 readonly number: number;
799 readonly page: number;
800 readonly collected: ReadonlyArray<PullRequestReviewThread>;
801 }): Effect.Effect<
802 { readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly truncated: boolean },
803 GitLabPullRequestCliError
804 > =>
805 api({
806 cwd: input.cwd,
807 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions?${query(
808 [
809 ["per_page", String(MAX_PAGE_SIZE)],
810 ["page", String(input.page)],
811 ],
812 )}`,
813 }).pipe(
814 Effect.flatMap((result) => {
815 const decoded = decodeDiscussionsJson(result.stdout.trim());
816 if (!Result.isSuccess(decoded)) {
817 return Effect.fail(
818 new GitLabMergeRequestReadError({
819 command: "glab",
820 cwd: input.cwd,
821 operation: "listDiscussions",
822 cause: decoded.failure,
823 }),
824 );
825 }
826 const collected = [...input.collected, ...decoded.success.threads];
827 // The raw count again: this endpoint returns the plain notes too, so a full page of
828 // those is not the end of the positioned ones.
829 if (decoded.success.rawCount < MAX_PAGE_SIZE) {
830 return Effect.succeed({ threads: collected, truncated: false });
831 }
832 return input.page >= CONVERSATION_PAGES
833 ? Effect.succeed({ threads: collected, truncated: true })
834 : discussionsPage({ ...input, page: input.page + 1, collected });
835 }),
836 );
837
838 /**
839 * The revisions a positioned comment is written against. GitLab resolves a comment's line
840 * against these three shas, so a review with line comments cannot be sent without them.
841 */
842 const getDiffRefs = (input: {
843 readonly cwd: string;
844 readonly repository: string;
845 readonly number: number;
846 }): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> =>
847 api({
848 cwd: input.cwd,
849 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`,
850 }).pipe(
851 Effect.flatMap((result): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> => {
852 const decoded = decodeDiffRefsJson(result.stdout.trim());
853 if (!Result.isSuccess(decoded)) {
854 return Effect.fail(
855 new GitLabMergeRequestReadError({
856 command: "glab",
857 cwd: input.cwd,
858 operation: "getDiffRefs",
859 cause: decoded.failure,
860 }),
861 );
862 }
863 // A merge request with no diff refs is a well-formed answer that cannot carry a
864 // positioned comment — a dead end, but not something that failed to be read.
865 return decoded.success === null
866 ? Effect.fail(
867 new GitLabDiffRefsUnavailableError({
868 command: "glab",
869 cwd: input.cwd,
870 number: input.number,
871 }),
872 )
873 : Effect.succeed(decoded.success);
874 }),
875 );
876
877 const getCommitDiffRefs = (input: {
878 readonly cwd: string;
879 readonly repository: string;
880 readonly commit: string;
881 readonly allowRoot: boolean;
882 }): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> =>
883 api({
884 cwd: input.cwd,
885 path: `projects/${projectPath(input.repository)}/repository/commits/${input.commit}`,
886 }).pipe(
887 Effect.flatMap((result): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> => {
888 const decoded = decodeCommitDiffRefsJson(result.stdout.trim());
889 if (!Result.isSuccess(decoded)) {
890 return Effect.fail(
891 new GitLabMergeRequestReadError({
892 command: "glab",
893 cwd: input.cwd,
894 operation: "getMergeRequestDiffFileContents",
895 cause: decoded.failure,
896 }),
897 );
898 }
899 return decoded.success === null
900 ? input.allowRoot
901 ? Effect.succeed({
902 baseSha: "",
903 headSha: input.commit,
904 startSha: "",
905 })
906 : Effect.fail(
907 new GitLabDiffCommitParentUnavailableError({
908 command: "glab",
909 cwd: input.cwd,
910 commit: input.commit,
911 }),
912 )
913 : Effect.succeed(decoded.success);
914 }),
915 );
916
917 /**
918 * The merge request itself, which several calls need for different parts of it: the detail for
919 * everything, and the reviewer paths for the ids GitLab writes a reviewer set with.
920 */
921 const mergeRequestDetail = (input: {
922 readonly cwd: string;
923 readonly repository: string;
924 readonly number: number;
925 }): Effect.Effect<GitLabMergeRequestDetail, GitLabPullRequestCliError> =>
926 api({
927 cwd: input.cwd,
928 // How far behind the target branch this one is comes only when asked for by name, and it
929 // is asked for here rather than on a second read because it is the same merge request.
930 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}?${query([
931 ["include_diverged_commits_count", "true"],
932 ])}`,
933 }).pipe(
934 Effect.flatMap((result) => {
935 const decoded = decodeMergeRequestDetailJson(result.stdout.trim());
936 return Result.isSuccess(decoded)
937 ? Effect.succeed(decoded.success)
938 : Effect.fail(
939 new GitLabMergeRequestReadError({
940 command: "glab",
941 cwd: input.cwd,
942 operation: "getMergeRequestDetail",
943 cause: decoded.failure,
944 }),
945 );
946 }),
947 );
948
949 /** The people with access to the project, one page deep. */
950 const projectUsers = (input: {
951 readonly cwd: string;
952 readonly repository: string;
953 }): Effect.Effect<GitLabProjectUsers, GitLabPullRequestCliError> =>
954 api({
955 cwd: input.cwd,
956 path: `projects/${projectPath(input.repository)}/users?${query([
957 ["per_page", String(MAX_PAGE_SIZE)],
958 ])}`,
959 }).pipe(
960 Effect.flatMap((result) => {
961 const decoded = decodeProjectUsersJson(result.stdout.trim());
962 return Result.isSuccess(decoded)
963 ? Effect.succeed(decoded.success)
964 : Effect.fail(
965 new GitLabMergeRequestReadError({
966 command: "glab",
967 cwd: input.cwd,
968 operation: "listReviewerCandidates",
969 cause: decoded.failure,
970 }),
971 );
972 }),
973 );
974
975 /** Where an award is written: a note of the merge request, or the merge request itself. */
976 const awardSubjectPath = (input: {
977 readonly repository: string;
978 readonly number: number;
979 readonly noteId?: string | undefined;
980 }) => {
981 const mergeRequest = `projects/${projectPath(input.repository)}/merge_requests/${input.number}`;
982 return input.noteId === undefined
983 ? `${mergeRequest}/award_emoji`
984 : `${mergeRequest}/notes/${encodeURIComponent(input.noteId)}/award_emoji`;
985 };
986
987 /**
988 * The awards on the merge request and its notes, a page of notes at a time. Bounded by the same
989 * count as the conversation itself: awards past the notes that were read belong to notes the
990 * page is not showing.
991 */
992 const awardsPage = (input: {
993 readonly cwd: string;
994 readonly repository: string;
995 readonly number: number;
996 readonly cursor: string | null;
997 readonly page: number;
998 readonly collected: {
999 readonly reactions: ReadonlyArray<PullRequestReaction>;
1000 readonly reactionsByNoteId: Map<string, ReadonlyArray<PullRequestReaction>>;
1001 } | null;
1002 }): Effect.Effect<
1003 {
1004 readonly reactions: ReadonlyArray<PullRequestReaction>;
1005 readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
1006 },
1007 GitLabPullRequestCliError
1008 > =>
1009 api({
1010 cwd: input.cwd,
1011 path: "graphql",
1012 method: "POST",
1013 stdin: JSON.stringify({
1014 query: AWARD_EMOJI_GRAPHQL_QUERY,
1015 variables: {
1016 fullPath: input.repository,
1017 iid: String(input.number),
1018 cursor: input.cursor,
1019 },
1020 }),
1021 }).pipe(
1022 Effect.flatMap((result) => {
1023 const decoded = decodeAwardEmojiJson(result.stdout.trim());
1024 if (!Result.isSuccess(decoded)) {
1025 return Effect.fail(
1026 new GitLabMergeRequestReadError({
1027 command: "glab",
1028 cwd: input.cwd,
1029 operation: "listReactions",
1030 cause: decoded.failure,
1031 }),
1032 );
1033 }
1034 const collected = input.collected ?? {
1035 reactions: decoded.success.reactions,
1036 reactionsByNoteId: new Map<string, ReadonlyArray<PullRequestReaction>>(),
1037 };
1038 for (const [id, reactions] of decoded.success.reactionsByNoteId)
1039 collected.reactionsByNoteId.set(id, reactions);
1040 return decoded.success.nextCursor === null || input.page >= CONVERSATION_PAGES
1041 ? Effect.succeed(collected)
1042 : awardsPage({
1043 ...input,
1044 cursor: decoded.success.nextCursor,
1045 page: input.page + 1,
1046 collected,
1047 });
1048 }),
1049 );
1050
1051 /**
1052 * GitLab charges the blobs query by how many paths it is handed, and its connection hands back
1053 * one page. A hundred at a time keeps each request inside both.
1054 */
1055 const BLOB_PATHS_PER_REQUEST = 100;
1056
1057 const blobsAt = (input: {
1058 readonly cwd: string;
1059 readonly repository: string;
1060 readonly ref: string;
1061 readonly paths: ReadonlyArray<string>;
1062 }): Effect.Effect<ReadonlyMap<string, string> | null, GitLabPullRequestCliError> =>
1063 api({
1064 cwd: input.cwd,
1065 path: "graphql",
1066 method: "POST",
1067 stdin: JSON.stringify({
1068 query: REPOSITORY_BLOBS_GRAPHQL_QUERY,
1069 variables: { fullPath: input.repository, ref: input.ref, paths: input.paths },
1070 }),
1071 }).pipe(
1072 Effect.flatMap(
1073 (result): Effect.Effect<ReadonlyMap<string, string> | null, GitLabPullRequestCliError> => {
1074 const decoded = decodeRepositoryBlobsJson(result.stdout.trim());
1075 return Result.isSuccess(decoded)
1076 ? Effect.succeed(decoded.success)
1077 : Effect.fail(
1078 new GitLabMergeRequestReadError({
1079 command: "glab",
1080 cwd: input.cwd,
1081 operation: "getFileRevisions",
1082 cause: decoded.failure,
1083 }),
1084 );
1085 },
1086 ),
1087 );
1088
1089 const fileRevisions = (input: {
1090 readonly cwd: string;
1091 readonly repository: string;
1092 readonly number: number;
1093 readonly paths: ReadonlyArray<string>;
1094 }): Effect.Effect<ReadonlyMap<string, string>, GitLabPullRequestCliError> =>
1095 input.paths.length === 0
1096 ? Effect.succeed(new Map())
1097 : getDiffRefs(input).pipe(
1098 Effect.flatMap((refs) => {
1099 const batches: Array<ReadonlyArray<string>> = [];
1100 for (let at = 0; at < input.paths.length; at += BLOB_PATHS_PER_REQUEST) {
1101 batches.push(input.paths.slice(at, at + BLOB_PATHS_PER_REQUEST));
1102 }
1103 return Effect.forEach(
1104 batches,
1105 (paths) =>
1106 blobsAt({ ...input, ref: refs.headSha, paths }).pipe(
1107 Effect.map((page) => ({ paths, page })),
1108 ),
1109 { concurrency: 2 },
1110 ).pipe(
1111 Effect.map((pages) => {
1112 const revisions = new Map<string, string>();
1113 for (const { paths, page } of pages) {
1114 // A batch GitLab did not answer says nothing about its paths, so they are left
1115 // out and the caller reads them as versions it could not learn, which leaves
1116 // the marks on them alone. Filling them in as removed would report every file
1117 // a reader has cleared as changed over a project the token cannot see.
1118 if (page === null) continue;
1119 for (const [path, oid] of page) revisions.set(path, oid);
1120 // Within a batch that was answered, every path was looked for at the head, so
1121 // one that is not there is one the merge request removed. Said as the empty
1122 // revision, which is an answer the caller can compare against and keep.
1123 for (const path of paths) {
1124 if (!revisions.has(path)) revisions.set(path, "");
1125 }
1126 }
1127 return revisions as ReadonlyMap<string, string>;
1128 }),
1129 );
1130 }),
1131 );
1132
1133 const viewerUsername = (input: { readonly cwd: string }) =>
1134 api({ cwd: input.cwd, path: "user" }).pipe(
1135 Effect.flatMap((result): Effect.Effect<string, GitLabPullRequestCliError> => {
1136 const decoded = decodeViewerJson(result.stdout.trim());
1137 if (!Result.isSuccess(decoded)) {
1138 return Effect.fail(
1139 new GitLabMergeRequestReadError({
1140 command: "glab",
1141 cwd: input.cwd,
1142 operation: "getViewerUsername",
1143 cause: decoded.failure,
1144 }),
1145 );
1146 }
1147 return decoded.success === null
1148 ? Effect.fail(new GitLabViewerUnavailableError({ command: "glab", cwd: input.cwd }))
1149 : Effect.succeed(decoded.success);
1150 }),
1151 );
1152
1153 return GitLabPullRequestCli.of({
1154 getViewerUsername: viewerUsername,
1155
1156 listMergeRequests: (input) => {
1157 const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE);
1158 const page = Math.floor((input.cursor?.delivered ?? 0) / perPage) + 1;
1159 return listPage({ ...input, page, collected: [], cursorAdvance: 0 });
1160 },
1161
1162 getMergeRequestDetail: mergeRequestDetail,
1163
1164 listNotes: (input) => notesPage({ ...input, page: 1, collected: [] }),
1165
1166 listReactions: (input) => awardsPage({ ...input, cursor: null, page: 1, collected: null }),
1167
1168 getFileRevisions: fileRevisions,
1169
1170 setReaction: (input) =>
1171 Effect.gen(function* () {
1172 const subject = awardSubjectPath(input);
1173 if (input.reacted) {
1174 yield* api({
1175 cwd: input.cwd,
1176 path: `${subject}?${query([["name", gitLabAwardName(input.content)]])}`,
1177 method: "POST",
1178 });
1179 return;
1180 }
1181 // GitLab deletes an award by its id and takes no emoji name there, so the reader's own
1182 // award of that name is looked up first. Nothing to delete is success: the reaction the
1183 // caller asked to take back is already gone.
1184 const viewer = yield* viewerUsername({ cwd: input.cwd });
1185 const listed = yield* api({ cwd: input.cwd, path: subject });
1186 const own = decodeOwnAwardIdJson(listed.stdout.trim(), {
1187 content: input.content,
1188 viewer,
1189 });
1190 if (!Result.isSuccess(own)) {
1191 return yield* new GitLabMergeRequestReadError({
1192 command: "glab",
1193 cwd: input.cwd,
1194 operation: "setReaction",
1195 cause: own.failure,
1196 });
1197 }
1198 if (own.success === null) return;
1199 yield* api({
1200 cwd: input.cwd,
1201 path: `${subject}/${own.success}`,
1202 method: "DELETE",
1203 });
1204 }),
1205
1206 listCommits: (input) =>
1207 api({
1208 cwd: input.cwd,
1209 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/commits?${query(
1210 [
1211 ["per_page", String(COMMIT_PAGE_SIZE)],
1212 ["with_stats", "true"],
1213 ],
1214 )}`,
1215 }).pipe(
1216 Effect.flatMap((result) => {
1217 const decoded = decodeCommitsJson(result.stdout.trim());
1218 return Result.isSuccess(decoded)
1219 ? Effect.succeed(decoded.success)
1220 : Effect.fail(
1221 new GitLabMergeRequestReadError({
1222 command: "glab",
1223 cwd: input.cwd,
1224 operation: "listCommits",
1225 cause: decoded.failure,
1226 }),
1227 );
1228 }),
1229 ),
1230
1231 getMergeRequestDiff: (input) => {
1232 if (input.commit !== undefined && !isCommitSha(input.commit)) {
1233 return Effect.fail(new GitLabDiffCommitError({ command: "glab", cwd: input.cwd }));
1234 }
1235 const target = {
1236 cwd: input.cwd,
1237 repository: input.repository,
1238 number: input.number,
1239 ...(input.commit === undefined ? {} : { commit: input.commit }),
1240 };
1241 if (input.cursor === undefined) {
1242 return diffPage({ ...target, page: 1 });
1243 }
1244 const page = diffCursorPage(input.cursor);
1245 return page === null
1246 ? Effect.fail(new GitLabDiffCursorError({ command: "glab", cwd: input.cwd }))
1247 : diffPage({ ...target, page });
1248 },
1249
1250 getMergeRequestDiffFileContents: (input) =>
1251 Effect.gen(function* () {
1252 if (input.commit !== undefined && !isCommitSha(input.commit)) {
1253 return yield* new GitLabDiffCommitError({ command: "glab", cwd: input.cwd });
1254 }
1255 const refs = yield* input.commit === undefined
1256 ? getDiffRefs(input)
1257 : getCommitDiffRefs({
1258 cwd: input.cwd,
1259 repository: input.repository,
1260 commit: input.commit,
1261 allowRoot: input.changeType === "new",
1262 });
1263
1264 const readFile = (revision: string, filePath: string) =>
1265 api({
1266 cwd: input.cwd,
1267 path: `projects/${projectPath(input.repository)}/repository/files/${encodeURIComponent(
1268 filePath,
1269 )}/raw?ref=${encodeURIComponent(revision)}`,
1270 maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES,
1271 timeoutMs: DIFF_TIMEOUT_MS,
1272 }).pipe(
1273 Effect.flatMap((result) =>
1274 result.stdoutTruncated ||
1275 result.stdout.includes("\0") ||
1276 result.stdoutInvalidUtf8 === true
1277 ? Effect.fail(
1278 new GitLabDiffFileContentsUnavailableError({
1279 command: "glab",
1280 cwd: input.cwd,
1281 path: filePath,
1282 reason: result.stdoutTruncated ? "oversized" : "binary",
1283 }),
1284 )
1285 : Effect.succeed(result.stdout),
1286 ),
1287 );
1288
1289 const [oldContents, newContents] = yield* Effect.all(
1290 [
1291 input.changeType === "new" ? Effect.succeed("") : readFile(refs.baseSha, input.oldPath),
1292 input.changeType === "deleted"
1293 ? Effect.succeed("")
1294 : readFile(refs.headSha, input.newPath),
1295 ],
1296 { concurrency: 2 },
1297 );
1298 return { oldContents, newContents };
1299 }),
1300
1301 getProjectMergeCapabilities: (input) =>
1302 api({
1303 cwd: input.cwd,
1304 path: `projects/${projectPath(input.repository)}?license=false`,
1305 }).pipe(
1306 Effect.flatMap((result) => {
1307 const decoded = decodeProjectMergeCapabilitiesJson(result.stdout.trim());
1308 return Result.isSuccess(decoded)
1309 ? Effect.succeed(decoded.success)
1310 : Effect.fail(
1311 new GitLabMergeRequestReadError({
1312 command: "glab",
1313 cwd: input.cwd,
1314 operation: "getProjectMergeCapabilities",
1315 cause: decoded.failure,
1316 }),
1317 );
1318 }),
1319 ),
1320
1321 listReviewerCandidates: (input) =>
1322 Effect.all([mergeRequestDetail(input), projectUsers(input)], { concurrency: 2 }).pipe(
1323 Effect.map(([mergeRequest, users]) => {
1324 const author = mergeRequest.author?.login;
1325 const requested = new Set(mergeRequest.reviewRequestLogins);
1326 return {
1327 // The author is dropped rather than shown unusable: GitLab refuses to make the person
1328 // who opened a merge request its reviewer.
1329 candidates: users.candidates.flatMap((candidate) =>
1330 candidate.login === author
1331 ? []
1332 : [{ ...candidate, isRequested: requested.has(candidate.login) }],
1333 ),
1334 truncated: users.rawCount >= MAX_PAGE_SIZE,
1335 };
1336 }),
1337 ),
1338
1339 setReviewerRequest: (input) =>
1340 mergeRequestDetail(input).pipe(
1341 Effect.flatMap((mergeRequest) => {
1342 // GitLab has no endpoint that adds or removes one reviewer: `reviewer_ids` replaces the
1343 // whole set, so the set that is already there is read first and the change applied to
1344 // it. Asking again for somebody already on it writes the same set back, which is how
1345 // GitLab re-requests a review.
1346 const ids = new Set(mergeRequest.reviewerIds);
1347 for (const reviewer of input.reviewers) {
1348 const id = Number(reviewer.id);
1349 // A candidate GitLab did not name is not an id it would accept, and sending it would
1350 // rewrite the reviewer set around a number nobody chose.
1351 if (!Number.isSafeInteger(id) || id <= 0) continue;
1352 if (input.requested) ids.add(id);
1353 else ids.delete(id);
1354 }
1355 return api({
1356 cwd: input.cwd,
1357 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`,
1358 method: "PUT",
1359 stdin: JSON.stringify({ reviewer_ids: [...ids] }),
1360 });
1361 }),
1362 Effect.asVoid,
1363 ),
1364
1365 runMergeRequestAction: (input) => {
1366 // `glab mr merge` arms auto-merge and never disarms it, so the one direction the CLI has
1367 // no flag for is asked of GitLab directly through the same `api` passthrough the rest of
1368 // this module writes with.
1369 if (input.action === "disable-auto-merge") {
1370 return api({
1371 cwd: input.cwd,
1372 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/cancel_merge_when_pipeline_succeeds`,
1373 method: "POST",
1374 }).pipe(Effect.asVoid);
1375 }
1376 const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod);
1377 return gitlab
1378 .execute({
1379 cwd: input.cwd,
1380 args: ["mr", subcommand!, String(input.number), "--repo", input.repository, ...flags],
1381 })
1382 .pipe(Effect.asVoid);
1383 },
1384
1385 updateMergeRequest: (input) =>
1386 api({
1387 cwd: input.cwd,
1388 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`,
1389 method: "PUT",
1390 // Only the fields the caller asked to change: GitLab leaves out what it is not sent, and
1391 // clears what it is sent empty — so a title corrected on its own must carry no
1392 // description at all.
1393 stdin: JSON.stringify({
1394 ...(input.title === undefined ? {} : { title: input.title }),
1395 ...(input.description === undefined ? {} : { description: input.description }),
1396 }),
1397 }).pipe(Effect.asVoid),
1398
1399 commentOnMergeRequest: (input) =>
1400 api({
1401 cwd: input.cwd,
1402 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes`,
1403 method: "POST",
1404 // A JSON body rather than a `--raw-field`: glab coerces a field that reads as a
1405 // literal `true` or a number, and a comment body is text either way.
1406 stdin: JSON.stringify({ body: input.body }),
1407 }).pipe(Effect.asVoid),
1408
1409 updateNote: (input) =>
1410 api({
1411 cwd: input.cwd,
1412 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes/${encodeURIComponent(
1413 input.noteId,
1414 )}`,
1415 method: "PUT",
1416 stdin: JSON.stringify({ body: input.body }),
1417 }).pipe(Effect.asVoid),
1418
1419 listDiscussions: (input) => discussionsPage({ ...input, page: 1, collected: [] }),
1420
1421 submitReview: (input) =>
1422 Effect.gen(function* () {
1423 const project = projectPath(input.repository);
1424 const mergeRequest = `projects/${project}/merge_requests/${input.number}`;
1425 // GitLab has no pending review to attach comments to, so a review is replayed as the
1426 // requests it is made of: the line comments, then the summary, then the verdict. A
1427 // failure part-way therefore leaves what was already posted in place, which is why
1428 // the verdict goes last — a half-sent review is never an approval.
1429 if (input.comments.length > 0) {
1430 const refs = yield* getDiffRefs(input);
1431 yield* Effect.forEach(
1432 input.comments,
1433 (comment) =>
1434 api({
1435 cwd: input.cwd,
1436 path: `${mergeRequest}/discussions`,
1437 method: "POST",
1438 stdin: JSON.stringify({
1439 body: comment.body,
1440 position: {
1441 base_sha: refs.baseSha,
1442 head_sha: refs.headSha,
1443 start_sha: refs.startSha,
1444 position_type: "text",
1445 // Both paths are sent because GitLab resolves a position against both
1446 // sides of the diff. They differ only for a renamed file, which is why the
1447 // draft carries the name the file had before the change.
1448 old_path: comment.oldPath ?? comment.path,
1449 new_path: comment.path,
1450 ...gitLabReviewPositionLines(comment.position),
1451 },
1452 }),
1453 }),
1454 { discard: true },
1455 );
1456 }
1457 if (input.body.trim().length > 0) {
1458 yield* api({
1459 cwd: input.cwd,
1460 path: `${mergeRequest}/notes`,
1461 method: "POST",
1462 // A JSON body rather than a `--raw-field`, for the reason the plain comment gives:
1463 // glab coerces a field that reads as a literal `true` or a number.
1464 // @effect-diagnostics-next-line preferSchemaOverJson:off
1465 stdin: JSON.stringify({ body: input.body }),
1466 });
1467 }
1468 if (input.verdict === "approve") {
1469 yield* api({ cwd: input.cwd, path: `${mergeRequest}/approve`, method: "POST" });
1470 }
1471 }),
1472
1473 replyToDiscussion: (input) =>
1474 api({
1475 cwd: input.cwd,
1476 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent(
1477 input.discussionId,
1478 )}/notes`,
1479 method: "POST",
1480 stdin: JSON.stringify({ body: input.body }),
1481 }).pipe(Effect.asVoid),
1482
1483 setDiscussionResolution: (input) =>
1484 api({
1485 cwd: input.cwd,
1486 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent(
1487 input.discussionId,
1488 )}`,
1489 method: "PUT",
1490 stdin: JSON.stringify({ resolved: input.resolved }),
1491 }).pipe(Effect.asVoid),
1492 });
1493});
1494
1495export const layer = Layer.effect(GitLabPullRequestCli, make);
apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts deleted-214
......@@ -1,214 +0,0 @@
1import { assert, describe, expect, it, vi } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
6import { gitLabViewerPermissions, make } from "./GitLabPullRequestProvider.ts";
7
8describe("gitLabViewerPermissions", () => {
9 it("offers everything to a viewer GitLab says can merge", () => {
10 expect(gitLabViewerPermissions({ viewerCanMerge: true })).toEqual({
11 // Arming a merge for later and taking the arming back answer to the same `can_merge`.
12 actions: [
13 "merge",
14 "ready",
15 "draft",
16 "close",
17 "reopen",
18 "update-branch",
19 "enable-auto-merge",
20 "disable-auto-merge",
21 ],
22 comment: true,
23 resolve: true,
24 verdicts: ["comment", "approve"],
25 // GitLab says nothing about who may set a reviewer, and an unreported permission is granted.
26 requestReviewers: true,
27 // Rebase and nothing else: GitLab cannot merge a target branch into a source branch, so
28 // offering the choice would be offering something no request could carry out.
29 updateMethods: ["rebase"],
30 });
31 });
32
33 it("keeps merge, now and later, from a viewer GitLab says cannot", () => {
34 // `user.can_merge` already accounts for the role, the approval rules and a protected target
35 // branch, so it is the one answer here that does not have to be inferred.
36 expect(gitLabViewerPermissions({ viewerCanMerge: false })).toEqual({
37 actions: ["ready", "draft", "close", "reopen"],
38 comment: true,
39 resolve: true,
40 verdicts: ["comment", "approve"],
41 requestReviewers: true,
42 });
43 });
44
45 it("names no way of updating a branch it will not let this viewer update", () => {
46 // The action and the strategy behind it go together: a button offered with nothing to press
47 // it with, or a strategy left standing next to a withheld button, is a half-refusal.
48 expect(gitLabViewerPermissions({ viewerCanMerge: false }).updateMethods).toBeUndefined();
49 });
50
51 it("treats an author with read access as any other reader, which is all GitLab says", () => {
52 // Its REST API names no relationship between the viewer and the merge request beyond
53 // `can_merge`, so the four an author keeps stay offered to everyone rather than being taken
54 // from the one person entitled to them.
55 expect(gitLabViewerPermissions({ viewerCanMerge: false }).actions).toEqual([
56 "ready",
57 "draft",
58 "close",
59 "reopen",
60 ]);
61 });
62});
63
64describe("getChangeRequest base freshness", () => {
65 const detail = {
66 number: 7,
67 title: "Merge request 7",
68 url: "https://gitlab.com/acme/web/-/merge_requests/7",
69 author: null,
70 headBranch: "feat/page",
71 baseBranch: "main",
72 state: "open" as const,
73 isDraft: false,
74 mergeability: "mergeable" as const,
75 additions: 0,
76 deletions: 0,
77 createdAt: "2026-07-01T00:00:00Z",
78 updatedAt: "2026-07-02T00:00:00Z",
79 reviewRequestLogins: [],
80 labels: [],
81 body: "",
82 changedFiles: 1,
83 mergedAt: null,
84 closedAt: null,
85 reviewers: [],
86 checks: [],
87 viewerCanMerge: true,
88 reviewerIds: [],
89 };
90
91 it.effect("reads checks without fetching project settings", () =>
92 Effect.gen(function* () {
93 const provider = yield* make.pipe(
94 Effect.provide(
95 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({
96 getMergeRequestDetail: () => Effect.succeed(detail),
97 }),
98 ),
99 );
100 const read = provider.getChangeRequestChecks;
101 if (read === undefined) return yield* Effect.die("checks read missing");
102 expect(
103 yield* read({ cwd: "/w", repository: "group/subgroup/web", host: "gitlab.com", number: 7 }),
104 ).toEqual({ state: "open", checks: [] });
105 }),
106 );
107
108 const readWith = (divergence: { readonly divergedCommits?: number }) =>
109 Effect.gen(function* () {
110 const provider = yield* make;
111 return yield* provider.getChangeRequest({
112 cwd: "/w",
113 repository: "acme/web",
114 host: "gitlab.com",
115 number: 7,
116 });
117 }).pipe(
118 Effect.provide(
119 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({
120 getMergeRequestDetail: () => Effect.succeed({ ...detail, ...divergence }),
121 getProjectMergeCapabilities: () =>
122 Effect.succeed({ merge: true, squash: true, rebase: true }),
123 }),
124 ),
125 );
126
127 it.effect("reads a counted divergence as a branch that has fallen behind", () =>
128 Effect.gen(function* () {
129 const changeRequest = yield* readWith({ divergedCommits: 3 });
130
131 expect(changeRequest.baseComparison).toBe("behind");
132 expect(changeRequest.behindBy).toBe(3);
133 }),
134 );
135
136 it.effect("reads a divergence of none as a branch that is current", () =>
137 Effect.gen(function* () {
138 const changeRequest = yield* readWith({ divergedCommits: 0 });
139
140 expect(changeRequest.baseComparison).toBe("up-to-date");
141 expect(changeRequest.behindBy).toBe(0);
142 }),
143 );
144
145 it.effect("says nothing at all where GitLab counted nothing", () =>
146 Effect.gen(function* () {
147 // An install too old to answer has to leave the page silent rather than let it claim the
148 // branch is current, which is the one wrong thing this banner could say.
149 const changeRequest = yield* readWith({});
150
151 expect(changeRequest.baseComparison).toBe("unknown");
152 expect(changeRequest.behindBy).toBeUndefined();
153 }),
154 );
155});
156
157describe("rewriting what has already been said", () => {
158 const updateMergeRequest = vi.fn(() => Effect.void);
159 const updateNote = vi.fn(() => Effect.void);
160
161 const providerWith = make.pipe(
162 Effect.provide(
163 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ updateMergeRequest, updateNote }),
164 ),
165 );
166
167 it.effect("sends only the half of the merge request the reader rewrote", () =>
168 Effect.gen(function* () {
169 const provider = yield* providerWith;
170 assert.isDefined(provider.updateChangeRequest);
171
172 yield* provider.updateChangeRequest({
173 cwd: "/w",
174 repository: "acme/web",
175 host: "gitlab.com",
176 number: 7,
177 body: "What this changes.",
178 });
179
180 // GitLab calls it the description, and the title stays out of the request entirely.
181 expect(updateMergeRequest).toHaveBeenCalledWith({
182 cwd: "/w",
183 repository: "acme/web",
184 number: 7,
185 description: "What this changes.",
186 });
187 }),
188 );
189
190 it.effect("rewrites a positioned comment through the same note as any other", () =>
191 Effect.gen(function* () {
192 const provider = yield* providerWith;
193 assert.isDefined(provider.updateComment);
194
195 yield* provider.updateComment({
196 cwd: "/w",
197 repository: "acme/web",
198 host: "gitlab.com",
199 number: 7,
200 commentId: "42",
201 kind: "review-comment",
202 body: "Reworded.",
203 });
204
205 expect(updateNote).toHaveBeenCalledWith({
206 cwd: "/w",
207 repository: "acme/web",
208 number: 7,
209 noteId: "42",
210 body: "Reworded.",
211 });
212 }),
213 );
214});
apps/server/src/pullRequest/GitLabPullRequestProvider.ts deleted-339
......@@ -1,339 +0,0 @@
1import * as Effect from "effect/Effect";
2import type {
3 PullRequestCapabilities,
4 PullRequestReaction,
5 PullRequestViewerPermissions,
6} from "@t3tools/contracts";
7
8import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
9import {
10 PullRequestProviderError,
11 type PullRequestProviderFailure,
12 type ProviderChangeRequestActivity,
13 type ProviderChangeRequestDetail,
14 type PullRequestProviderApi,
15} from "@t3tools/source-control-core/server/PullRequestProvider";
16
17const CAPABILITIES: PullRequestCapabilities = {
18 diff: true,
19 comment: true,
20 actions: [
21 "merge",
22 "ready",
23 "draft",
24 "close",
25 "reopen",
26 "update-branch",
27 "enable-auto-merge",
28 "disable-auto-merge",
29 ],
30 // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it.
31 mergeMethods: ["merge", "squash", "rebase"],
32 // Rebase alone: GitLab moves a stale branch onto its target by replaying it, and has nothing
33 // that merges the target back in the way GitHub's update button can. Declaring only what it
34 // does is what lets a request to merge the target in be refused instead of quietly rebasing.
35 updateMethods: ["rebase"],
36 search: true,
37 reactions: true,
38 // GitLab keeps a reader's viewed files in one browser's local storage, where nothing outside
39 // that browser can read or write them. So the marks made here are this environment's own: they
40 // follow the reader between the clients connected to it, but they are not the ones gitlab.com
41 // shows, and the surface says so rather than implying a review can be carried on from there.
42 viewedFiles: "environment",
43 review: {
44 inlineComment: true,
45 reply: true,
46 resolve: true,
47 // No "changes requested": GitLab has approval and unresolved discussions, and nothing that
48 // says a merge request has been reviewed and rejected.
49 verdicts: ["comment", "approve"],
50 },
51 reviewers: { request: true, listCandidates: true },
52 edit: { changeRequest: true, comment: true },
53};
54
55/**
56 * The actions `user.can_merge` answers for. Rebasing writes to the source branch rather than to
57 * the target, so it is not literally the same permission — but GitLab reports nothing narrower,
58 * and someone it will not let land this change has no business rewriting its branch either.
59 */
60const MERGE_ACTIONS: ReadonlySet<string> = new Set([
61 "merge",
62 "update-branch",
63 "enable-auto-merge",
64 "disable-auto-merge",
65]);
66
67/**
68 * What the signed-in account may do here. GitLab answers exactly one of these questions per
69 * viewer, on the merge request itself: `user.can_merge`, which is why merging is the only thing
70 * narrowed.
71 *
72 * The rest stay granted. GitLab's REST API reports the viewer's role on the project but never
73 * whether they opened this merge request — and its author may close it, reopen it and move it in
74 * and out of draft whatever their role, just as the author of a note may resolve the discussion
75 * it started. Withholding those controls from the one person entitled to them is the worse of the
76 * two mistakes, so they are offered and GitLab explains any refusal itself.
77 *
78 * Asking for a review is granted for the same reason: GitLab takes a reviewer set from the author
79 * and from anyone with the Developer role, and states neither of those two facts here.
80 */
81export function gitLabViewerPermissions(input: {
82 readonly viewerCanMerge: boolean;
83}): PullRequestViewerPermissions {
84 return {
85 // Arming the merge and taking the arming back are the merge, deferred, so they answer to
86 // the same `can_merge` the merge itself does.
87 actions: CAPABILITIES.actions.filter(
88 (action) => !MERGE_ACTIONS.has(action) || input.viewerCanMerge,
89 ),
90 comment: true,
91 resolve: true,
92 verdicts: CAPABILITIES.review.verdicts,
93 requestReviewers: true,
94 ...(input.viewerCanMerge ? { updateMethods: CAPABILITIES.updateMethods } : {}),
95 };
96}
97
98/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */
99export function gitLabProviderFailure(
100 error: GitLabPullRequestCli.GitLabPullRequestCliError,
101): PullRequestProviderFailure {
102 if (error._tag === "GitLabCliUnavailableError") return { reason: "missing-tool" };
103 if (error._tag === "GitLabCliAuthenticationError") return { reason: "unauthenticated" };
104 if (error._tag === "GitLabCliRateLimitError") return { reason: "rate-limited" };
105 if (error._tag === "GitLabMergeRequestNotFoundError") return { reason: "not-found" };
106 return { reason: "failed" };
107}
108
109export const make = Effect.gen(function* () {
110 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
111
112 const fail = (operation: string) => (error: GitLabPullRequestCli.GitLabPullRequestCliError) =>
113 new PullRequestProviderError({
114 provider: "gitlab",
115 operation,
116 ...gitLabProviderFailure(error),
117 detail: error.detail,
118 cause: error,
119 });
120
121 const provider: PullRequestProviderApi = {
122 kind: "gitlab",
123 capabilities: CAPABILITIES,
124
125 getViewer: (input) =>
126 cli.getViewerUsername({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))),
127
128 listChangeRequests: (input) =>
129 cli
130 .listMergeRequests({
131 cwd: input.cwd,
132 repository: input.repository,
133 state: input.state,
134 involvement: input.involvement,
135 viewer: input.viewer,
136 limit: input.limit,
137 query: input.query,
138 cursor: input.cursor,
139 })
140 .pipe(
141 Effect.mapError(fail("listChangeRequests")),
142 // GitLab is asked for its merge requests by update, newest first, whether or not it is
143 // being carried on from — so every page it answers is one a cursor can continue.
144 Effect.map((batch) => ({ ...batch, continues: true })),
145 ),
146
147 getChangeRequestChecks: (input) =>
148 cli.getMergeRequestDetail(input).pipe(
149 Effect.map(({ state, checks }) => ({ state, checks })),
150 Effect.mapError(fail("getChangeRequestChecks")),
151 ),
152
153 getChangeRequest: (input) =>
154 Effect.all(
155 [
156 cli.getMergeRequestDetail(input),
157 cli.getProjectMergeCapabilities({ cwd: input.cwd, repository: input.repository }),
158 ],
159 { concurrency: 2 },
160 ).pipe(
161 Effect.mapError(fail("getChangeRequest")),
162 Effect.map(([mergeRequest, mergeCapabilities]): ProviderChangeRequestDetail => ({
163 ...mergeRequest,
164 mergeCapabilities,
165 viewerPermissions: gitLabViewerPermissions(mergeRequest),
166 // A GitLab too old to count the divergence says nothing here rather than "up to
167 // date": the banner is worth missing, and a wrong all-clear is not worth showing.
168 baseComparison:
169 mergeRequest.divergedCommits === undefined
170 ? "unknown"
171 : mergeRequest.divergedCommits > 0
172 ? "behind"
173 : "up-to-date",
174 ...(mergeRequest.divergedCommits === undefined
175 ? {}
176 : { behindBy: mergeRequest.divergedCommits }),
177 })),
178 ),
179
180 getChangeRequestActivity: (input) =>
181 Effect.all(
182 [
183 cli
184 .listNotes(input)
185 .pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: true }))),
186 cli.listCommits(input).pipe(Effect.orElseSucceed(() => [])),
187 cli
188 .listDiscussions(input)
189 .pipe(Effect.orElseSucceed(() => ({ threads: [], truncated: true }))),
190 // The notes endpoint carries no award of any kind, so they are read alongside it. A
191 // failed read costs the conversation its reactions rather than its words.
192 cli.listReactions(input).pipe(
193 Effect.orElseSucceed(() => ({
194 reactions: [] as ReadonlyArray<PullRequestReaction>,
195 reactionsByNoteId: new Map<string, ReadonlyArray<PullRequestReaction>>(),
196 })),
197 ),
198 ],
199 { concurrency: 4 },
200 ).pipe(
201 Effect.mapError(fail("getChangeRequestActivity")),
202 Effect.map(([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({
203 reactions: awards.reactions,
204 comments: notes.comments.map((comment) => ({
205 ...comment,
206 reactions: awards.reactionsByNoteId.get(comment.id) ?? [],
207 })),
208 // GitLab reports no count of its own, so the walk's own total is the host's: the
209 // notes endpoint carries every comment on the merge request, including the ones
210 // written under a discussion, and it is read until GitLab runs out.
211 commentCount: notes.comments.length,
212 commentsTruncated: notes.truncated || discussions.truncated,
213 reviewThreads: discussions.threads.map((thread) => ({
214 ...thread,
215 comments: thread.comments.map((comment) => ({
216 ...comment,
217 reactions: awards.reactionsByNoteId.get(comment.id) ?? [],
218 })),
219 })),
220 commits,
221 })),
222 ),
223
224 // The same read the detail takes it from, on its own: `user.can_merge` lives on the merge
225 // request, so there is no cheaper thing to ask GitLab.
226 getViewerPermissions: (input) =>
227 cli
228 .getMergeRequestDetail(input)
229 .pipe(Effect.mapError(fail("getViewerPermissions")), Effect.map(gitLabViewerPermissions)),
230
231 getDiff: (input) => cli.getMergeRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))),
232
233 // What each marked file is at the head, which is what tells a mark that still stands from one
234 // the branch has moved past. GitLab's own local-storage marks are keyed on the blob id too,
235 // so this stales at the same moment its web UI would.
236 getFileRevisions: (input) =>
237 cli.getFileRevisions(input).pipe(
238 Effect.mapError(fail("getFileRevisions")),
239 Effect.map((revisions) => ({ revisions })),
240 ),
241
242 // Users only: GitLab requests a review of a person, and the groups that can stand in for one
243 // appear in approval rules rather than in a merge request's reviewers.
244 listReviewerCandidates: (input) =>
245 cli
246 .listReviewerCandidates({
247 cwd: input.cwd,
248 repository: input.repository,
249 number: input.number,
250 })
251 .pipe(Effect.mapError(fail("listReviewerCandidates"))),
252
253 setReviewerRequest: (input) =>
254 cli
255 .setReviewerRequest({
256 cwd: input.cwd,
257 repository: input.repository,
258 number: input.number,
259 reviewers: input.reviewers,
260 requested: input.requested,
261 })
262 .pipe(Effect.mapError(fail("setReviewerRequest"))),
263
264 runAction: (input) =>
265 cli
266 .runMergeRequestAction({
267 cwd: input.cwd,
268 repository: input.repository,
269 number: input.number,
270 action: input.action,
271 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
272 })
273 .pipe(Effect.mapError(fail("runAction"))),
274
275 updateChangeRequest: (input) =>
276 cli
277 .updateMergeRequest({
278 cwd: input.cwd,
279 repository: input.repository,
280 number: input.number,
281 ...(input.title === undefined ? {} : { title: input.title }),
282 ...(input.body === undefined ? {} : { description: input.body }),
283 })
284 .pipe(Effect.mapError(fail("updateChangeRequest"))),
285
286 comment: (input) => cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))),
287
288 // The kind is not read: every comment this provider hands out, positioned or not, carries a
289 // plain REST note id, and one endpoint rewrites both.
290 updateComment: (input) =>
291 cli
292 .updateNote({
293 cwd: input.cwd,
294 repository: input.repository,
295 number: input.number,
296 noteId: input.commentId,
297 body: input.body,
298 })
299 .pipe(Effect.mapError(fail("updateComment"))),
300
301 submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))),
302
303 replyToThread: (input) =>
304 cli
305 .replyToDiscussion({
306 cwd: input.cwd,
307 repository: input.repository,
308 number: input.number,
309 discussionId: input.threadId,
310 body: input.body,
311 })
312 .pipe(Effect.mapError(fail("replyToThread"))),
313
314 setReaction: (input) =>
315 cli
316 .setReaction({
317 cwd: input.cwd,
318 repository: input.repository,
319 number: input.number,
320 ...(input.subjectId === undefined ? {} : { noteId: input.subjectId }),
321 content: input.content,
322 reacted: input.reacted,
323 })
324 .pipe(Effect.mapError(fail("setReaction"))),
325
326 setThreadResolution: (input) =>
327 cli
328 .setDiscussionResolution({
329 cwd: input.cwd,
330 repository: input.repository,
331 number: input.number,
332 discussionId: input.threadId,
333 resolved: input.resolved,
334 })
335 .pipe(Effect.mapError(fail("setThreadResolution"))),
336 };
337
338 return provider;
339});
apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts+2-2
......@@ -4,11 +4,11 @@ import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/Azu
44import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
55import * as GitHubApi from "../sourceControl/GitHubApi.ts";
66import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
7import * as GitLabCli from "../sourceControl/GitLabCli.ts";
7import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
88import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider";
99import { bitbucketProviderFailure } from "./BitbucketPullRequestProvider.ts";
1010import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts";
11import { gitLabProviderFailure } from "./GitLabPullRequestProvider.ts";
11import { gitLabProviderFailure } from "@t3tools/source-control-gitlab/server/GitLabPullRequestProvider";
1212
1313const cause = new Error("redacted provider failure");
1414
apps/server/src/pullRequest/PullRequestProviderRegistry.ts-5
......@@ -5,13 +5,10 @@ import type { SourceControlProviderKind } from "@t3tools/contracts";
55
66import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
77import * as GitHubApi from "../sourceControl/GitHubApi.ts";
8import * as GitLabCli from "../sourceControl/GitLabCli.ts";
98import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
109import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts";
1110import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
1211import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";
13import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
14import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts";
1512import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts";
1613import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider";
1714
......@@ -44,7 +41,6 @@ export function fromProviders(
4441export const make = Effect.gen(function* () {
4542 const providers = yield* Effect.all([
4643 GitHubPullRequestProvider.make,
47 GitLabPullRequestProvider.make,
4844 BitbucketPullRequestProvider.make,
4945 ]);
5046 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>
......@@ -65,7 +61,6 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
6561 ),
6662 ),
6763 ),
68 Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))),
6964 Layer.provide(BuiltInDrivers.layer),
7065 Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))),
7166);
apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts deleted-803
......@@ -1,803 +0,0 @@
1import * as Result from "effect/Result";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 decodeAwardEmojiJson,
6 decodeRepositoryBlobsJson,
7 decodeCommitsJson,
8 decodeMergeRequestDetailJson,
9 decodeMergeRequestDiffsJson,
10 decodeMergeRequestListJson,
11 decodeNotesJson,
12 decodeOwnAwardIdJson,
13 decodeViewerJson,
14 gitLabAwardName,
15} from "./gitLabMergeRequestJson.ts";
16
17function listJson(entries: ReadonlyArray<Record<string, unknown>>): string {
18 return JSON.stringify(
19 entries.map((entry) => ({
20 iid: 1,
21 title: "Add the merge requests page",
22 web_url: "https://gitlab.com/acme/web/-/merge_requests/1",
23 source_branch: "feat/page",
24 target_branch: "main",
25 created_at: "2026-07-01T00:00:00Z",
26 updated_at: "2026-07-02T00:00:00Z",
27 ...entry,
28 })),
29 );
30}
31
32function detailJson(entry: Record<string, unknown>): string {
33 return JSON.stringify({
34 iid: 1,
35 title: "Add the merge requests page",
36 web_url: "https://gitlab.com/acme/web/-/merge_requests/1",
37 source_branch: "feat/page",
38 target_branch: "main",
39 created_at: "2026-07-01T00:00:00Z",
40 updated_at: "2026-07-02T00:00:00Z",
41 ...entry,
42 });
43}
44
45function expectSuccess<A>(result: Result.Result<A, unknown>): A {
46 expect(Result.isSuccess(result)).toBe(true);
47 if (!Result.isSuccess(result)) throw new Error("expected a successful decode");
48 return result.success;
49}
50
51describe("decodeMergeRequestListJson", () => {
52 it("reads a merge request as a change request", () => {
53 const batch = expectSuccess(
54 decodeMergeRequestListJson(
55 listJson([
56 {
57 iid: 42,
58 author: { username: "bilal", name: "Bilal" },
59 state: "opened",
60 merge_status: "can_be_merged",
61 draft: false,
62 reviewers: [{ username: "julius" }],
63 labels: ["backend", " "],
64 },
65 ]),
66 ),
67 );
68
69 expect(batch.items).toHaveLength(1);
70 expect(batch.items[0]).toMatchObject({
71 number: 42,
72 author: { login: "bilal", name: "Bilal" },
73 headBranch: "feat/page",
74 baseBranch: "main",
75 state: "open",
76 isDraft: false,
77 mergeability: "mergeable",
78 reviewRequestLogins: ["julius"],
79 labels: [{ name: "backend", color: null }],
80 });
81 });
82
83 it("reports no line counts, which GitLab does not expose", () => {
84 const batch = expectSuccess(decodeMergeRequestListJson(listJson([{}])));
85
86 expect(batch.items[0]).toMatchObject({ additions: 0, deletions: 0 });
87 });
88
89 it("treats a merged timestamp as merged whatever the state says", () => {
90 const batch = expectSuccess(
91 decodeMergeRequestListJson(
92 listJson([{ state: "opened", merged_at: "2026-07-03T00:00:00Z" }]),
93 ),
94 );
95
96 expect(batch.items[0]?.state).toBe("merged");
97 });
98
99 it("keeps a locked merge request open", () => {
100 const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ state: "locked" }])));
101
102 expect(batch.items[0]?.state).toBe("open");
103 });
104
105 it("reads the legacy draft flag", () => {
106 const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ work_in_progress: true }])));
107
108 expect(batch.items[0]?.isDraft).toBe(true);
109 });
110
111 it("calls a conflicted merge request conflicting even while the merge check is pending", () => {
112 const batch = expectSuccess(
113 decodeMergeRequestListJson(listJson([{ merge_status: "checking", has_conflicts: true }])),
114 );
115
116 expect(batch.items[0]?.mergeability).toBe("conflicting");
117 });
118
119 it("leaves an unfinished merge check unknown", () => {
120 const batch = expectSuccess(
121 decodeMergeRequestListJson(listJson([{ merge_status: "checking" }])),
122 );
123
124 expect(batch.items[0]?.mergeability).toBe("unknown");
125 });
126
127 it("skips a malformed row but still counts it, so paging does not stop early", () => {
128 const batch = expectSuccess(
129 decodeMergeRequestListJson(
130 JSON.stringify([{ iid: "not a number" }, ...JSON.parse(listJson([{}]))]),
131 ),
132 );
133
134 expect(batch.items).toHaveLength(1);
135 expect(batch.rawIndexes).toEqual([1]);
136 expect(batch.rawCount).toBe(2);
137 });
138});
139
140describe("decodeMergeRequestDetailJson", () => {
141 it("reads the description, file count and pipeline", () => {
142 const detail = expectSuccess(
143 decodeMergeRequestDetailJson(
144 detailJson({
145 description: "Ships the page.",
146 changes_count: "3",
147 reviewers: [{ username: "julius", name: "Julius" }],
148 head_pipeline: {
149 status: "success",
150 web_url: "https://gitlab.com/acme/web/-/pipelines/9",
151 source: "merge_request_event",
152 },
153 }),
154 ),
155 );
156
157 expect(detail.body).toBe("Ships the page.");
158 expect(detail.changedFiles).toBe(3);
159 expect(detail.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]);
160 expect(detail.checks).toEqual([
161 {
162 name: "Pipeline",
163 status: "success",
164 description: "merge_request_event",
165 url: "https://gitlab.com/acme/web/-/pipelines/9",
166 },
167 ]);
168 });
169
170 it("reads an uncounted change set as its floor", () => {
171 const detail = expectSuccess(
172 decodeMergeRequestDetailJson(detailJson({ changes_count: "1000+" })),
173 );
174
175 expect(detail.changedFiles).toBe(1000);
176 });
177
178 it("falls back to no file count when GitLab omits one", () => {
179 const detail = expectSuccess(decodeMergeRequestDetailJson(detailJson({})));
180
181 expect(detail.changedFiles).toBe(0);
182 });
183
184 it("reads either auto-merge field, and says nothing where GitLab named neither", () => {
185 const armed = (entry: Record<string, unknown>) =>
186 expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).autoMergeEnabled;
187
188 expect(armed({ merge_when_pipeline_succeeds: true })).toBe(true);
189 // The newer name for the same fact, which older GitLab installs do not send.
190 expect(armed({ auto_merge_enabled: true })).toBe(true);
191 expect(armed({ merge_when_pipeline_succeeds: false })).toBe(false);
192 // Absent is GitLab not saying, which the page must not read as "not armed".
193 expect(armed({})).toBeUndefined();
194 });
195
196 it("keeps the squash choice stored with auto-merge", () => {
197 expect(
198 expectSuccess(
199 decodeMergeRequestDetailJson(
200 detailJson({ auto_merge_enabled: true, squash_on_merge: true }),
201 ),
202 ),
203 ).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" });
204
205 expect(
206 expectSuccess(
207 decodeMergeRequestDetailJson(
208 detailJson({
209 auto_merge_enabled: true,
210 squash: true,
211 squash_on_merge: false,
212 }),
213 ),
214 ).autoMergeMethod,
215 ).toBeUndefined();
216 });
217
218 it("keeps a divergence GitLab did not count apart from a divergence of none", () => {
219 const behind = (entry: Record<string, unknown>) =>
220 expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).divergedCommits;
221
222 expect(behind({ diverged_commits_count: 3 })).toBe(3);
223 // Counted and found level, which is the one answer that entitles the page to say so.
224 expect(behind({ diverged_commits_count: 0 })).toBe(0);
225 // An install that does not answer, and a null where the answer would have gone, are both
226 // silence: reading either as zero would tell a stale branch it is current.
227 expect(behind({})).toBeUndefined();
228 expect(behind({ diverged_commits_count: null })).toBeUndefined();
229 });
230
231 it("maps a pipeline waiting on a person to neutral, not failure", () => {
232 const detail = expectSuccess(
233 decodeMergeRequestDetailJson(detailJson({ head_pipeline: { status: "manual" } })),
234 );
235
236 expect(detail.checks[0]?.status).toBe("neutral");
237 });
238});
239
240describe("decodeViewerJson", () => {
241 it("reads the signed-in username", () => {
242 expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: "bilal" })))).toBe("bilal");
243 });
244
245 it("returns nothing when the account has no username", () => {
246 expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: " " })))).toBeNull();
247 });
248});
249
250describe("decodeNotesJson", () => {
251 it("keeps comments and drops GitLab's own activity notes", () => {
252 const notes = expectSuccess(
253 decodeNotesJson(
254 JSON.stringify([
255 {
256 id: 1,
257 body: "assigned to @bilal",
258 system: true,
259 created_at: "2026-07-01T00:00:00Z",
260 },
261 {
262 id: 2,
263 body: "Looks good.",
264 author: { username: "julius" },
265 created_at: "2026-07-02T00:00:00Z",
266 },
267 { id: 3, body: " ", created_at: "2026-07-03T00:00:00Z" },
268 ]),
269 ),
270 );
271
272 expect(notes.comments).toHaveLength(1);
273 expect(notes.comments[0]).toMatchObject({
274 id: "2",
275 kind: "issue-comment",
276 body: "Looks good.",
277 });
278 // The raw count keeps the dropped notes visible to the caller, which needs them to page.
279 expect(notes.rawCount).toBe(3);
280 });
281
282 it("reads a line note as a review comment on its file", () => {
283 const notes = expectSuccess(
284 decodeNotesJson(
285 JSON.stringify([
286 {
287 id: 7,
288 type: "DiffNote",
289 body: "Rename this.",
290 created_at: "2026-07-02T00:00:00Z",
291 position: { new_path: "src/app.ts", old_path: "src/old.ts" },
292 },
293 ]),
294 ),
295 );
296
297 expect(notes.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" });
298 });
299
300 it("falls back to the old path for a note on a deleted line", () => {
301 const notes = expectSuccess(
302 decodeNotesJson(
303 JSON.stringify([
304 {
305 id: 8,
306 type: "DiffNote",
307 body: "Gone.",
308 created_at: "2026-07-02T00:00:00Z",
309 position: { new_path: null, old_path: "src/old.ts" },
310 },
311 ]),
312 ),
313 );
314
315 expect(notes.comments[0]?.path).toBe("src/old.ts");
316 });
317});
318
319describe("decodeCommitsJson", () => {
320 it("returns commits oldest first", () => {
321 const commits = expectSuccess(
322 decodeCommitsJson(
323 JSON.stringify([
324 { id: "bbb", title: "second", committed_date: "2026-07-02T00:00:00Z" },
325 { id: "aaa", title: "first", committed_date: "2026-07-01T00:00:00Z" },
326 ]),
327 ),
328 );
329
330 expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]);
331 });
332
333 it("skips commits whose id is empty", () => {
334 const commits = expectSuccess(
335 decodeCommitsJson(
336 JSON.stringify([
337 { id: " ", title: "invalid", committed_date: "2026-07-02T00:00:00Z" },
338 { id: "aaa", committed_date: "2026-07-01T00:00:00Z" },
339 ]),
340 ),
341 );
342
343 expect(commits.map((commit) => commit.oid)).toEqual(["aaa"]);
344 });
345
346 it("falls back to the creation timestamp when there is no commit date", () => {
347 const commits = expectSuccess(
348 decodeCommitsJson(
349 JSON.stringify([
350 {
351 id: "aaa",
352 created_at: "2026-07-01T00:00:00+08:00",
353 author_name: "Ada Lovelace",
354 author_email: "ada@example.com",
355 },
356 ]),
357 ),
358 );
359
360 expect(commits[0]).toMatchObject({
361 oid: "aaa",
362 committedDate: "2026-07-01T00:00:00+08:00",
363 authors: [{ login: "Ada Lovelace", name: "Ada Lovelace", avatarUrl: null }],
364 });
365 });
366
367 it("carries commit additions and deletions when GitLab returns stats", () => {
368 const commits = expectSuccess(
369 decodeCommitsJson(
370 JSON.stringify([
371 {
372 id: "aaa",
373 committed_date: "2026-07-01T00:00:00Z",
374 stats: { additions: 21, deletions: 8, total: 29 },
375 },
376 ]),
377 ),
378 );
379
380 expect(commits[0]).toMatchObject({ additions: 21, deletions: 8 });
381 });
382});
383
384describe("decodeMergeRequestDiffsJson", () => {
385 it("quotes literal backslashes without interpreting them as escapes", () => {
386 const result = expectSuccess(
387 decodeMergeRequestDiffsJson(
388 JSON.stringify([
389 {
390 old_path: String.raw`src\notes.ts`,
391 new_path: String.raw`src\notes.ts`,
392 diff: "@@ -1 +1 @@\n-old\n+new",
393 },
394 ]),
395 ),
396 );
397
398 expect(result.patch).toBe(
399 [
400 String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`,
401 String.raw`--- "a/src\\notes.ts"`,
402 String.raw`+++ "b/src\\notes.ts"`,
403 "@@ -1 +1 @@",
404 "-old",
405 "+new",
406 "",
407 ].join("\n"),
408 );
409 });
410
411 it("preserves spaces and literal backslashes in both rename paths", () => {
412 const result = expectSuccess(
413 decodeMergeRequestDiffsJson(
414 JSON.stringify([
415 {
416 old_path: String.raw` old\name.ts `,
417 new_path: String.raw` new\name.ts `,
418 renamed_file: true,
419 diff: "",
420 },
421 ]),
422 ),
423 );
424
425 expect(result.patch).toBe(
426 [
427 String.raw`diff --git "a/ old\\name.ts " "b/ new\\name.ts "`,
428 String.raw`rename from " old\\name.ts "`,
429 String.raw`rename to " new\\name.ts "`,
430 String.raw`--- "a/ old\\name.ts "`,
431 String.raw`+++ "b/ new\\name.ts "`,
432 ].join("\n"),
433 );
434 });
435
436 it("assembles a unified patch GitLab does not return", () => {
437 const result = expectSuccess(
438 decodeMergeRequestDiffsJson(
439 JSON.stringify([
440 {
441 old_path: "src/app.ts",
442 new_path: "src/app.ts",
443 diff: "@@ -1 +1 @@\n-old\n+new\n",
444 },
445 ]),
446 ),
447 );
448
449 expect(result.patch).toBe(
450 [
451 "diff --git a/src/app.ts b/src/app.ts",
452 "--- a/src/app.ts",
453 "+++ b/src/app.ts",
454 "@@ -1 +1 @@",
455 "-old",
456 "+new",
457 "",
458 ].join("\n"),
459 );
460 expect(result.truncated).toBe(false);
461 });
462
463 it("points a new file at /dev/null on the left and a deleted file on the right", () => {
464 const result = expectSuccess(
465 decodeMergeRequestDiffsJson(
466 JSON.stringify([
467 {
468 old_path: "src/new.ts",
469 new_path: "src/new.ts",
470 new_file: true,
471 b_mode: "100755",
472 diff: "@@ -0,0 +1 @@\n+hello\n",
473 },
474 {
475 old_path: "src/gone.ts",
476 new_path: "src/gone.ts",
477 deleted_file: true,
478 diff: "@@ -1 +0,0 @@\n-bye\n",
479 },
480 ]),
481 ),
482 );
483
484 expect(result.patch).toContain("new file mode 100755");
485 expect(result.patch).toContain("--- /dev/null");
486 expect(result.patch).toContain("deleted file mode 100644");
487 expect(result.patch).toContain("+++ /dev/null");
488 });
489
490 it("records a rename so the patch names both paths", () => {
491 const result = expectSuccess(
492 decodeMergeRequestDiffsJson(
493 JSON.stringify([
494 { old_path: "src/old.ts", new_path: "src/new.ts", renamed_file: true, diff: "" },
495 ]),
496 ),
497 );
498
499 expect(result.patch).toContain("rename from src/old.ts");
500 expect(result.patch).toContain("rename to src/new.ts");
501 });
502
503 it("reports truncation for a file GitLab refused to inline", () => {
504 const result = expectSuccess(
505 decodeMergeRequestDiffsJson(
506 JSON.stringify([{ old_path: "big.bin", new_path: "big.bin", diff: "", too_large: true }]),
507 ),
508 );
509
510 expect(result.truncated).toBe(true);
511 expect(result.patch).toContain("diff --git a/big.bin b/big.bin");
512 });
513
514 it("reports how many files GitLab returned, so the caller can page", () => {
515 const result = expectSuccess(
516 decodeMergeRequestDiffsJson(
517 JSON.stringify(
518 Array.from({ length: 3 }, (_, index) => ({
519 old_path: `src/${index}.ts`,
520 new_path: `src/${index}.ts`,
521 diff: "@@ -1 +1 @@\n-a\n+b\n",
522 })),
523 ),
524 ),
525 );
526
527 expect(result.rawCount).toBe(3);
528 expect(result.truncated).toBe(false);
529 expect(result.patch).toContain("src/2.ts");
530 });
531
532 it("fails when GitLab did not return a list", () => {
533 expect(Result.isFailure(decodeMergeRequestDiffsJson('{"message":"404"}'))).toBe(true);
534 });
535});
536
537describe("merge request viewer fields", () => {
538 it("carries GitLab's own answer for whether this viewer can merge", () => {
539 expect(
540 expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: { can_merge: false } })))
541 .viewerCanMerge,
542 ).toBe(false);
543 expect(
544 expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: { can_merge: true } })))
545 .viewerCanMerge,
546 ).toBe(true);
547 });
548
549 it("leaves merging permitted where GitLab answered without the field", () => {
550 // Only the single-merge-request endpoint carries `user`, and an install that answers without
551 // it has said nothing about the viewer rather than said no.
552 expect(expectSuccess(decodeMergeRequestDetailJson(detailJson({}))).viewerCanMerge).toBe(true);
553 expect(
554 expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: null }))).viewerCanMerge,
555 ).toBe(true);
556 });
557});
558
559describe("decodeAwardEmojiJson", () => {
560 it("reads MR-level awards, keys per-note awards by the REST id inside their gid, and ignores an award outside the eight", () => {
561 const result = expectSuccess(
562 decodeAwardEmojiJson(
563 JSON.stringify({
564 data: {
565 currentUser: { username: "bilal" },
566 project: {
567 mergeRequest: {
568 awardEmoji: {
569 nodes: [
570 { name: "thumbsup", user: { username: "bilal" } },
571 { name: "thumbsup", user: { username: "julius" } },
572 ],
573 },
574 notes: {
575 pageInfo: { hasNextPage: false, endCursor: null },
576 nodes: [
577 {
578 id: "gid://gitlab/DiffNote/42",
579 awardEmoji: { nodes: [{ name: "heart", user: { username: "julius" } }] },
580 },
581 {
582 id: "gid://gitlab/Note/7",
583 // Not one of the eight the contract carries.
584 awardEmoji: {
585 nodes: [{ name: "partyparrot", user: { username: "bilal" } }],
586 },
587 },
588 ],
589 },
590 },
591 },
592 },
593 }),
594 ),
595 );
596
597 // `bilal` is `currentUser`, so the group they are in reads back as reacted, but their own
598 // username is left out of `actors` — the page names them "You" instead — while `count` still
599 // counts them; `julius` alone does not turn a group's own `viewerHasReacted` on.
600 expect(result.reactions).toEqual([
601 { content: "thumbs-up", count: 2, actors: ["julius"], viewerHasReacted: true },
602 ]);
603 // Note 7's only award named nobody the eight recognise, so it carries no reactions and is
604 // left out of the map rather than kept empty.
605 expect([...result.reactionsByNoteId]).toEqual([
606 ["42", [{ content: "heart", count: 1, actors: ["julius"], viewerHasReacted: false }]],
607 ]);
608 expect(result.nextCursor).toBeNull();
609 });
610
611 it("hands back a cursor when GitLab has more notes to page", () => {
612 const result = expectSuccess(
613 decodeAwardEmojiJson(
614 JSON.stringify({
615 data: {
616 currentUser: null,
617 project: {
618 mergeRequest: {
619 awardEmoji: { nodes: [] },
620 notes: { pageInfo: { hasNextPage: true, endCursor: "Y3Vyc29yOjE" }, nodes: [] },
621 },
622 },
623 },
624 }),
625 ),
626 );
627
628 expect(result.nextCursor).toBe("Y3Vyc29yOjE");
629 });
630
631 it("matches the viewer's username case-insensitively, since GitLab is not consistent about case", () => {
632 const result = expectSuccess(
633 decodeAwardEmojiJson(
634 JSON.stringify({
635 data: {
636 currentUser: { username: "Bilal" },
637 project: {
638 mergeRequest: {
639 awardEmoji: {
640 nodes: [
641 { name: "heart", user: { username: "bilal" } },
642 { name: "heart", user: { username: "julius" } },
643 ],
644 },
645 notes: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
646 },
647 },
648 },
649 }),
650 ),
651 );
652
653 expect(result.reactions).toEqual([
654 { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true },
655 ]);
656 });
657});
658
659describe("decodeOwnAwardIdJson", () => {
660 const awards = JSON.stringify([
661 { id: 101, name: "thumbsup", user: { username: "julius" } },
662 { id: 102, name: "thumbsup", user: { username: "bilal" } },
663 ]);
664
665 it("finds the reader's own award of that name, which is the one a removal deletes", () => {
666 expect(
667 expectSuccess(decodeOwnAwardIdJson(awards, { content: "thumbs-up", viewer: "bilal" })),
668 ).toBe(102);
669 });
670
671 it("returns nothing where the reader has no award of that name", () => {
672 expect(
673 expectSuccess(decodeOwnAwardIdJson(awards, { content: "heart", viewer: "bilal" })),
674 ).toBeNull();
675 });
676});
677
678describe("gitLabAwardName", () => {
679 it("spells the contents whose GitLab award name is not their own kebab-case", () => {
680 expect(gitLabAwardName("thumbs-up")).toBe("thumbsup");
681 expect(gitLabAwardName("laugh")).toBe("laughing");
682 expect(gitLabAwardName("hooray")).toBe("tada");
683 });
684});
685
686describe("decodeRepositoryBlobsJson", () => {
687 /** Null is the query going unanswered, which these cases are not about. */
688 function expectBlobs(result: Result.Result<ReadonlyMap<string, string> | null, unknown>) {
689 const blobs = expectSuccess(result);
690 expect(blobs).not.toBe(null);
691 if (blobs === null) throw new Error("expected an answered blobs query");
692 return blobs;
693 }
694
695 it("reads a blob id per path", () => {
696 const blobs = expectBlobs(
697 decodeRepositoryBlobsJson(
698 JSON.stringify({
699 data: {
700 project: {
701 repository: {
702 blobs: {
703 nodes: [
704 { path: "src/a.ts", oid: "aaa111" },
705 { path: "src/b.ts", oid: "bbb222" },
706 ],
707 },
708 },
709 },
710 },
711 }),
712 ),
713 );
714
715 expect([...blobs]).toEqual([
716 ["src/a.ts", "aaa111"],
717 ["src/b.ts", "bbb222"],
718 ]);
719 });
720
721 it("leaves out a node missing either half, which names no version", () => {
722 const blobs = expectBlobs(
723 decodeRepositoryBlobsJson(
724 JSON.stringify({
725 data: {
726 project: {
727 repository: {
728 blobs: {
729 nodes: [
730 { path: "src/a.ts", oid: null },
731 { path: null, oid: "bbb222" },
732 null,
733 { path: "src/c.ts", oid: "ccc333" },
734 ],
735 },
736 },
737 },
738 },
739 }),
740 ),
741 );
742
743 expect([...blobs]).toEqual([["src/c.ts", "ccc333"]]);
744 });
745
746 it("keys a blob by the path the host spelled, spaces and all", () => {
747 // A leading or trailing space is a legal part of a file's name. Trimmed here, the id lands
748 // under a key the asked-for path is not spelled with, and the caller fills that path in as
749 // the empty revision: a mark on the file then never compares against the real head blob.
750 const blobs = expectBlobs(
751 decodeRepositoryBlobsJson(
752 JSON.stringify({
753 data: {
754 project: {
755 repository: {
756 blobs: {
757 nodes: [
758 { path: " leading.ts", oid: "aaa111" },
759 { path: "trailing.ts ", oid: "bbb222" },
760 { path: " ", oid: "ccc333" },
761 { path: "", oid: "ddd444" },
762 ],
763 },
764 },
765 },
766 },
767 }),
768 ),
769 );
770
771 expect([...blobs]).toEqual([
772 [" leading.ts", "aaa111"],
773 ["trailing.ts ", "bbb222"],
774 // A name that is only spaces is one Git carries too, so it is a path like any other.
775 [" ", "ccc333"],
776 ]);
777 });
778
779 it("tells a project the reader cannot see from a revision with none of the files", () => {
780 // Null is the query going unanswered. Read as an empty answer it would say the head has none
781 // of the asked-for files, which reports every file a reader has cleared as changed.
782 expect(
783 expectSuccess(decodeRepositoryBlobsJson(JSON.stringify({ data: { project: null } }))),
784 ).toBe(null);
785 expect(
786 expectSuccess(
787 decodeRepositoryBlobsJson(JSON.stringify({ data: { project: { repository: null } } })),
788 ),
789 ).toBe(null);
790 expect(
791 expectSuccess(
792 decodeRepositoryBlobsJson(
793 JSON.stringify({ data: { project: { repository: { blobs: { nodes: [] } } } } }),
794 ),
795 ),
796 ).toEqual(new Map());
797 });
798
799 it("fails on output that is not the query's shape", () => {
800 expect(Result.isSuccess(decodeRepositoryBlobsJson("not json"))).toBe(false);
801 expect(Result.isSuccess(decodeRepositoryBlobsJson(JSON.stringify({ errors: [] })))).toBe(false);
802 });
803});
apps/server/src/pullRequest/gitLabMergeRequestJson.ts deleted-1032
......@@ -1,1032 +0,0 @@
1import * as Cause from "effect/Cause";
2import * as Exit from "effect/Exit";
3import * as Result from "effect/Result";
4import * as Schema from "effect/Schema";
5import type {
6 PullRequestActor,
7 PullRequestCheck,
8 PullRequestCheckStatus,
9 PullRequestComment,
10 PullRequestCommit,
11 PullRequestLabel,
12 PullRequestMergeability,
13 PullRequestMergeCapabilities,
14 PullRequestMergeMethod,
15 PullRequestReaction,
16 PullRequestReactionContent,
17 PullRequestReviewThread,
18 PullRequestReviewerCandidate,
19 PullRequestState,
20} from "@t3tools/contracts";
21import { TrimmedNonEmptyString } from "@t3tools/contracts";
22import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
23import { decodeJsonResult } from "@t3tools/shared/schemaJson";
24
25/**
26 * GitLab's REST enums are decoded as plain strings and normalized here: a GitLab release that
27 * adds a pipeline status or a merge status must not fail the whole payload.
28 */
29const RawUserSchema = Schema.Struct({
30 /**
31 * GitLab writes a merge request's reviewers as numeric ids and takes no usernames there, so the
32 * id is carried alongside the handle rather than looked up again when a review is asked for.
33 */
34 id: Schema.optional(Schema.Int),
35 username: Schema.String,
36 name: Schema.optional(Schema.NullOr(Schema.String)),
37 avatar_url: Schema.optional(Schema.NullOr(Schema.String)),
38});
39
40const RawPipelineSchema = Schema.Struct({
41 status: Schema.optional(Schema.NullOr(Schema.String)),
42 web_url: Schema.optional(Schema.NullOr(Schema.String)),
43 source: Schema.optional(Schema.NullOr(Schema.String)),
44});
45
46const RawMergeRequestSchema = Schema.Struct({
47 iid: Schema.Int,
48 title: Schema.String,
49 web_url: Schema.String,
50 description: Schema.optional(Schema.NullOr(Schema.String)),
51 author: Schema.optional(Schema.NullOr(RawUserSchema)),
52 source_branch: Schema.String,
53 target_branch: Schema.String,
54 state: Schema.optional(Schema.NullOr(Schema.String)),
55 draft: Schema.optional(Schema.Boolean),
56 work_in_progress: Schema.optional(Schema.Boolean),
57 merge_status: Schema.optional(Schema.NullOr(Schema.String)),
58 has_conflicts: Schema.optional(Schema.NullOr(Schema.Boolean)),
59 created_at: Schema.String,
60 updated_at: Schema.String,
61 merged_at: Schema.optional(Schema.NullOr(Schema.String)),
62 closed_at: Schema.optional(Schema.NullOr(Schema.String)),
63 reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))),
64 labels: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))),
65 // A string, and "1000+" past GitLab's counting limit, so it is parsed rather than decoded.
66 changes_count: Schema.optional(Schema.NullOr(Schema.String)),
67 head_pipeline: Schema.optional(Schema.NullOr(RawPipelineSchema)),
68 /**
69 * What the requesting account may do, which only the single-merge-request endpoint carries.
70 * GitLab answers `can_merge` for this viewer against this merge request, so it already accounts
71 * for the role, the approval rules and a protected target branch — none of which a project's
72 * access level on its own would tell apart.
73 */
74 user: Schema.optional(
75 Schema.NullOr(Schema.Struct({ can_merge: Schema.optional(Schema.Boolean) })),
76 ),
77 /**
78 * Whether GitLab is holding this merge request to merge it once its pipeline goes green.
79 * `merge_when_pipeline_succeeds` is the field every version answers with; newer ones also
80 * carry `auto_merge_enabled`, which is the same fact under the name GitLab settled on, so
81 * either one saying yes is a yes.
82 */
83 merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)),
84 auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)),
85 /** The merge request's stored squash choice, including project-policy overrides. */
86 squash_on_merge: Schema.optional(Schema.NullOr(Schema.Boolean)),
87 squash: Schema.optional(Schema.NullOr(Schema.Boolean)),
88 /**
89 * How far the target branch has moved on since this one left it, which is the same number
90 * GitLab's own "out of date" wording counts. It costs a walk of the two branches, so GitLab
91 * withholds it unless `include_diverged_commits_count` asks for it, and answers it only for a
92 * single merge request — a list never carries it, however it is asked for.
93 */
94 diverged_commits_count: Schema.optional(Schema.NullOr(Schema.Int)),
95});
96
97const RawNoteSchema = Schema.Struct({
98 id: Schema.Int,
99 body: Schema.optional(Schema.NullOr(Schema.String)),
100 author: Schema.optional(Schema.NullOr(RawUserSchema)),
101 created_at: Schema.String,
102 /** True for notes GitLab writes itself ("assigned to…"), which are events, not comments. */
103 system: Schema.optional(Schema.Boolean),
104 type: Schema.optional(Schema.NullOr(Schema.String)),
105 position: Schema.optional(
106 Schema.NullOr(
107 Schema.Struct({
108 new_path: Schema.optional(Schema.NullOr(Schema.String)),
109 old_path: Schema.optional(Schema.NullOr(Schema.String)),
110 }),
111 ),
112 ),
113});
114
115/**
116 * A discussion note carrying its place in the diff, which is the shape the whole thread view
117 * is built from. `resolved` lives on the note rather than on the discussion: GitLab calls a
118 * discussion resolved once every resolvable note in it is.
119 */
120const RawDiscussionNoteSchema = Schema.Struct({
121 id: Schema.Int,
122 body: Schema.optional(Schema.NullOr(Schema.String)),
123 author: Schema.optional(Schema.NullOr(RawUserSchema)),
124 created_at: Schema.String,
125 system: Schema.optional(Schema.Boolean),
126 resolvable: Schema.optional(Schema.Boolean),
127 resolved: Schema.optional(Schema.NullOr(Schema.Boolean)),
128 position: Schema.optional(
129 Schema.NullOr(
130 Schema.Struct({
131 position_type: Schema.optional(Schema.NullOr(Schema.String)),
132 new_path: Schema.optional(Schema.NullOr(Schema.String)),
133 old_path: Schema.optional(Schema.NullOr(Schema.String)),
134 new_line: Schema.optional(Schema.NullOr(Schema.Int)),
135 old_line: Schema.optional(Schema.NullOr(Schema.Int)),
136 }),
137 ),
138 ),
139});
140
141const RawDiscussionSchema = Schema.Struct({
142 id: Schema.String,
143 notes: Schema.optional(Schema.NullOr(Schema.Array(RawDiscussionNoteSchema))),
144});
145
146const RawDiffRefsSchema = Schema.Struct({
147 diff_refs: Schema.optional(
148 Schema.NullOr(
149 Schema.Struct({
150 base_sha: Schema.String,
151 head_sha: Schema.String,
152 start_sha: Schema.String,
153 }),
154 ),
155 ),
156});
157
158const RawCommitSchema = Schema.Struct({
159 id: TrimmedNonEmptyString,
160 title: Schema.optional(Schema.NullOr(Schema.String)),
161 committed_date: Schema.optional(Schema.NullOr(Schema.String)),
162 created_at: Schema.optional(Schema.NullOr(Schema.String)),
163 parent_ids: Schema.optional(Schema.Array(Schema.String)),
164 author_name: Schema.optional(Schema.NullOr(Schema.String)),
165 author_email: Schema.optional(Schema.NullOr(Schema.String)),
166 stats: Schema.optional(
167 Schema.NullOr(
168 Schema.Struct({
169 additions: Schema.optional(Schema.Int),
170 deletions: Schema.optional(Schema.Int),
171 }),
172 ),
173 ),
174});
175
176const RawDiffSchema = Schema.Struct({
177 old_path: Schema.String,
178 new_path: Schema.String,
179 a_mode: Schema.optional(Schema.NullOr(Schema.String)),
180 b_mode: Schema.optional(Schema.NullOr(Schema.String)),
181 new_file: Schema.optional(Schema.Boolean),
182 renamed_file: Schema.optional(Schema.Boolean),
183 deleted_file: Schema.optional(Schema.Boolean),
184 diff: Schema.optional(Schema.NullOr(Schema.String)),
185 /** GitLab omits the hunks for a file it considers too large to inline. */
186 too_large: Schema.optional(Schema.NullOr(Schema.Boolean)),
187 /** And for one it collapsed, which withholds them the same way. */
188 collapsed: Schema.optional(Schema.NullOr(Schema.Boolean)),
189});
190
191const RawViewerSchema = Schema.Struct({
192 username: Schema.optional(Schema.NullOr(Schema.String)),
193});
194
195/** A GitLab project settles on one merge strategy plus an optional squash. */
196const RawProjectMergeSettingsSchema = Schema.Struct({
197 merge_method: Schema.optional(Schema.NullOr(Schema.String)),
198 squash_option: Schema.optional(Schema.NullOr(Schema.String)),
199});
200
201export interface GitLabMergeRequestListItem {
202 readonly number: number;
203 readonly title: string;
204 readonly url: string;
205 readonly author: PullRequestActor | null;
206 readonly headBranch: string;
207 readonly baseBranch: string;
208 readonly state: PullRequestState;
209 readonly isDraft: boolean;
210 readonly mergeability: PullRequestMergeability;
211 /**
212 * GitLab reports neither added nor removed lines on a merge request, so both stay zero and
213 * the surface omits the stat. The Code tab counts them from the patch it already fetched.
214 */
215 readonly additions: number;
216 readonly deletions: number;
217 readonly createdAt: string;
218 readonly updatedAt: string;
219 readonly reviewRequestLogins: ReadonlyArray<string>;
220 readonly labels: ReadonlyArray<PullRequestLabel>;
221}
222
223export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem {
224 readonly body: string;
225 readonly changedFiles: number;
226 readonly mergedAt: string | null;
227 readonly closedAt: string | null;
228 readonly reviewers: ReadonlyArray<PullRequestActor>;
229 readonly checks: ReadonlyArray<PullRequestCheck>;
230 /** False only where GitLab said so; an answer without the field leaves merging permitted. */
231 readonly viewerCanMerge: boolean;
232 /** The reviewers as GitLab addresses them, which is what writing the set back takes. */
233 readonly reviewerIds: ReadonlyArray<number>;
234 /** Absent where GitLab named neither auto-merge field, which is not the same as off. */
235 readonly autoMergeEnabled?: boolean;
236 /** GitLab only exposes the stored strategy separately when that strategy is squash. */
237 readonly autoMergeMethod?: PullRequestMergeMethod;
238 /**
239 * Absent where GitLab did not count, which is not the same as a branch that has nothing behind
240 * it: an install too old to answer must not be read as saying the branch is current.
241 */
242 readonly divergedCommits?: number;
243}
244
245function trimmed(value: string | null | undefined): string | null {
246 const text = value?.trim() ?? "";
247 return text.length > 0 ? text : null;
248}
249
250function toActor(raw: Schema.Schema.Type<typeof RawUserSchema> | null | undefined) {
251 const login = trimmed(raw?.username);
252 return login === null
253 ? null
254 : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatar_url) };
255}
256
257function toState(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): PullRequestState {
258 if (trimmed(raw.merged_at) !== null) return "merged";
259 switch (raw.state?.trim().toLowerCase()) {
260 case "merged":
261 return "merged";
262 case "closed":
263 return "closed";
264 default:
265 // `locked` is an open merge request whose discussion is locked.
266 return "open";
267 }
268}
269
270function toMergeability(
271 raw: Schema.Schema.Type<typeof RawMergeRequestSchema>,
272): PullRequestMergeability {
273 if (raw.has_conflicts === true) return "conflicting";
274 switch (raw.merge_status?.trim().toLowerCase()) {
275 case "can_be_merged":
276 return "mergeable";
277 case "cannot_be_merged":
278 return "conflicting";
279 default:
280 // `unchecked` and `checking` mean GitLab has not finished the merge check yet.
281 return "unknown";
282 }
283}
284
285function toLabels(raw: ReadonlyArray<string> | null | undefined): ReadonlyArray<PullRequestLabel> {
286 // GitLab returns label names only, so there is no colour to carry.
287 return (raw ?? []).flatMap((label) => {
288 const name = trimmed(label);
289 return name === null ? [] : [{ name, color: null }];
290 });
291}
292
293/**
294 * "3" for a counted change set, "1000+" once GitLab gives up counting. The leading number is
295 * the floor either way, which reads better than dropping an uncounted change set to nothing.
296 */
297function toChangedFiles(value: string | null | undefined): number {
298 const parsed = Number.parseInt(value?.trim() ?? "", 10);
299 return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
300}
301
302function toPipelineStatus(value: string | null | undefined): PullRequestCheckStatus {
303 switch (value?.trim().toLowerCase()) {
304 case "success":
305 return "success";
306 case "failed":
307 return "failure";
308 case "canceled":
309 case "cancelling":
310 return "cancelled";
311 case "skipped":
312 return "skipped";
313 // A pipeline waiting on a person is not progress, and it is not a failure either.
314 case "manual":
315 case "scheduled":
316 return "neutral";
317 default:
318 return "pending";
319 }
320}
321
322/**
323 * GitLab has no per-job check list on a merge request, so its pipeline is reported as the one
324 * check. The jobs behind it stay one click away through the pipeline URL.
325 */
326function toChecks(
327 raw: Schema.Schema.Type<typeof RawMergeRequestSchema>,
328): ReadonlyArray<PullRequestCheck> {
329 const pipeline = raw.head_pipeline;
330 if (!pipeline) return [];
331 return [
332 {
333 name: "Pipeline",
334 status: toPipelineStatus(pipeline.status),
335 description: trimmed(pipeline.source),
336 url: trimmed(pipeline.web_url),
337 },
338 ];
339}
340
341function toListItem(
342 raw: Schema.Schema.Type<typeof RawMergeRequestSchema>,
343): GitLabMergeRequestListItem {
344 return {
345 number: raw.iid,
346 title: raw.title,
347 url: raw.web_url,
348 author: toActor(raw.author),
349 headBranch: raw.source_branch,
350 baseBranch: raw.target_branch,
351 state: toState(raw),
352 isDraft: raw.draft ?? raw.work_in_progress ?? false,
353 mergeability: toMergeability(raw),
354 additions: 0,
355 deletions: 0,
356 createdAt: raw.created_at,
357 updatedAt: raw.updated_at,
358 reviewRequestLogins: (raw.reviewers ?? []).flatMap((reviewer) => {
359 const login = trimmed(reviewer.username);
360 return login === null ? [] : [login];
361 }),
362 labels: toLabels(raw.labels),
363 };
364}
365
366function toDetail(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): GitLabMergeRequestDetail {
367 const listItem = toListItem(raw);
368 const autoMerge =
369 raw.merge_when_pipeline_succeeds == null && raw.auto_merge_enabled == null
370 ? undefined
371 : raw.merge_when_pipeline_succeeds === true || raw.auto_merge_enabled === true;
372 return {
373 ...listItem,
374 body: raw.description ?? "",
375 changedFiles: toChangedFiles(raw.changes_count),
376 mergedAt: trimmed(raw.merged_at),
377 closedAt: trimmed(raw.closed_at),
378 // Built from the reviewers themselves rather than from their logins, so the avatars survive.
379 reviewers: (raw.reviewers ?? []).flatMap((reviewer) => {
380 const actor = toActor(reviewer);
381 return actor === null ? [] : [actor];
382 }),
383 checks: toChecks(raw),
384 viewerCanMerge: raw.user?.can_merge !== false,
385 reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) =>
386 reviewer.id === undefined ? [] : [reviewer.id],
387 ),
388 ...(autoMerge === undefined ? {} : { autoMergeEnabled: autoMerge }),
389 ...(autoMerge === true && raw.squash_on_merge === true
390 ? { autoMergeMethod: "squash" as const }
391 : {}),
392 ...(raw.diverged_commits_count == null ? {} : { divergedCommits: raw.diverged_commits_count }),
393 };
394}
395
396const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown));
397const decodeMergeRequestEntry = Schema.decodeUnknownExit(RawMergeRequestSchema);
398const decodeMergeRequest = decodeJsonResult(RawMergeRequestSchema);
399const decodeNoteEntry = Schema.decodeUnknownExit(RawNoteSchema);
400const decodeUserEntry = Schema.decodeUnknownExit(RawUserSchema);
401const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema);
402const decodeCommit = decodeJsonResult(RawCommitSchema);
403const decodeDiffEntry = Schema.decodeUnknownExit(RawDiffSchema);
404const decodeDiscussionEntry = Schema.decodeUnknownExit(RawDiscussionSchema);
405const decodeDiffRefs = decodeJsonResult(RawDiffRefsSchema);
406const decodeViewer = decodeJsonResult(RawViewerSchema);
407const decodeProjectMergeSettings = decodeJsonResult(RawProjectMergeSettingsSchema);
408
409type DecodeFailure = Cause.Cause<Schema.SchemaError>;
410
411export interface GitLabProjectUsers {
412 readonly candidates: ReadonlyArray<PullRequestReviewerCandidate>;
413 /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */
414 readonly rawCount: number;
415}
416
417export interface GitLabMergeRequestListBatch {
418 readonly items: ReadonlyArray<GitLabMergeRequestListItem>;
419 /** Zero-based positions of the decoded items in GitLab's raw page. */
420 readonly rawIndexes: ReadonlyArray<number>;
421 /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */
422 readonly rawCount: number;
423}
424
425/** Malformed entries are skipped rather than failing the batch: one unexpected merge request
426 * must not blank the whole list. */
427export function decodeMergeRequestListJson(
428 raw: string,
429): Result.Result<GitLabMergeRequestListBatch, DecodeFailure> {
430 const decoded = decodeUnknownList(raw);
431 if (!Result.isSuccess(decoded)) {
432 return Result.fail(decoded.failure);
433 }
434 const items: GitLabMergeRequestListItem[] = [];
435 const rawIndexes: number[] = [];
436 for (const [rawIndex, entry] of decoded.success.entries()) {
437 const item = decodeMergeRequestEntry(entry);
438 if (Exit.isSuccess(item)) {
439 items.push(toListItem(item.value));
440 rawIndexes.push(rawIndex);
441 }
442 }
443 return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length });
444}
445
446export function decodeMergeRequestDetailJson(
447 raw: string,
448): Result.Result<GitLabMergeRequestDetail, DecodeFailure> {
449 const decoded = decodeMergeRequest(raw);
450 return Result.isSuccess(decoded)
451 ? Result.succeed(toDetail(decoded.success))
452 : Result.fail(decoded.failure);
453}
454
455export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> {
456 const decoded = decodeViewer(raw);
457 return Result.isSuccess(decoded)
458 ? Result.succeed(trimmed(decoded.success.username))
459 : Result.fail(decoded.failure);
460}
461
462/**
463 * The people with access to the project, which `GET /projects/:id/users` answers with — the same
464 * list GitLab's own reviewer field is filled from, including the members a group above the project
465 * lends it. A malformed row is skipped rather than failing the menu it belongs to.
466 *
467 * Nobody is marked requested here: who has been asked lives on the merge request, and only the
468 * caller holds both.
469 */
470export function decodeProjectUsersJson(
471 raw: string,
472): Result.Result<GitLabProjectUsers, DecodeFailure> {
473 const decoded = decodeUnknownList(raw);
474 if (!Result.isSuccess(decoded)) {
475 return Result.fail(decoded.failure);
476 }
477 const candidates: PullRequestReviewerCandidate[] = [];
478 for (const entry of decoded.success) {
479 const user = decodeUserEntry(entry);
480 if (Exit.isFailure(user) || user.value.id === undefined) continue;
481 const actor = toActor(user.value);
482 if (actor === null) continue;
483 candidates.push({
484 ...actor,
485 id: String(user.value.id),
486 kind: "user",
487 isRequested: false,
488 });
489 }
490 return Result.succeed({ candidates, rawCount: decoded.success.length });
491}
492
493/**
494 * GitLab settles the strategy per project rather than offering all three per merge request:
495 * `merge_method` picks one of merge commit, semi-linear or fast-forward, and squashing is a
496 * separate switch. An unrecognized setting offers nothing rather than offering a strategy the
497 * project forbids.
498 */
499export function decodeProjectMergeCapabilitiesJson(
500 raw: string,
501): Result.Result<PullRequestMergeCapabilities, DecodeFailure> {
502 const decoded = decodeProjectMergeSettings(raw);
503 if (!Result.isSuccess(decoded)) {
504 return Result.fail(decoded.failure);
505 }
506 const mergeMethod = decoded.success.merge_method?.trim().toLowerCase();
507 const squashOption = decoded.success.squash_option?.trim().toLowerCase();
508 return Result.succeed({
509 merge: mergeMethod === "merge",
510 // Both semi-linear and fast-forward histories are reached by rebasing onto the target.
511 rebase: mergeMethod === "rebase_merge" || mergeMethod === "ff",
512 // Only GitLab's own enabling values. An absent or unrecognized setting offers nothing,
513 // rather than offering a squash the project may forbid.
514 squash:
515 squashOption === "always" || squashOption === "default_on" || squashOption === "default_off",
516 });
517}
518
519/**
520 * Comments only. System notes are GitLab's own activity feed entries, and a `DiffNote` is the
521 * root of a line-level discussion, which is what the review-comment kind means.
522 *
523 * The raw note count comes back alongside, because dropping notes hides whether the page was
524 * full: a caller cannot tell "no more notes" from "a page of activity entries" without it.
525 */
526/** The three revisions a positioned comment is written against. */
527export interface GitLabDiffRefs {
528 readonly baseSha: string;
529 readonly headSha: string;
530 readonly startSha: string;
531}
532
533export interface GitLabDiscussions {
534 readonly threads: ReadonlyArray<PullRequestReviewThread>;
535 /** Discussions GitLab returned, counted before decoding, so a skipped one still counts. */
536 readonly rawCount: number;
537}
538
539/**
540 * Positioned discussions only. GitLab returns the merge request's whole conversation here,
541 * including the plain notes the timeline already shows, and only a positioned one belongs
542 * against a line of the diff.
543 */
544export function decodeDiscussionsJson(
545 raw: string,
546): Result.Result<GitLabDiscussions, DecodeFailure> {
547 const decoded = decodeUnknownList(raw);
548 if (!Result.isSuccess(decoded)) {
549 return Result.fail(decoded.failure);
550 }
551 const threads: PullRequestReviewThread[] = [];
552 for (const entry of decoded.success) {
553 const discussion = decodeDiscussionEntry(entry);
554 if (!Exit.isSuccess(discussion)) continue;
555 const notes = (discussion.value.notes ?? []).filter((note) => note.system !== true);
556 const root = notes[0];
557 const position = root?.position;
558 if (root === undefined || !position || position.position_type !== "text") continue;
559 // A comment on an added or context line carries `new_line`; one on a removed line carries
560 // only `old_line`, and belongs against the file as it was.
561 const side = position.new_line === null || position.new_line === undefined ? "left" : "right";
562 const path = trimmed(side === "left" ? position.old_path : position.new_path);
563 const line = side === "left" ? position.old_line : position.new_line;
564 if (path === null) continue;
565 threads.push({
566 id: discussion.value.id,
567 path,
568 line: typeof line === "number" && line > 0 ? line : null,
569 side,
570 isResolved: root.resolved === true,
571 // GitLab reports no equivalent of "written against a line that has since moved", so a
572 // thread the diff cannot place is worked out from the diff itself rather than claimed
573 // here.
574 isOutdated: false,
575 comments: notes.map((note) => ({
576 id: String(note.id),
577 author: toActor(note.author),
578 body: note.body ?? "",
579 createdAt: note.created_at,
580 url: null,
581 })),
582 });
583 }
584 return Result.succeed({ threads, rawCount: decoded.success.length });
585}
586
587export function decodeDiffRefsJson(
588 raw: string,
589): Result.Result<GitLabDiffRefs | null, DecodeFailure> {
590 const decoded = decodeDiffRefs(raw);
591 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
592 const refs = decoded.success.diff_refs;
593 return Result.succeed(
594 refs ? { baseSha: refs.base_sha, headSha: refs.head_sha, startSha: refs.start_sha } : null,
595 );
596}
597
598export function decodeNotesJson(
599 raw: string,
600): Result.Result<
601 { readonly comments: ReadonlyArray<PullRequestComment>; readonly rawCount: number },
602 DecodeFailure
603> {
604 const decoded = decodeUnknownList(raw);
605 if (!Result.isSuccess(decoded)) {
606 return Result.fail(decoded.failure);
607 }
608 const comments: PullRequestComment[] = [];
609 for (const entry of decoded.success) {
610 const note = decodeNoteEntry(entry);
611 if (Exit.isFailure(note)) continue;
612 const value = note.value;
613 if (value.system === true) continue;
614 const body = value.body ?? "";
615 if (body.trim().length === 0) continue;
616 const isDiffNote = value.type?.trim() === "DiffNote";
617 comments.push({
618 id: String(value.id),
619 kind: isDiffNote ? "review-comment" : "issue-comment",
620 author: toActor(value.author),
621 body,
622 createdAt: value.created_at,
623 url: null,
624 path: trimmed(value.position?.new_path) ?? trimmed(value.position?.old_path),
625 reviewState: null,
626 });
627 }
628 return Result.succeed({ comments, rawCount: decoded.success.length });
629}
630
631export function decodeCommitsJson(
632 raw: string,
633): Result.Result<ReadonlyArray<PullRequestCommit>, DecodeFailure> {
634 const decoded = decodeUnknownList(raw);
635 if (!Result.isSuccess(decoded)) {
636 return Result.fail(decoded.failure);
637 }
638 const commits: PullRequestCommit[] = [];
639 for (const entry of decoded.success) {
640 const commit = decodeCommitEntry(entry);
641 if (Exit.isFailure(commit)) continue;
642 const committedDate = trimmed(commit.value.committed_date) ?? trimmed(commit.value.created_at);
643 if (committedDate === null) continue;
644 commits.push({
645 oid: commit.value.id,
646 messageHeadline: commit.value.title ?? "",
647 committedDate,
648 ...(commit.value.stats === null || commit.value.stats === undefined
649 ? {}
650 : {
651 additions: Math.max(0, commit.value.stats.additions ?? 0),
652 deletions: Math.max(0, commit.value.stats.deletions ?? 0),
653 }),
654 authors: (() => {
655 const login = trimmed(commit.value.author_name) ?? trimmed(commit.value.author_email);
656 return login === null
657 ? []
658 : [{ login, name: trimmed(commit.value.author_name), avatarUrl: null }];
659 })(),
660 });
661 }
662 // GitLab lists a merge request's commits newest first; the timeline reads oldest first.
663 return Result.succeed(commits.toReversed());
664}
665
666/** The exact comparison GitLab uses for a commit-scoped diff. */
667export function decodeCommitDiffRefsJson(
668 raw: string,
669): Result.Result<GitLabDiffRefs | null, DecodeFailure> {
670 const decoded = decodeCommit(raw);
671 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
672 const baseSha = trimmed(decoded.success.parent_ids?.[0]);
673 const headSha = trimmed(decoded.success.id);
674 return Result.succeed(
675 baseSha === null || headSha === null ? null : { baseSha, headSha, startSha: baseSha },
676 );
677}
678
679function diffHeaderPaths(raw: Schema.Schema.Type<typeof RawDiffSchema>): {
680 readonly from: string;
681 readonly to: string;
682} {
683 return {
684 from: raw.new_file === true ? "/dev/null" : quoteGitPatchPath(`a/${raw.old_path}`),
685 to: raw.deleted_file === true ? "/dev/null" : quoteGitPatchPath(`b/${raw.new_path}`),
686 };
687}
688
689export interface GitLabMergeRequestPatch {
690 readonly patch: string;
691 /** At least one file's hunks were withheld by GitLab as too large to inline. */
692 readonly truncated: boolean;
693 /** Files GitLab returned, counted before decoding, so the caller can page. */
694 readonly rawCount: number;
695}
696
697/**
698 * GitLab returns hunks per file with no `diff --git` header, so the unified patch every diff
699 * viewer expects is assembled here. This decodes one page; walking pages is the caller's job,
700 * which is why the raw file count comes back with the patch.
701 */
702export function decodeMergeRequestDiffsJson(
703 raw: string,
704): Result.Result<GitLabMergeRequestPatch, DecodeFailure> {
705 const decoded = decodeUnknownList(raw);
706 if (!Result.isSuccess(decoded)) {
707 return Result.fail(decoded.failure);
708 }
709 const sections: string[] = [];
710 let truncated = false;
711 for (const entry of decoded.success) {
712 const file = decodeDiffEntry(entry);
713 if (Exit.isFailure(file)) continue;
714 const value = file.value;
715 const hunks = value.diff ?? "";
716 if (hunks.length === 0) {
717 // A file GitLab declined to inline still belongs in the file list, header only.
718 truncated = truncated || value.too_large === true || value.collapsed === true;
719 }
720 const { from, to } = diffHeaderPaths(value);
721 const header = [
722 `diff --git ${quoteGitPatchPath(`a/${value.old_path}`)} ${quoteGitPatchPath(`b/${value.new_path}`)}`,
723 ...(value.new_file === true ? [`new file mode ${value.b_mode ?? "100644"}`] : []),
724 ...(value.deleted_file === true ? [`deleted file mode ${value.a_mode ?? "100644"}`] : []),
725 ...(value.renamed_file === true
726 ? [
727 `rename from ${quoteGitPatchPath(value.old_path)}`,
728 `rename to ${quoteGitPatchPath(value.new_path)}`,
729 ]
730 : []),
731 `--- ${from}`,
732 `+++ ${to}`,
733 ].join("\n");
734 sections.push(hunks.length === 0 ? header : `${header}\n${hunks.replace(/\n?$/, "\n")}`);
735 }
736 return Result.succeed({
737 patch: sections.join("\n"),
738 truncated,
739 rawCount: decoded.success.length,
740 });
741}
742
743/** GitLab's award names for the eight reactions the contract carries. */
744const GITLAB_AWARD_BY_CONTENT: Readonly<Record<PullRequestReactionContent, string>> = {
745 "thumbs-up": "thumbsup",
746 "thumbs-down": "thumbsdown",
747 laugh: "laughing",
748 hooray: "tada",
749 confused: "confused",
750 heart: "heart",
751 rocket: "rocket",
752 eyes: "eyes",
753};
754
755const CONTENT_BY_GITLAB_AWARD: Readonly<Record<string, PullRequestReactionContent>> =
756 Object.fromEntries(
757 Object.entries(GITLAB_AWARD_BY_CONTENT).map(([content, name]) => [name, content]),
758 ) as Readonly<Record<string, PullRequestReactionContent>>;
759
760export function gitLabAwardName(content: PullRequestReactionContent): string {
761 return GITLAB_AWARD_BY_CONTENT[content];
762}
763
764/**
765 * Awards on the merge request and on every note of it, in one read. The REST notes endpoint the
766 * conversation comes from carries no award at all, and asking per note would be a request each.
767 *
768 * `currentUser` rides along because GitLab names who awarded but never says whether that is the
769 * reader — so the comparison is made here rather than paid for with a request of its own.
770 */
771export const AWARD_EMOJI_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!, $cursor: String) {
772 currentUser { username }
773 project(fullPath: $fullPath) {
774 mergeRequest(iid: $iid) {
775 awardEmoji { nodes { name user { username } } }
776 notes(first: 100, after: $cursor) {
777 pageInfo { hasNextPage endCursor }
778 nodes { id awardEmoji { nodes { name user { username } } } }
779 }
780 }
781 }
782}`;
783
784const RawAwardEmojiNodesSchema = Schema.optional(
785 Schema.NullOr(
786 Schema.Struct({
787 nodes: Schema.optional(
788 Schema.NullOr(
789 Schema.Array(
790 Schema.NullOr(
791 Schema.Struct({
792 name: Schema.optional(Schema.NullOr(Schema.String)),
793 user: Schema.optional(
794 Schema.NullOr(
795 Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) }),
796 ),
797 ),
798 }),
799 ),
800 ),
801 ),
802 ),
803 }),
804 ),
805);
806
807const RawAwardEmojiPageSchema = Schema.Struct({
808 data: Schema.Struct({
809 currentUser: Schema.optional(
810 Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })),
811 ),
812 project: Schema.NullOr(
813 Schema.Struct({
814 mergeRequest: Schema.NullOr(
815 Schema.Struct({
816 awardEmoji: RawAwardEmojiNodesSchema,
817 notes: Schema.optional(
818 Schema.NullOr(
819 Schema.Struct({
820 pageInfo: Schema.optional(
821 Schema.Struct({
822 hasNextPage: Schema.optional(Schema.Boolean),
823 endCursor: Schema.optional(Schema.NullOr(Schema.String)),
824 }),
825 ),
826 nodes: Schema.Array(
827 Schema.NullOr(
828 Schema.Struct({
829 id: Schema.optional(Schema.NullOr(Schema.String)),
830 awardEmoji: RawAwardEmojiNodesSchema,
831 }),
832 ),
833 ),
834 }),
835 ),
836 ),
837 }),
838 ),
839 }),
840 ),
841 }),
842});
843
844const decodeAwardEmojiPage = decodeJsonResult(RawAwardEmojiPageSchema);
845
846/**
847 * The awards on one subject, grouped the way a reaction pill is drawn. The viewer's own username
848 * is left out of `actors` — the page names them "You" instead, and leaving it in would name them
849 * twice — but `count` still counts them along with everyone else.
850 */
851function toReactions(
852 nodes: Schema.Schema.Type<typeof RawAwardEmojiNodesSchema>,
853 viewer: string | null,
854): ReadonlyArray<PullRequestReaction> {
855 const normalizedViewer = viewer?.toLowerCase() ?? null;
856 const groups = new Map<
857 PullRequestReactionContent,
858 { count: number; actors: string[]; viewer: boolean }
859 >();
860 for (const node of nodes?.nodes ?? []) {
861 // An award outside the eight is left out rather than shown under a name the picker has no
862 // way to take back: GitLab accepts any emoji, and the other hosts accept none of them.
863 const content = CONTENT_BY_GITLAB_AWARD[trimmed(node?.name)?.toLowerCase() ?? ""];
864 if (content === undefined) continue;
865 const username = trimmed(node?.user?.username);
866 if (username === null) continue;
867 const group = groups.get(content) ?? { count: 0, actors: [], viewer: false };
868 group.count++;
869 if (normalizedViewer !== null && username.toLowerCase() === normalizedViewer) {
870 group.viewer = true;
871 } else {
872 group.actors.push(username);
873 }
874 groups.set(content, group);
875 }
876 return [...groups].flatMap(([content, group]) =>
877 group.count === 0
878 ? []
879 : [{ content, count: group.count, actors: group.actors, viewerHasReacted: group.viewer }],
880 );
881}
882
883/** `gid://gitlab/DiffNote/42` is note 42, which is the id the REST conversation carries. */
884function noteIdOf(gid: string | null | undefined): string | null {
885 const id = trimmed(gid)?.split("/").at(-1);
886 return id !== undefined && /^\d+$/.test(id) ? id : null;
887}
888
889export interface GitLabAwardEmojiPage {
890 /** The merge request's own awards, which are the ones on its description. */
891 readonly reactions: ReadonlyArray<PullRequestReaction>;
892 readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
893 readonly nextCursor: string | null;
894}
895
896export function decodeAwardEmojiJson(
897 raw: string,
898): Result.Result<GitLabAwardEmojiPage, DecodeFailure> {
899 const decoded = decodeAwardEmojiPage(raw);
900 if (!Result.isSuccess(decoded)) {
901 return Result.fail(decoded.failure);
902 }
903 const data = decoded.success.data;
904 const viewer = trimmed(data.currentUser?.username);
905 const mergeRequest = data.project?.mergeRequest;
906 const reactionsByNoteId = new Map<string, ReadonlyArray<PullRequestReaction>>();
907 for (const node of mergeRequest?.notes?.nodes ?? []) {
908 const id = noteIdOf(node?.id);
909 if (id === null) continue;
910 const reactions = toReactions(node?.awardEmoji, viewer);
911 if (reactions.length > 0) reactionsByNoteId.set(id, reactions);
912 }
913 const pageInfo = mergeRequest?.notes?.pageInfo;
914 return Result.succeed({
915 reactions: toReactions(mergeRequest?.awardEmoji, viewer),
916 reactionsByNoteId,
917 nextCursor: pageInfo?.hasNextPage === true ? (trimmed(pageInfo.endCursor) ?? null) : null,
918 });
919}
920
921const RawAwardSchema = Schema.Struct({
922 id: Schema.Int,
923 name: Schema.optional(Schema.NullOr(Schema.String)),
924 user: Schema.optional(
925 Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })),
926 ),
927});
928
929const decodeAward = Schema.decodeUnknownExit(RawAwardSchema);
930
931/**
932 * The reader's own award of one name on a subject, which is what taking a reaction back is
933 * addressed by: GitLab deletes an award by its id and has no way to name one by its emoji.
934 */
935export function decodeOwnAwardIdJson(
936 raw: string,
937 input: { readonly content: PullRequestReactionContent; readonly viewer: string },
938): Result.Result<number | null, DecodeFailure> {
939 const decoded = decodeUnknownList(raw);
940 if (!Result.isSuccess(decoded)) {
941 return Result.fail(decoded.failure);
942 }
943 const name = gitLabAwardName(input.content);
944 for (const entry of decoded.success) {
945 const award = decodeAward(entry);
946 if (Exit.isFailure(award)) continue;
947 const value = award.value;
948 if (trimmed(value.name)?.toLowerCase() !== name) continue;
949 if (trimmed(value.user?.username) !== input.viewer) continue;
950 return Result.succeed(value.id);
951 }
952 return Result.succeed(null);
953}
954
955/**
956 * What the given paths are at one revision, as blob ids. Asked for by path rather than by walking
957 * the tree, since GitLab charges this query by how many paths it is given. A path the revision
958 * does not have comes back missing rather than as an error, which is the answer for a deleted file.
959 */
960export const REPOSITORY_BLOBS_GRAPHQL_QUERY = `query($fullPath: ID!, $ref: String!, $paths: [String!]!) {
961 project(fullPath: $fullPath) {
962 repository {
963 blobs(ref: $ref, paths: $paths) {
964 nodes { path oid }
965 }
966 }
967 }
968}`;
969
970const RawRepositoryBlobsSchema = Schema.Struct({
971 data: Schema.Struct({
972 project: Schema.NullOr(
973 Schema.Struct({
974 repository: Schema.optional(
975 Schema.NullOr(
976 Schema.Struct({
977 blobs: Schema.optional(
978 Schema.NullOr(
979 Schema.Struct({
980 nodes: Schema.optional(
981 Schema.NullOr(
982 Schema.Array(
983 Schema.NullOr(
984 Schema.Struct({
985 path: Schema.optional(Schema.NullOr(Schema.String)),
986 oid: Schema.optional(Schema.NullOr(Schema.String)),
987 }),
988 ),
989 ),
990 ),
991 ),
992 }),
993 ),
994 ),
995 }),
996 ),
997 ),
998 }),
999 ),
1000 }),
1001});
1002
1003const decodeRepositoryBlobs = decodeJsonResult(RawRepositoryBlobsSchema);
1004
1005/**
1006 * Blob ids by path, or null where GitLab did not answer the query at all (a project the token
1007 * cannot see, or a repository with no blobs connection). That case must be told apart from an
1008 * empty answer: read as "the revision has none of these files", it would report every cleared
1009 * file as changed again. A node missing either half is left out, since the caller treats an
1010 * absent path as one the revision does not carry.
1011 */
1012export function decodeRepositoryBlobsJson(
1013 raw: string,
1014): Result.Result<ReadonlyMap<string, string> | null, DecodeFailure> {
1015 const decoded = decodeRepositoryBlobs(raw);
1016 if (!Result.isSuccess(decoded)) {
1017 return Result.fail(decoded.failure);
1018 }
1019 const nodes = decoded.success.data.project?.repository?.blobs?.nodes;
1020 if (nodes === undefined || nodes === null) return Result.succeed(null);
1021 const blobs = new Map<string, string>();
1022 for (const node of nodes) {
1023 // Not trimmed, unlike everything else read out of this payload: a leading or trailing space
1024 // is a legal part of a file's name, and trimming it would key this map under a name the
1025 // caller's asked-for path never matches.
1026 const path = node?.path;
1027 const oid = trimmed(node?.oid);
1028 if (path === undefined || path === null || path.length === 0 || oid === null) continue;
1029 blobs.set(path, oid);
1030 }
1031 return Result.succeed(blobs);
1032}
apps/server/src/server.ts-2
......@@ -54,7 +54,6 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
5454import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";
5555import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
5656import * as GitHubApi from "./sourceControl/GitHubApi.ts";
57import * as GitLabCli from "./sourceControl/GitLabCli.ts";
5857import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
5958import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";
6059import * as TextGeneration from "./textGeneration/TextGeneration.ts";
......@@ -302,7 +301,6 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p
302301 Layer.mergeAll(
303302 BitbucketApi.layer,
304303 GitHubApi.layerWithDependencies,
305 GitLabCli.layer,
306304 SourceControlBuiltInDrivers.layer,
307305 ),
308306 ),
apps/server/src/sourceControl/GitLabCli.test.ts deleted-384
......@@ -1,384 +0,0 @@
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 * as VcsProcess from "../vcs/VcsProcess.ts";
9import * as GitLabCli from "./GitLabCli.ts";
10
11const mockedRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>();
12const layer = it.layer(
13 GitLabCli.layer.pipe(
14 Layer.provide(
15 Layer.mock(VcsProcess.VcsProcess)({
16 run: mockedRun,
17 }),
18 ),
19 ),
20);
21
22function processOutput(stdout: string): VcsProcess.VcsProcessOutput {
23 return {
24 exitCode: ChildProcessSpawner.ExitCode(0),
25 stdout,
26 stderr: "",
27 stdoutTruncated: false,
28 stderrTruncated: false,
29 };
30}
31
32afterEach(() => {
33 mockedRun.mockReset();
34});
35
36layer("GitLabCli.layer", (it) => {
37 it.effect("parses merge request view output", () =>
38 Effect.gen(function* () {
39 mockedRun.mockReturnValueOnce(
40 Effect.succeed(
41 processOutput(
42 JSON.stringify({
43 iid: 42,
44 title: "Add MR thread creation",
45 web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
46 target_branch: "main",
47 source_branch: "feature/mr-threads",
48 state: "closed",
49 closed_at: "2026-08-23T10:00:00Z",
50 source_project_id: 101,
51 target_project_id: 100,
52 source_project: {
53 path_with_namespace: "octocat/t3code",
54 },
55 }),
56 ),
57 ),
58 );
59
60 const result = yield* Effect.gen(function* () {
61 const glab = yield* GitLabCli.GitLabCli;
62 return yield* glab.getMergeRequest({
63 cwd: "/repo",
64 reference: "42",
65 });
66 });
67
68 assert.deepStrictEqual(result, {
69 number: 42,
70 title: "Add MR thread creation",
71 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
72 baseRefName: "main",
73 headRefName: "feature/mr-threads",
74 state: "closed",
75 closedAt: "2026-08-23T10:00:00Z",
76 mergedAt: null,
77 isCrossRepository: true,
78 headRepositoryNameWithOwner: "octocat/t3code",
79 headRepositoryOwnerLogin: "octocat",
80 });
81 expect(mockedRun).toHaveBeenCalledWith(
82 expect.objectContaining({
83 command: "glab",
84 cwd: "/repo",
85 args: ["mr", "view", "42", "--output", "json"],
86 }),
87 );
88 }),
89 );
90
91 it.effect("skips invalid entries when parsing MR lists", () =>
92 Effect.gen(function* () {
93 mockedRun.mockReturnValueOnce(
94 Effect.succeed(
95 processOutput(
96 JSON.stringify([
97 {
98 iid: 0,
99 title: "invalid",
100 web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/0",
101 target_branch: "main",
102 source_branch: "feature/invalid",
103 },
104 {
105 iid: 43,
106 title: " Valid MR ",
107 web_url: " https://gitlab.com/pingdotgg/t3code/-/merge_requests/43 ",
108 target_branch: " main ",
109 source_branch: " feature/mr-list ",
110 state: "merged",
111 merged_at: "2026-08-23T11:00:00Z",
112 },
113 ]),
114 ),
115 ),
116 );
117
118 const result = yield* Effect.gen(function* () {
119 const glab = yield* GitLabCli.GitLabCli;
120 return yield* glab.listMergeRequests({
121 cwd: "/repo",
122 headSelector: "feature/mr-list",
123 state: "all",
124 });
125 });
126
127 assert.deepStrictEqual(result, [
128 {
129 number: 43,
130 title: "Valid MR",
131 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/43",
132 baseRefName: "main",
133 headRefName: "feature/mr-list",
134 state: "merged",
135 closedAt: null,
136 mergedAt: "2026-08-23T11:00:00Z",
137 },
138 ]);
139 expect(mockedRun).toHaveBeenCalledWith(
140 expect.objectContaining({
141 command: "glab",
142 cwd: "/repo",
143 args: [
144 "mr",
145 "list",
146 "--source-branch",
147 "feature/mr-list",
148 "--all",
149 "--per-page",
150 "20",
151 "--output",
152 "json",
153 ],
154 }),
155 );
156 }),
157 );
158
159 it.effect("reads repository clone URLs", () =>
160 Effect.gen(function* () {
161 mockedRun.mockReturnValueOnce(
162 Effect.succeed(
163 processOutput(
164 JSON.stringify({
165 path_with_namespace: "octocat/t3code",
166 web_url: "https://gitlab.com/octocat/t3code",
167 http_url_to_repo: "https://gitlab.com/octocat/t3code.git",
168 ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git",
169 }),
170 ),
171 ),
172 );
173
174 const result = yield* Effect.gen(function* () {
175 const glab = yield* GitLabCli.GitLabCli;
176 return yield* glab.getRepositoryCloneUrls({
177 cwd: "/repo",
178 repository: "octocat/t3code",
179 });
180 });
181
182 assert.deepStrictEqual(result, {
183 nameWithOwner: "octocat/t3code",
184 url: "https://gitlab.com/octocat/t3code",
185 sshUrl: "git@gitlab.com:octocat/t3code.git",
186 });
187 }),
188 );
189
190 it.effect("creates merge requests through the GitLab API without placing the body in argv", () =>
191 Effect.gen(function* () {
192 mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("{}")));
193
194 const glab = yield* GitLabCli.GitLabCli;
195 yield* glab.createMergeRequest({
196 cwd: "/repo",
197 baseBranch: "main",
198 headSelector: "owner:feature/provider",
199 title: "Provider MR",
200 bodyFile: "/tmp/t3-mr-body.md",
201 });
202
203 expect(mockedRun).toHaveBeenCalledWith(
204 expect.objectContaining({
205 command: "glab",
206 cwd: "/repo",
207 args: [
208 "api",
209 "--method",
210 "POST",
211 "projects/:fullpath/merge_requests",
212 "--raw-field",
213 "source_branch=feature/provider",
214 "--raw-field",
215 "target_branch=main",
216 "--raw-field",
217 "title=Provider MR",
218 "--field",
219 "description=@/tmp/t3-mr-body.md",
220 ],
221 }),
222 );
223 }),
224 );
225
226 it.effect("creates repositories under an explicit namespace", () =>
227 Effect.gen(function* () {
228 mockedRun
229
230 .mockReturnValueOnce(Effect.succeed(processOutput(JSON.stringify({ id: 1234 }))))
231 .mockReturnValueOnce(
232 Effect.succeed(
233 processOutput(
234 JSON.stringify({
235 path_with_namespace: "octocat/t3code",
236 web_url: "https://gitlab.com/octocat/t3code",
237 http_url_to_repo: "https://gitlab.com/octocat/t3code.git",
238 ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git",
239 }),
240 ),
241 ),
242 );
243
244 const glab = yield* GitLabCli.GitLabCli;
245 const result = yield* glab.createRepository({
246 cwd: "/repo",
247 repository: "octocat/t3code",
248 visibility: "public",
249 });
250
251 assert.deepStrictEqual(result, {
252 nameWithOwner: "octocat/t3code",
253 url: "https://gitlab.com/octocat/t3code",
254 sshUrl: "git@gitlab.com:octocat/t3code.git",
255 });
256 expect(mockedRun).toHaveBeenNthCalledWith(
257 1,
258 expect.objectContaining({
259 command: "glab",
260 cwd: "/repo",
261 args: ["api", "namespaces/octocat"],
262 }),
263 );
264 expect(mockedRun).toHaveBeenNthCalledWith(
265 2,
266 expect.objectContaining({
267 command: "glab",
268 cwd: "/repo",
269 args: [
270 "api",
271 "--method",
272 "POST",
273 "projects",
274 "--raw-field",
275 "path=t3code",
276 "--raw-field",
277 "name=t3code",
278 "--raw-field",
279 "visibility=public",
280 "--raw-field",
281 "namespace_id=1234",
282 ],
283 }),
284 );
285 }),
286 );
287
288 it.effect("does not pass unsupported force flags when checking out merge requests", () =>
289 Effect.gen(function* () {
290 mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
291
292 const glab = yield* GitLabCli.GitLabCli;
293 yield* glab.checkoutMergeRequest({
294 cwd: "/repo",
295 reference: "42",
296 force: true,
297 });
298
299 expect(mockedRun).toHaveBeenCalledWith(
300 expect.objectContaining({
301 command: "glab",
302 cwd: "/repo",
303 args: ["mr", "checkout", "42"],
304 }),
305 );
306 }),
307 );
308
309 it.effect("surfaces a friendly error when the merge request is not found", () =>
310 Effect.gen(function* () {
311 const cause = new VcsProcessExitError({
312 operation: "GitLabCli.execute",
313 command: "glab",
314 cwd: "/repo",
315 exitCode: 1,
316 detail: "GET 404 merge request not found",
317 failureKind: "not-found",
318 });
319 mockedRun.mockReturnValueOnce(Effect.fail(cause));
320
321 const error = yield* Effect.gen(function* () {
322 const glab = yield* GitLabCli.GitLabCli;
323 return yield* glab.getMergeRequest({
324 cwd: "/repo",
325 reference: "4888",
326 });
327 }).pipe(Effect.flip);
328
329 assert.equal(error.message.includes("Merge request 4888 was not found"), true);
330 assert.strictEqual(error._tag, "GitLabMergeRequestNotFoundError");
331 assert.strictEqual(error.command, "glab");
332 assert.strictEqual(error.cwd, "/repo");
333 assert.strictEqual(error.cause, cause);
334 assert.equal(error.message.includes(cause.detail), false);
335 }),
336 );
337
338 it.effect("keeps non-merge-request not-found failures generic", () =>
339 Effect.gen(function* () {
340 const cause = new VcsProcessExitError({
341 operation: "GitLabCli.execute",
342 command: "glab",
343 cwd: "/repo",
344 exitCode: 1,
345 detail: "GET 404 project not found",
346 failureKind: "not-found",
347 });
348 mockedRun.mockReturnValueOnce(Effect.fail(cause));
349
350 const error = yield* Effect.gen(function* () {
351 const glab = yield* GitLabCli.GitLabCli;
352 return yield* glab.getRepositoryCloneUrls({
353 cwd: "/repo",
354 repository: "missing/project",
355 });
356 }).pipe(Effect.flip);
357
358 assert.strictEqual(error._tag, "GitLabCliCommandError");
359 assert.strictEqual(error.cause, cause);
360 }),
361 );
362
363 it.effect("preserves rate-limit failures as a distinct error", () =>
364 Effect.gen(function* () {
365 const cause = new VcsProcessExitError({
366 operation: "GitLabCli.execute",
367 command: "glab",
368 cwd: "/repo",
369 exitCode: 1,
370 detail: "API rate limit exceeded.",
371 failureKind: "rate-limited",
372 });
373 mockedRun.mockReturnValueOnce(Effect.fail(cause));
374
375 const glab = yield* GitLabCli.GitLabCli;
376 const error = yield* glab
377 .execute({ cwd: "/repo", args: ["api", "projects"] })
378 .pipe(Effect.flip);
379
380 assert.strictEqual(error._tag, "GitLabCliRateLimitError");
381 assert.strictEqual(error.cause, cause);
382 }),
383 );
384});
apps/server/src/sourceControl/GitLabCli.ts deleted-660
......@@ -1,660 +0,0 @@
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 VcsProcess from "../vcs/VcsProcess.ts";
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<VcsProcess.VcsProcessOutput, 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>()("t3/sourceControl/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* VcsProcess.VcsProcess;
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);
apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts deleted-309
......@@ -1,309 +0,0 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5import { ChildProcessSpawner } from "effect/process";
6
7import * as GitLabCli from "./GitLabCli.ts";
8import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
9import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
10
11function makeProvider(gitlab: Partial<GitLabCli.GitLabCli["Service"]>) {
12 return GitLabSourceControlProvider.make.pipe(
13 Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)),
14 );
15}
16
17it.effect("maps GitLab MR summaries into provider-neutral change requests", () =>
18 Effect.gen(function* () {
19 const provider = yield* makeProvider({
20 getMergeRequest: () =>
21 Effect.succeed({
22 number: 42,
23 title: "Add GitLab provider",
24 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
25 baseRefName: "main",
26 headRefName: "feature/source-control",
27 state: "closed",
28 closedAt: "2026-08-23T10:00:00Z",
29 isCrossRepository: true,
30 headRepositoryNameWithOwner: "fork/t3code",
31 headRepositoryOwnerLogin: "fork",
32 }),
33 });
34
35 const changeRequest = yield* provider.getChangeRequest({
36 cwd: "/repo",
37 reference: "42",
38 });
39
40 assert.deepStrictEqual(changeRequest, {
41 provider: "gitlab",
42 number: 42,
43 title: "Add GitLab provider",
44 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
45 baseRefName: "main",
46 headRefName: "feature/source-control",
47 state: "closed",
48 closedAt: "2026-08-23T10:00:00Z",
49 mergedAt: null,
50 updatedAt: Option.none(),
51 isCrossRepository: true,
52 headRepositoryNameWithOwner: "fork/t3code",
53 headRepositoryOwnerLogin: "fork",
54 });
55 }),
56);
57
58it.effect("adds repository context while retaining GitLab CLI causes", () =>
59 Effect.gen(function* () {
60 const cause = new GitLabCli.GitLabCliCommandError({
61 operation: "execute",
62 command: "glab",
63 cwd: "/repo",
64 cause: new Error("raw upstream detail that should remain in the cause"),
65 });
66 const provider = yield* makeProvider({
67 createRepository: () => Effect.fail(cause),
68 });
69
70 const error = yield* provider
71 .createRepository({
72 cwd: "/repo",
73 repository: "owner/repo",
74 visibility: "private",
75 })
76 .pipe(Effect.flip);
77
78 assert.deepStrictEqual(
79 {
80 provider: error.provider,
81 operation: error.operation,
82 command: error.command,
83 cwd: error.cwd,
84 repository: error.repository,
85 detail: error.detail,
86 },
87 {
88 provider: "gitlab",
89 operation: "createRepository",
90 command: "glab",
91 cwd: "/repo",
92 repository: "owner/repo",
93 detail: "GitLab CLI command failed.",
94 },
95 );
96 assert.strictEqual(error.cause, cause);
97 assert.equal(error.message.includes("raw upstream detail"), false);
98 }),
99);
100
101it.effect("lists GitLab MRs through provider-neutral input names", () =>
102 Effect.gen(function* () {
103 let listInput: Parameters<GitLabCli.GitLabCli["Service"]["listMergeRequests"]>[0] | null = null;
104 const provider = yield* makeProvider({
105 listMergeRequests: (input) => {
106 listInput = input;
107 return Effect.succeed([]);
108 },
109 });
110
111 yield* provider.listChangeRequests({
112 cwd: "/repo",
113 headSelector: "feature/provider",
114 state: "all",
115 limit: 10,
116 });
117
118 assert.deepStrictEqual(listInput, {
119 cwd: "/repo",
120 headSelector: "feature/provider",
121 state: "all",
122 limit: 10,
123 });
124 }),
125);
126
127it.effect("creates GitLab MRs through provider-neutral input names", () =>
128 Effect.gen(function* () {
129 let createInput: Parameters<GitLabCli.GitLabCli["Service"]["createMergeRequest"]>[0] | null =
130 null;
131 const provider = yield* makeProvider({
132 createMergeRequest: (input) => {
133 createInput = input;
134 return Effect.void;
135 },
136 });
137
138 yield* provider.createChangeRequest({
139 cwd: "/repo",
140 baseRefName: "main",
141 headSelector: "owner:feature/provider",
142 title: "Provider MR",
143 bodyFile: "/tmp/body.md",
144 });
145
146 assert.deepStrictEqual(createInput, {
147 cwd: "/repo",
148 baseBranch: "main",
149 headSelector: "owner:feature/provider",
150 source: {
151 owner: "owner",
152 refName: "feature/provider",
153 },
154 title: "Provider MR",
155 bodyFile: "/tmp/body.md",
156 });
157 }),
158);
159
160it("accepts authenticated GitLab hosts when another configured host fails", () => {
161 const auth = GitLabSourceControlProvider.discovery.parseAuth({
162 exitCode: ChildProcessSpawner.ExitCode(1),
163 stdout: `gitlab.com
164 x gitlab.com: API call failed: 401 Unauthorized
165 ! No token found
166self-hosted.example.test
167 ✓ Logged in to self-hosted.example.test as gitlab-user
168 ✓ Token found: ******
169`,
170 stderr: "",
171 });
172
173 assert.deepStrictEqual(
174 {
175 status: auth.status,
176 account: auth.account,
177 host: auth.host,
178 },
179 {
180 status: "authenticated",
181 account: Option.some("gitlab-user"),
182 host: Option.some("self-hosted.example.test"),
183 },
184 );
185});
186
187it("refines unknown GitLab remotes with mixed-case provider hosts", () => {
188 const provider = GitLabSourceControlProvider.discovery.refineUnknownRemote?.({
189 cwd: "/repo",
190 context: {
191 provider: {
192 kind: "unknown",
193 name: "Self-Hosted.Example.Test",
194 baseUrl: "https://Self-Hosted.Example.Test",
195 },
196 remoteName: "origin",
197 remoteUrl: "https://Self-Hosted.Example.Test/group/project.git",
198 },
199 auth: {
200 exitCode: ChildProcessSpawner.ExitCode(0),
201 stdout: `self-hosted.example.test
202 ✓ Logged in to self-hosted.example.test as gitlab-user
203 ✓ Token found: ******
204`,
205 stderr: "",
206 },
207 });
208
209 assert.deepStrictEqual(provider, {
210 kind: "gitlab",
211 name: "GitLab Self-Hosted",
212 baseUrl: "https://Self-Hosted.Example.Test",
213 });
214});
215
216it("parses authenticated GitLab auth status hosts with ports and single-label names", () => {
217 assert.deepStrictEqual(
218 parseGitLabAuthStatusHosts(`localhost:8080
219 ✓ Logged in to localhost:8080 as local-user
220selfhosted
221 ✓ Logged in to selfhosted as single-label-user
222`),
223 [
224 { host: "localhost:8080", account: "local-user" },
225 { host: "selfhosted", account: "single-label-user" },
226 ],
227 );
228});
229
230it.effect.each(["merge_requests", "issues"])(
231 "resolves %s subjects on the linked host without using the checkout",
232 (kind) =>
233 Effect.gen(function* () {
234 const provider = yield* makeProvider({
235 execute: (input) => {
236 assert.deepStrictEqual(input.args, [
237 "api",
238 "--hostname",
239 "gitlab.com",
240 `projects/group%2Fsubgroup%2Fproject/${kind}/42`,
241 ]);
242 assert.strictEqual(input.maxOutputBytes, 32_000);
243 assert.strictEqual(input.timeoutMs, 3_000);
244 return Effect.succeed({
245 exitCode: ChildProcessSpawner.ExitCode(0),
246 stdout: JSON.stringify({
247 title: "Pairing expiry",
248 description: "Preserve remote access",
249 }),
250 stderr: "",
251 stdoutTruncated: false,
252 stderrTruncated: false,
253 });
254 },
255 });
256 const lookup = provider.resolveLink?.({
257 cwd: "/unrelated",
258 url: new URL(`https://gitlab.com/group/subgroup/project/-/${kind}/42`),
259 });
260 assert.ok(lookup);
261 assert.deepStrictEqual(yield* lookup, {
262 title: "Pairing expiry",
263 body: "Preserve remote access",
264 });
265 assert.strictEqual(
266 provider.resolveLink?.({
267 cwd: "/unrelated",
268 url: new URL("https://gitlab.com/owner/repo"),
269 }),
270 undefined,
271 );
272 }),
273);
274
275it.effect.each(["read", "decode"] as const)(
276 "retains the %s failure without exposing its raw contents",
277 (stage) =>
278 Effect.gen(function* () {
279 const cause = new GitLabCli.GitLabCliCommandError({
280 command: "glab",
281 cwd: "/repo",
282 operation: "execute",
283 cause: new Error("private response text"),
284 });
285 const provider = yield* makeProvider({
286 execute: () =>
287 stage === "read"
288 ? Effect.fail(cause)
289 : Effect.succeed({
290 exitCode: ChildProcessSpawner.ExitCode(0),
291 stdout: "private response text",
292 stderr: "",
293 stdoutTruncated: false,
294 stderrTruncated: false,
295 }),
296 });
297 const lookup = provider.resolveLink?.({
298 cwd: "/repo",
299 url: new URL("https://gitlab.com/owner/repo/-/issues/42"),
300 });
301 assert.ok(lookup);
302 const error = yield* Effect.flip(lookup);
303 assert.strictEqual(error.operation, stage === "read" ? "resolveLink" : "resolveLink.decode");
304 assert.strictEqual(error.detail, "The linked subject could not be read.");
305 assert.notInclude(error.message, "private response text");
306 if (stage === "read") assert.strictEqual(error.cause, cause);
307 else assert.propertyVal(error.cause, "_tag", "SchemaError");
308 }),
309);
apps/server/src/sourceControl/GitLabSourceControlProvider.ts deleted-308
......@@ -1,308 +0,0 @@
1import * as Schema from "effect/Schema";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
5
6import * as GitLabCli from "./GitLabCli.ts";
7import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
8import {
9 combinedAuthOutput,
10 firstSafeAuthLine,
11 matchFirst,
12 parseCliHost,
13 providerAuth,
14 type SourceControlAuthProbeInput,
15 type SourceControlCliDiscoverySpec,
16 type SourceControlUnknownRemoteRefinementInput,
17} from "@t3tools/source-control-core/server/discovery";
18import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
19
20const decodeLinkSubject = Schema.decodeUnknownEffect(
21 Schema.fromJsonString(
22 Schema.Struct({ title: Schema.String, description: Schema.NullOr(Schema.String) }),
23 ),
24);
25
26function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest {
27 return {
28 provider: "gitlab",
29 number: summary.number,
30 title: summary.title,
31 url: summary.url,
32 baseRefName: summary.baseRefName,
33 headRefName: summary.headRefName,
34 state: summary.state ?? "open",
35 ...(summary.isDraft === true ? { isDraft: true } : {}),
36 closedAt: summary.closedAt ?? null,
37 mergedAt: summary.mergedAt ?? null,
38 updatedAt: summary.updatedAt ?? Option.none(),
39 ...(summary.isCrossRepository !== undefined
40 ? { isCrossRepository: summary.isCrossRepository }
41 : {}),
42 ...(summary.headRepositoryNameWithOwner !== undefined
43 ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner }
44 : {}),
45 ...(summary.headRepositoryOwnerLogin !== undefined
46 ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin }
47 : {}),
48 };
49}
50
51function parseGitLabAuth(input: SourceControlAuthProbeInput) {
52 const output = combinedAuthOutput(input);
53 const authenticatedHost = findAuthenticatedGitLabHost(parseGitLabAuthStatusHosts(output));
54 const account =
55 authenticatedHost?.account ??
56 matchFirst(output, [
57 /Logged in to .* as\s+([^\s(]+)/iu,
58 /Logged in to .* account\s+([^\s(]+)/iu,
59 /account:\s*([^\s(]+)/iu,
60 ]);
61 const host = authenticatedHost?.host ?? parseCliHost(output);
62
63 if (account) {
64 return providerAuth({ status: "authenticated", account, host });
65 }
66
67 if (input.exitCode !== 0) {
68 return providerAuth({
69 status: "unauthenticated",
70 host,
71 detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.",
72 });
73 }
74
75 return providerAuth({
76 status: "unknown",
77 host,
78 detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.",
79 });
80}
81
82function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementInput) {
83 const host = input.context.provider.name.toLowerCase();
84 const authenticated = parseGitLabAuthStatusHosts(combinedAuthOutput(input.auth)).some(
85 (entry) => entry.account !== null && entry.host === host,
86 );
87
88 if (!authenticated) {
89 return null;
90 }
91
92 return {
93 kind: "gitlab",
94 name: "GitLab Self-Hosted",
95 baseUrl: input.context.provider.baseUrl,
96 } as const;
97}
98
99export const discovery = {
100 type: "cli",
101 kind: "gitlab",
102 label: "GitLab",
103 executable: "glab",
104 versionArgs: ["--version"],
105 authArgs: ["auth", "status"],
106 parseAuth: parseGitLabAuth,
107 refineUnknownRemote: refineUnknownGitLabRemote,
108 installHint:
109 "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).",
110} satisfies SourceControlCliDiscoverySpec;
111
112export const make = Effect.gen(function* () {
113 const gitlab = yield* GitLabCli.GitLabCli;
114
115 const readLinkSubject = Effect.fn("GitLabSourceControlProvider.readLinkSubject")(function* (
116 input: { readonly cwd: string; readonly url: URL },
117 endpoint: string,
118 ) {
119 const result = yield* gitlab
120 .execute({
121 cwd: input.cwd,
122 args: ["api", "--hostname", input.url.host, endpoint],
123 timeoutMs: 3_000,
124 maxOutputBytes: 32_000,
125 })
126 .pipe(
127 Effect.mapError(
128 (cause) =>
129 new SourceControlProviderError({
130 provider: "gitlab",
131 operation: "resolveLink",
132 cwd: input.cwd,
133 detail: "The linked subject could not be read.",
134 cause,
135 }),
136 ),
137 );
138 const subject = yield* decodeLinkSubject(result.stdout).pipe(
139 Effect.mapError(
140 (cause) =>
141 new SourceControlProviderError({
142 provider: "gitlab",
143 operation: "resolveLink.decode",
144 cwd: input.cwd,
145 detail: "The linked subject could not be read.",
146 cause,
147 }),
148 ),
149 );
150 return { title: subject.title, body: subject.description };
151 });
152
153 return SourceControlProvider.SourceControlProvider.of({
154 kind: "gitlab",
155 resolveLink: (input) => {
156 // Automatic enrichment must not send ambient CLI credentials to a host from message text.
157 if (input.url.host !== "gitlab.com") return undefined;
158 const match = /^\/(.+)\/-\/(merge_requests|issues)\/([1-9]\d*)(?:\/.*)?$/.exec(
159 input.url.pathname,
160 );
161 if (!match) return undefined;
162 return readLinkSubject(
163 input,
164 `projects/${encodeURIComponent(match[1]!)}/${match[2]}/${match[3]}`,
165 );
166 },
167 listChangeRequests: (input) => {
168 const source = SourceControlProvider.sourceControlRefFromInput(input);
169 return gitlab
170 .listMergeRequests({
171 cwd: input.cwd,
172 headSelector: input.headSelector,
173 ...(source ? { source } : {}),
174 state: input.state,
175 ...(input.limit !== undefined ? { limit: input.limit } : {}),
176 })
177 .pipe(
178 Effect.map((items) => items.map(toChangeRequest)),
179 Effect.mapError(
180 (error) =>
181 new SourceControlProviderError({
182 provider: "gitlab",
183 operation: "listChangeRequests",
184 command: error.command,
185 cwd: input.cwd,
186 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
187 input.headSelector,
188 ),
189 detail: error.detail,
190 cause: error,
191 }),
192 ),
193 );
194 },
195 getChangeRequest: (input) =>
196 gitlab.getMergeRequest(input).pipe(
197 Effect.map(toChangeRequest),
198 Effect.mapError(
199 (error) =>
200 new SourceControlProviderError({
201 provider: "gitlab",
202 operation: "getChangeRequest",
203 command: error.command,
204 cwd: input.cwd,
205 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
206 input.reference,
207 ),
208 detail: error.detail,
209 cause: error,
210 }),
211 ),
212 ),
213 createChangeRequest: (input) => {
214 const source = SourceControlProvider.sourceControlRefFromInput(input);
215 return gitlab
216 .createMergeRequest({
217 cwd: input.cwd,
218 baseBranch: input.baseRefName,
219 headSelector: input.headSelector,
220 ...(source ? { source } : {}),
221 ...(input.target ? { target: input.target } : {}),
222 title: input.title,
223 bodyFile: input.bodyFile,
224 })
225 .pipe(
226 Effect.mapError(
227 (error) =>
228 new SourceControlProviderError({
229 provider: "gitlab",
230 operation: "createChangeRequest",
231 command: error.command,
232 cwd: input.cwd,
233 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
234 input.headSelector,
235 ),
236 detail: error.detail,
237 cause: error,
238 }),
239 ),
240 );
241 },
242 getRepositoryCloneUrls: (input) =>
243 gitlab.getRepositoryCloneUrls(input).pipe(
244 Effect.mapError(
245 (error) =>
246 new SourceControlProviderError({
247 provider: "gitlab",
248 operation: "getRepositoryCloneUrls",
249 command: error.command,
250 cwd: input.cwd,
251 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
252 input.repository,
253 ),
254 detail: error.detail,
255 cause: error,
256 }),
257 ),
258 ),
259 createRepository: (input) =>
260 gitlab.createRepository(input).pipe(
261 Effect.mapError(
262 (error) =>
263 new SourceControlProviderError({
264 provider: "gitlab",
265 operation: "createRepository",
266 command: error.command,
267 cwd: input.cwd,
268 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
269 input.repository,
270 ),
271 detail: error.detail,
272 cause: error,
273 }),
274 ),
275 ),
276 getDefaultBranch: (input) =>
277 gitlab.getDefaultBranch(input).pipe(
278 Effect.mapError(
279 (error) =>
280 new SourceControlProviderError({
281 provider: "gitlab",
282 operation: "getDefaultBranch",
283 command: error.command,
284 cwd: input.cwd,
285 detail: error.detail,
286 cause: error,
287 }),
288 ),
289 ),
290 checkoutChangeRequest: (input) =>
291 gitlab.checkoutMergeRequest(input).pipe(
292 Effect.mapError(
293 (error) =>
294 new SourceControlProviderError({
295 provider: "gitlab",
296 operation: "checkoutChangeRequest",
297 command: error.command,
298 cwd: input.cwd,
299 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
300 input.reference,
301 ),
302 detail: error.detail,
303 cause: error,
304 }),
305 ),
306 ),
307 });
308});
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+3-1
......@@ -20,7 +20,8 @@ import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/Azu
2020import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli";
2121import * as BitbucketApi from "./BitbucketApi.ts";
2222import * as GitHubApi from "./GitHubApi.ts";
23import * as GitLabCli from "./GitLabCli.ts";
23import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
24import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";
2425import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
2526import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider";
2627import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider";
......@@ -46,6 +47,7 @@ const layerSourceControlProviderRegistryTest = (input: {
4647 Layer.mock(GitHubApi.GitHubApi)({}),
4748 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
4849 Layer.mock(GitLabCli.GitLabCli)({}),
50 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}),
4951 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
5052 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
5153 Layer.mock(VcsProcess.VcsProcess)(input.process),
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+3-1
......@@ -19,7 +19,8 @@ import * as BitbucketApi from "./BitbucketApi.ts";
1919import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
2020import * as GitHubApi from "./GitHubApi.ts";
2121import * as GitHubCredentials from "./GitHubCredentials.ts";
22import * as GitLabCli from "./GitLabCli.ts";
22import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
23import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";
2324import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
2425import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
2526import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
......@@ -114,6 +115,7 @@ function makeRegistry(input: {
114115 }),
115116 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
116117 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),
118 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}),
117119 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
118120 ServerConfig.layerTest(process.cwd(), {
119121 prefix: "t3-source-control-registry-test-",
apps/server/src/sourceControl/SourceControlProviderRegistry.ts-7
......@@ -13,7 +13,6 @@ import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/source
1313
1414import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
1515import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
16import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
1716import * as BuiltInDrivers from "./builtInDrivers.ts";
1817import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
1918import {
......@@ -306,7 +305,6 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
306305export const make = Effect.gen(function* () {
307306 const github = yield* GitHubSourceControlProvider.make;
308307 const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery;
309 const gitlab = yield* GitLabSourceControlProvider.make;
310308 const bitbucket = yield* BitbucketSourceControlProvider.make;
311309 const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery;
312310 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>
......@@ -326,11 +324,6 @@ export const make = Effect.gen(function* () {
326324 provider: github,
327325 discovery: githubDiscovery,
328326 },
329 {
330 kind: "gitlab",
331 provider: gitlab,
332 discovery: GitLabSourceControlProvider.discovery,
333 },
334327 {
335328 kind: "bitbucket",
336329 provider: bitbucket,
apps/server/src/sourceControl/builtInDrivers.ts+5-1
......@@ -12,12 +12,15 @@ import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops
1212import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver";
1313import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
1414import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver";
15import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
16import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";
17import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver";
1518import type { SourceControlDriver } from "@t3tools/source-control-core/server/driver";
1619import * as Layer from "effect/Layer";
1720
1821import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
1922
20const drivers = [AzureDevOpsDriver.driver, ForgejoDriver.driver];
23const drivers = [GitLabDriver.driver, AzureDevOpsDriver.driver, ForgejoDriver.driver];
2124
2225/** Every service a built-in driver's `make` needs; the server's layers must provide them all. */
2326export type BuiltInSourceControlDriversEnv =
......@@ -31,4 +34,5 @@ export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray<
3134export const layer = Layer.mergeAll(
3235 AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)),
3336 ForgejoCli.layer,
37 GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)),
3438).pipe(Layer.provideMerge(ServerSourceControlHost.layer));
apps/server/src/sourceControl/gitLabAuthStatus.ts deleted-48
......@@ -1,48 +0,0 @@
1const HOST_LINE_PATTERN = /^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\[[a-f0-9:.]+\])(?::\d+)?$/iu;
2const LOGGED_IN_PATTERN = /Logged in to .+? as\s+([^\s(]+)/iu;
3
4export interface GitLabAuthStatusHost {
5 readonly host: string;
6 readonly account: string | null;
7}
8
9export function parseGitLabAuthStatusHosts(text: string): ReadonlyArray<GitLabAuthStatusHost> {
10 const hosts: GitLabAuthStatusHost[] = [];
11 let currentHost: string | null = null;
12 let currentLines: string[] = [];
13
14 const flush = () => {
15 if (currentHost === null) return;
16
17 const account = LOGGED_IN_PATTERN.exec(currentLines.join("\n"))?.[1]?.trim() || null;
18 hosts.push({ host: currentHost, account });
19 currentHost = null;
20 currentLines = [];
21 };
22
23 for (const rawLine of text.split(/\r?\n/)) {
24 const line = rawLine.trim();
25 if (line.length === 0) continue;
26
27 const isHostLine =
28 rawLine.length === rawLine.trimStart().length && HOST_LINE_PATTERN.test(line);
29 if (isHostLine) {
30 flush();
31 currentHost = line.toLowerCase();
32 continue;
33 }
34
35 if (currentHost !== null) {
36 currentLines.push(line);
37 }
38 }
39
40 flush();
41 return hosts;
42}
43
44export function findAuthenticatedGitLabHost(
45 hosts: ReadonlyArray<GitLabAuthStatusHost>,
46): GitLabAuthStatusHost | undefined {
47 return hosts.find((host) => host.account !== null);
48}
apps/server/src/sourceControl/gitLabMergeRequests.ts deleted-161
......@@ -1,161 +0,0 @@
1import * as Cause from "effect/Cause";
2import * as DateTime from "effect/DateTime";
3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";
7import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
8import { decodeJsonResult } from "@t3tools/shared/schemaJson";
9
10export interface NormalizedGitLabMergeRequestRecord {
11 readonly number: number;
12 readonly title: string;
13 readonly url: string;
14 readonly baseRefName: string;
15 readonly headRefName: string;
16 readonly state: "open" | "closed" | "merged";
17 readonly isDraft?: boolean;
18 readonly closedAt?: string | null;
19 readonly mergedAt?: string | null;
20 readonly updatedAt: Option.Option<DateTime.Utc>;
21 readonly isCrossRepository?: boolean;
22 readonly headRepositoryNameWithOwner?: string | null;
23 readonly headRepositoryOwnerLogin?: string | null;
24}
25
26const GitLabProjectReferenceSchema = Schema.Struct({
27 path_with_namespace: Schema.optional(Schema.String),
28 pathWithNamespace: Schema.optional(Schema.String),
29 namespace: Schema.optional(
30 Schema.NullOr(
31 Schema.Struct({
32 path: Schema.optional(Schema.String),
33 full_path: Schema.optional(Schema.String),
34 fullPath: Schema.optional(Schema.String),
35 }),
36 ),
37 ),
38});
39
40const GitLabMergeRequestSchema = Schema.Struct({
41 iid: PositiveInt,
42 title: TrimmedNonEmptyString,
43 web_url: TrimmedNonEmptyString,
44 source_branch: TrimmedNonEmptyString,
45 target_branch: TrimmedNonEmptyString,
46 state: Schema.optional(Schema.NullOr(Schema.String)),
47 draft: Schema.optional(Schema.Boolean),
48 work_in_progress: Schema.optional(Schema.Boolean),
49 closed_at: Schema.optional(Schema.NullOr(Schema.String)),
50 merged_at: Schema.optional(Schema.NullOr(Schema.String)),
51 updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
52 source_project_id: Schema.optional(Schema.NullOr(Schema.Number)),
53 target_project_id: Schema.optional(Schema.NullOr(Schema.Number)),
54 source_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)),
55 target_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)),
56});
57
58function trimOptionalString(value: string | null | undefined): string | null {
59 const trimmed = value?.trim() ?? "";
60 return trimmed.length > 0 ? trimmed : null;
61}
62
63function normalizeGitLabMergeRequestState(
64 state: string | null | undefined,
65): "open" | "closed" | "merged" {
66 const normalized = state?.trim().toLowerCase();
67 if (normalized === "merged") {
68 return "merged";
69 }
70 if (normalized === "closed") {
71 return "closed";
72 }
73 return "open";
74}
75
76function projectPathWithNamespace(
77 project: Schema.Schema.Type<typeof GitLabProjectReferenceSchema> | null | undefined,
78): string | null {
79 const explicit =
80 trimOptionalString(project?.path_with_namespace) ??
81 trimOptionalString(project?.pathWithNamespace);
82 if (explicit) {
83 return explicit;
84 }
85
86 const namespacePath =
87 trimOptionalString(project?.namespace?.full_path) ??
88 trimOptionalString(project?.namespace?.fullPath) ??
89 trimOptionalString(project?.namespace?.path);
90 return namespacePath;
91}
92
93function ownerLoginFromPathWithNamespace(pathWithNamespace: string | null): string | null {
94 const [owner] = pathWithNamespace?.split("/") ?? [];
95 return trimOptionalString(owner);
96}
97
98function normalizeGitLabMergeRequestRecord(
99 raw: Schema.Schema.Type<typeof GitLabMergeRequestSchema>,
100): NormalizedGitLabMergeRequestRecord {
101 const sourceProjectPath = projectPathWithNamespace(raw.source_project);
102 const targetProjectPath = projectPathWithNamespace(raw.target_project);
103 const isCrossRepository =
104 typeof raw.source_project_id === "number" && typeof raw.target_project_id === "number"
105 ? raw.source_project_id !== raw.target_project_id
106 : sourceProjectPath !== null && targetProjectPath !== null
107 ? sourceProjectPath.toLowerCase() !== targetProjectPath.toLowerCase()
108 : undefined;
109 const headRepositoryOwnerLogin = ownerLoginFromPathWithNamespace(sourceProjectPath);
110
111 return {
112 number: raw.iid,
113 title: raw.title,
114 url: raw.web_url,
115 baseRefName: raw.target_branch,
116 headRefName: raw.source_branch,
117 state: normalizeGitLabMergeRequestState(raw.state),
118 ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}),
119 closedAt: raw.closed_at ?? null,
120 mergedAt: raw.merged_at ?? null,
121 updatedAt: raw.updated_at ?? Option.none(),
122 ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}),
123 ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}),
124 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
125 };
126}
127
128const decodeGitLabMergeRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
129const decodeGitLabMergeRequest = decodeJsonResult(GitLabMergeRequestSchema);
130const decodeGitLabMergeRequestEntry = Schema.decodeUnknownExit(GitLabMergeRequestSchema);
131
132export function decodeGitLabMergeRequestListJson(
133 raw: string,
134): Result.Result<
135 ReadonlyArray<NormalizedGitLabMergeRequestRecord>,
136 Cause.Cause<Schema.SchemaError>
137> {
138 const result = decodeGitLabMergeRequestList(raw);
139 if (Result.isSuccess(result)) {
140 const mergeRequests: NormalizedGitLabMergeRequestRecord[] = [];
141 for (const entry of result.success) {
142 const decodedEntry = decodeGitLabMergeRequestEntry(entry);
143 if (Exit.isFailure(decodedEntry)) {
144 continue;
145 }
146 mergeRequests.push(normalizeGitLabMergeRequestRecord(decodedEntry.value));
147 }
148 return Result.succeed(mergeRequests);
149 }
150 return Result.fail(result.failure);
151}
152
153export function decodeGitLabMergeRequestJson(
154 raw: string,
155): Result.Result<NormalizedGitLabMergeRequestRecord, Cause.Cause<Schema.SchemaError>> {
156 const result = decodeGitLabMergeRequest(raw);
157 if (Result.isSuccess(result)) {
158 return Result.succeed(normalizeGitLabMergeRequestRecord(result.success));
159 }
160 return Result.fail(result.failure);
161}
apps/server/src/ws.ts-2
......@@ -231,7 +231,6 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.
231231import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
232232import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
233233import * as GitHubApi from "./sourceControl/GitHubApi.ts";
234import * as GitLabCli from "./sourceControl/GitLabCli.ts";
235234import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";
236235import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
237236import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
......@@ -3205,7 +3204,6 @@ export const layer = Layer.unwrap(
32053204 Layer.mergeAll(
32063205 BitbucketApi.layer,
32073206 GitHubApi.layerWithDependencies,
3208 GitLabCli.layer,
32093207 SourceControlBuiltInDrivers.layer,
32103208 ),
32113209 ),
docs/internals/pull-request-file-revisions.md+2-2
......@@ -7,7 +7,7 @@ one. No type distinguishes those meanings and no single file states the whole pa
77this page is for.
88
99The failure is on record rather than hypothetical. The interface doc above
10[GitLab's `getFileRevisions`](../../apps/server/src/pullRequest/GitLabPullRequestCli.ts) once said
10[GitLab's `getFileRevisions`](../../packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts) once said
1111the opposite of the code beneath it, because it described a missing path the way the layer below
1212means it. Anything written against that reading would treat a version the host declined to give as
1313a file the change request deleted, which reports every file a reader has cleared as changed across
......@@ -30,7 +30,7 @@ What "never got to look at" is belongs to the host, and the contract cannot know
3030- [Bitbucket](../../apps/server/src/pullRequest/BitbucketPullRequestApi.ts) reads them off the
3131 pull request's own patch, the only place it states a file's version, so a patch cut short at the
3232 byte ceiling leaves the paths past the cut out.
33- [GitLab](../../apps/server/src/pullRequest/GitLabPullRequestCli.ts) asks in batches, so a batch
33- [GitLab](../../packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts) asks in batches, so a batch
3434 it could not read leaves that batch's paths out.
3535
3636Those three facts stay with their providers. The contract states only that absence means the read
package.json+1-1
......@@ -28,7 +28,7 @@
2828 "tc": "vp run -r --concurrency-limit 2 typecheck",
2929 "lint": "vp lint --report-unused-disable-directives",
3030 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",
31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-forgejo --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-forgejo --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
3232 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",
3333 "lint:mobile": "node scripts/mobile-native-static-check.ts",
3434 "test": "vp run -r test",
packages/source-control-gitlab/package.json created+55
......@@ -0,0 +1,55 @@
1{
2 "name": "@t3tools/source-control-gitlab",
3 "private": true,
4 "type": "module",
5 "exports": {
6 "./server/driver": {
7 "types": "./src/server/driver.ts",
8 "import": "./src/server/driver.ts"
9 },
10 "./server/gitLabAuthStatus": {
11 "types": "./src/server/gitLabAuthStatus.ts",
12 "import": "./src/server/gitLabAuthStatus.ts"
13 },
14 "./server/GitLabCli": {
15 "types": "./src/server/GitLabCli.ts",
16 "import": "./src/server/GitLabCli.ts"
17 },
18 "./server/gitLabMergeRequestJson": {
19 "types": "./src/server/gitLabMergeRequestJson.ts",
20 "import": "./src/server/gitLabMergeRequestJson.ts"
21 },
22 "./server/gitLabMergeRequests": {
23 "types": "./src/server/gitLabMergeRequests.ts",
24 "import": "./src/server/gitLabMergeRequests.ts"
25 },
26 "./server/GitLabPullRequestCli": {
27 "types": "./src/server/GitLabPullRequestCli.ts",
28 "import": "./src/server/GitLabPullRequestCli.ts"
29 },
30 "./server/GitLabPullRequestProvider": {
31 "types": "./src/server/GitLabPullRequestProvider.ts",
32 "import": "./src/server/GitLabPullRequestProvider.ts"
33 },
34 "./server/GitLabSourceControlProvider": {
35 "types": "./src/server/GitLabSourceControlProvider.ts",
36 "import": "./src/server/GitLabSourceControlProvider.ts"
37 }
38 },
39 "scripts": {
40 "typecheck": "tsc --noEmit",
41 "test": "vp test run --config ../../vite.config.ts --dir ."
42 },
43 "dependencies": {
44 "@t3tools/contracts": "workspace:*",
45 "@t3tools/shared": "workspace:*",
46 "@t3tools/source-control-core": "workspace:*",
47 "effect": "catalog:"
48 },
49 "devDependencies": {
50 "@effect/vitest": "catalog:",
51 "@t3tools/source-control-testing": "workspace:*",
52 "@types/node": "catalog:",
53 "vite-plus": "catalog:"
54 }
55}
packages/source-control-gitlab/src/server/GitLabCli.test.ts created+379
......@@ -0,0 +1,379 @@
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});
packages/source-control-gitlab/src/server/GitLabCli.ts created+660
......@@ -0,0 +1,660 @@
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);
packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts created+1536
......@@ -0,0 +1,1536 @@
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});
packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts created+1495
......@@ -0,0 +1,1495 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Result from "effect/Result";
5import * as Schema from "effect/Schema";
6import type {
7 PullRequestAction,
8 PullRequestComment,
9 PullRequestCommit,
10 PullRequestInvolvement,
11 PullRequestListState,
12 PullRequestMergeCapabilities,
13 PullRequestMergeMethod,
14 PullRequestReaction,
15 PullRequestReactionContent,
16 PullRequestReviewCommentDraft,
17 PullRequestReviewPosition,
18 PullRequestReviewThread,
19 PullRequestReviewVerdict,
20 PullRequestReviewerCandidateList,
21} from "@t3tools/contracts";
22
23import * as GitLabCli from "./GitLabCli.ts";
24import {
25 AWARD_EMOJI_GRAPHQL_QUERY,
26 decodeAwardEmojiJson,
27 decodeCommitDiffRefsJson,
28 decodeCommitsJson,
29 decodeDiffRefsJson,
30 decodeDiscussionsJson,
31 decodeMergeRequestDetailJson,
32 decodeMergeRequestDiffsJson,
33 decodeMergeRequestListJson,
34 decodeNotesJson,
35 decodeOwnAwardIdJson,
36 decodeProjectMergeCapabilitiesJson,
37 decodeProjectUsersJson,
38 decodeRepositoryBlobsJson,
39 decodeViewerJson,
40 gitLabAwardName,
41 REPOSITORY_BLOBS_GRAPHQL_QUERY,
42 type GitLabDiffRefs,
43 type GitLabMergeRequestDetail,
44 type GitLabMergeRequestListItem,
45 type GitLabProjectUsers,
46} from "./gitLabMergeRequestJson.ts";
47import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider";
48
49/**
50 * Names the read that produced unusable output, so a failure reports the call it came from
51 * rather than borrowing another operation's message.
52 */
53export class GitLabMergeRequestReadError extends Schema.TaggedError<GitLabMergeRequestReadError>()(
54 "GitLabMergeRequestReadError",
55 {
56 command: Schema.Literal("glab"),
57 cwd: Schema.String,
58 operation: Schema.String,
59 cause: Schema.Defect(),
60 },
61) {
62 get detail(): string {
63 return `GitLab CLI returned an unreadable ${this.operation} response.`;
64 }
65
66 override get message(): string {
67 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
68 }
69}
70
71/** Not a decode failure: glab answered, the account it answered for just has no username. */
72export class GitLabViewerUnavailableError extends Schema.TaggedError<GitLabViewerUnavailableError>()(
73 "GitLabViewerUnavailableError",
74 {
75 command: Schema.Literal("glab"),
76 cwd: Schema.String,
77 },
78) {
79 get detail(): string {
80 return "GitLab CLI returned no username for the authenticated account.";
81 }
82
83 override get message(): string {
84 return `GitLab CLI failed in getViewerUsername: ${this.detail}`;
85 }
86}
87
88/** Not a decode failure: GitLab answered, the merge request just has no revisions to place a
89 * comment against. */
90export class GitLabDiffRefsUnavailableError extends Schema.TaggedError<GitLabDiffRefsUnavailableError>()(
91 "GitLabDiffRefsUnavailableError",
92 {
93 command: Schema.Literal("glab"),
94 cwd: Schema.String,
95 number: Schema.Int,
96 },
97) {
98 get detail(): string {
99 return "The merge request reported no diff revisions.";
100 }
101
102 override get message(): string {
103 return `GitLab CLI failed in getDiffRefs: ${this.detail}`;
104 }
105}
106
107/** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */
108export class GitLabDiffCursorError extends Schema.TaggedError<GitLabDiffCursorError>()(
109 "GitLabDiffCursorError",
110 {
111 command: Schema.Literal("glab"),
112 cwd: Schema.String,
113 },
114) {
115 get detail(): string {
116 return "The diff cursor was not one this merge request handed out.";
117 }
118
119 override get message(): string {
120 return `GitLab CLI failed in getMergeRequestDiff: ${this.detail}`;
121 }
122}
123
124/** Not a decode failure: the reader named a commit that is not a sha this project could hold. */
125export class GitLabDiffCommitError extends Schema.TaggedError<GitLabDiffCommitError>()(
126 "GitLabDiffCommitError",
127 {
128 command: Schema.Literal("glab"),
129 cwd: Schema.String,
130 },
131) {
132 get detail(): string {
133 return "The named commit was not a commit sha.";
134 }
135
136 override get message(): string {
137 return `GitLab CLI failed in getMergeRequestDiff: ${this.detail}`;
138 }
139}
140
141/** The commit exists and decoded, but it has no parent to use as the old revision. */
142export class GitLabDiffCommitParentUnavailableError extends Schema.TaggedError<GitLabDiffCommitParentUnavailableError>()(
143 "GitLabDiffCommitParentUnavailableError",
144 {
145 command: Schema.Literal("glab"),
146 cwd: Schema.String,
147 commit: Schema.String,
148 },
149) {
150 get detail(): string {
151 return `Commit ${this.commit} reported no parent revision.`;
152 }
153
154 override get message(): string {
155 return `GitLab CLI failed in getMergeRequestDiffFileContents: ${this.detail}`;
156 }
157}
158
159/** A blob exists, but expanding it would be unsafe or would not produce text. */
160export class GitLabDiffFileContentsUnavailableError extends Schema.TaggedError<GitLabDiffFileContentsUnavailableError>()(
161 "GitLabDiffFileContentsUnavailableError",
162 {
163 command: Schema.Literal("glab"),
164 cwd: Schema.String,
165 path: Schema.String,
166 reason: Schema.Literals(["oversized", "binary"]),
167 },
168) {
169 get detail(): string {
170 return this.reason === "oversized"
171 ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.`
172 : `The diff file '${this.path}' is binary.`;
173 }
174
175 override get message(): string {
176 return `GitLab CLI failed in getMergeRequestDiffFileContents: ${this.detail}`;
177 }
178}
179
180export type GitLabPullRequestCliError =
181 | GitLabCli.GitLabCliError
182 | GitLabMergeRequestReadError
183 | GitLabDiffCursorError
184 | GitLabDiffCommitError
185 | GitLabDiffCommitParentUnavailableError
186 | GitLabDiffFileContentsUnavailableError
187 | GitLabDiffRefsUnavailableError
188 | GitLabViewerUnavailableError;
189
190/** GitLab's own ceiling on `per_page`, so a larger page has to be walked. */
191const MAX_PAGE_SIZE = 100;
192/** Commit history is read one page deep; the rest of a long history stays on GitLab. */
193const COMMIT_PAGE_SIZE = 100;
194/**
195 * Pages of the conversation to follow before it is reported as truncated. GitLab caps a page at
196 * a hundred, so this is a thousand notes and a thousand discussions — more than any merge
197 * request a person is reading holds, and a walk that ends whatever the host has.
198 */
199const CONVERSATION_PAGES = 10;
200const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
201const DIFF_TIMEOUT_MS = 60_000;
202const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;
203
204export interface GitLabMergeRequestListBatch {
205 readonly items: ReadonlyArray<GitLabMergeRequestListItem>;
206 readonly truncated: boolean;
207 /** Raw GitLab rows consumed to produce this page, including malformed rows. */
208 readonly cursorAdvance: number;
209}
210
211export interface GitLabMergeRequestDiffSlice {
212 readonly patch: string;
213 /** Files in this slice had their hunks withheld, as opposed to there being more slices. */
214 readonly truncated: boolean;
215 /** Where the next slice starts, or null once the patch is whole. */
216 readonly nextCursor: string | null;
217}
218
219export class GitLabPullRequestCli extends Context.Service<
220 GitLabPullRequestCli,
221 {
222 readonly getViewerUsername: (input: {
223 readonly cwd: string;
224 }) => Effect.Effect<string, GitLabPullRequestCliError>;
225
226 readonly listMergeRequests: (input: {
227 readonly cwd: string;
228 readonly repository: string;
229 readonly state: PullRequestListState;
230 readonly involvement: PullRequestInvolvement;
231 readonly viewer: string;
232 readonly limit: number;
233 /** Free text for GitLab's own `search`, which matches title and description. */
234 readonly query?: string | undefined;
235 /** Where to carry on from in GitLab's stable update-ordered row set. */
236 readonly cursor?: ProviderListCursor | undefined;
237 }) => Effect.Effect<GitLabMergeRequestListBatch, GitLabPullRequestCliError>;
238
239 readonly getMergeRequestDetail: (input: {
240 readonly cwd: string;
241 readonly repository: string;
242 readonly number: number;
243 }) => Effect.Effect<GitLabMergeRequestDetail, GitLabPullRequestCliError>;
244
245 readonly listNotes: (input: {
246 readonly cwd: string;
247 readonly repository: string;
248 readonly number: number;
249 }) => Effect.Effect<
250 { readonly comments: ReadonlyArray<PullRequestComment>; readonly truncated: boolean },
251 GitLabPullRequestCliError
252 >;
253
254 readonly listCommits: (input: {
255 readonly cwd: string;
256 readonly repository: string;
257 readonly number: number;
258 }) => Effect.Effect<ReadonlyArray<PullRequestCommit>, GitLabPullRequestCliError>;
259
260 readonly getMergeRequestDiff: (input: {
261 readonly cwd: string;
262 readonly repository: string;
263 readonly number: number;
264 /** Absent asks for the first slice; anything else is a cursor a slice handed back. */
265 readonly cursor?: string | undefined;
266 /** One commit's own changes, rather than everything the merge request carries. */
267 readonly commit?: string | undefined;
268 }) => Effect.Effect<GitLabMergeRequestDiffSlice, GitLabPullRequestCliError>;
269
270 readonly getMergeRequestDiffFileContents: (input: {
271 readonly cwd: string;
272 readonly repository: string;
273 readonly number: number;
274 readonly commit?: string | undefined;
275 readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted";
276 readonly oldPath: string;
277 readonly newPath: string;
278 }) => Effect.Effect<
279 { readonly oldContents: string; readonly newContents: string },
280 GitLabPullRequestCliError
281 >;
282
283 readonly getProjectMergeCapabilities: (input: {
284 readonly cwd: string;
285 readonly repository: string;
286 }) => Effect.Effect<PullRequestMergeCapabilities, GitLabPullRequestCliError>;
287
288 /**
289 * What the merge request's head has of each of these paths, as blob ids.
290 *
291 * The head sha comes from the merge request's own diff refs, so the answer is the version a
292 * reader is looking at rather than whatever the source branch has moved on to. A path the
293 * head does not have is answered as the empty revision, since the batch it was asked in was
294 * looked at; a batch GitLab did not answer for is left out instead.
295 */
296 readonly getFileRevisions: (input: {
297 readonly cwd: string;
298 readonly repository: string;
299 readonly number: number;
300 readonly paths: ReadonlyArray<string>;
301 }) => Effect.Effect<ReadonlyMap<string, string>, GitLabPullRequestCliError>;
302
303 /**
304 * Who this merge request may be sent to, and who it has already been sent to. Two reads at
305 * once, because GitLab keeps the people with access on the project and the reviewers on the
306 * merge request, and neither answers for the other.
307 */
308 readonly listReviewerCandidates: (input: {
309 readonly cwd: string;
310 readonly repository: string;
311 readonly number: number;
312 }) => Effect.Effect<PullRequestReviewerCandidateList, GitLabPullRequestCliError>;
313
314 readonly setReviewerRequest: (input: {
315 readonly cwd: string;
316 readonly repository: string;
317 readonly number: number;
318 readonly reviewers: ReadonlyArray<{ readonly id: string }>;
319 readonly requested: boolean;
320 }) => Effect.Effect<void, GitLabPullRequestCliError>;
321
322 readonly runMergeRequestAction: (input: {
323 readonly cwd: string;
324 readonly repository: string;
325 readonly number: number;
326 readonly action: PullRequestAction;
327 readonly mergeMethod?: PullRequestMergeMethod;
328 }) => Effect.Effect<void, GitLabPullRequestCliError>;
329
330 /** Whichever of the two is given is sent. GitLab calls a merge request's body its description. */
331 readonly updateMergeRequest: (input: {
332 readonly cwd: string;
333 readonly repository: string;
334 readonly number: number;
335 readonly title?: string | undefined;
336 readonly description?: string | undefined;
337 }) => Effect.Effect<void, GitLabPullRequestCliError>;
338
339 readonly commentOnMergeRequest: (input: {
340 readonly cwd: string;
341 readonly repository: string;
342 readonly number: number;
343 readonly body: string;
344 }) => Effect.Effect<void, GitLabPullRequestCliError>;
345
346 readonly updateNote: (input: {
347 readonly cwd: string;
348 readonly repository: string;
349 readonly number: number;
350 readonly noteId: string;
351 readonly body: string;
352 }) => Effect.Effect<void, GitLabPullRequestCliError>;
353
354 readonly listDiscussions: (input: {
355 readonly cwd: string;
356 readonly repository: string;
357 readonly number: number;
358 }) => Effect.Effect<
359 { readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly truncated: boolean },
360 GitLabPullRequestCliError
361 >;
362
363 readonly submitReview: (input: {
364 readonly cwd: string;
365 readonly repository: string;
366 readonly number: number;
367 readonly verdict: PullRequestReviewVerdict;
368 readonly body: string;
369 readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>;
370 }) => Effect.Effect<void, GitLabPullRequestCliError>;
371
372 readonly replyToDiscussion: (input: {
373 readonly cwd: string;
374 readonly repository: string;
375 readonly number: number;
376 readonly discussionId: string;
377 readonly body: string;
378 }) => Effect.Effect<void, GitLabPullRequestCliError>;
379
380 readonly setDiscussionResolution: (input: {
381 readonly cwd: string;
382 readonly repository: string;
383 readonly number: number;
384 readonly discussionId: string;
385 readonly resolved: boolean;
386 }) => Effect.Effect<void, GitLabPullRequestCliError>;
387
388 /** The awards on the merge request and on every note of it, keyed by the note's REST id. */
389 readonly listReactions: (input: {
390 readonly cwd: string;
391 readonly repository: string;
392 readonly number: number;
393 }) => Effect.Effect<
394 {
395 readonly reactions: ReadonlyArray<PullRequestReaction>;
396 readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
397 },
398 GitLabPullRequestCliError
399 >;
400
401 /**
402 * Awards an emoji, or takes the award back. `noteId` is a note of the merge request; absent
403 * awards the merge request itself, which is where its description's reactions live.
404 */
405 readonly setReaction: (input: {
406 readonly cwd: string;
407 readonly repository: string;
408 readonly number: number;
409 readonly noteId?: string | undefined;
410 readonly content: PullRequestReactionContent;
411 readonly reacted: boolean;
412 }) => Effect.Effect<void, GitLabPullRequestCliError>;
413 }
414>()("@t3tools/source-control-gitlab/server/GitLabPullRequestCli") {}
415
416/** The REST API addresses a project by its URL-encoded full path. */
417function projectPath(repository: string): string {
418 return encodeURIComponent(repository.trim());
419}
420
421function gitLabReviewPositionLines(
422 position: PullRequestReviewPosition,
423):
424 | { readonly new_line: number }
425 | { readonly old_line: number }
426 | { readonly old_line: number; readonly new_line: number } {
427 switch (position.kind) {
428 case "added":
429 return { new_line: position.newLine };
430 case "deleted":
431 return { old_line: position.oldLine };
432 case "context":
433 return { old_line: position.oldLine, new_line: position.newLine };
434 }
435}
436
437function stateParam(state: PullRequestListState): string {
438 // GitLab's `closed` already excludes merged merge requests, so no extra filter is needed,
439 // and it spans every state under `all`.
440 return state === "open" ? "opened" : state;
441}
442
443function involvementParams(input: {
444 readonly involvement: PullRequestInvolvement;
445 readonly viewer: string;
446}): ReadonlyArray<readonly [string, string]> {
447 switch (input.involvement) {
448 case "authored":
449 return [["author_username", input.viewer]];
450 case "reviewing":
451 return [["reviewer_username", input.viewer]];
452 case "all":
453 return [];
454 }
455}
456
457/**
458 * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor
459 * arrives from the reader as a string and goes straight into a query, so it is parsed rather
460 * than trusted; the length bound keeps a page number out of exponential notation.
461 */
462function diffCursorPage(cursor: string): number | null {
463 return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null;
464}
465
466/**
467 * A commit sha arrives from the reader and goes straight into a request path, so it is checked
468 * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a
469 * whole sha.
470 */
471function isCommitSha(value: string): boolean {
472 return /^[0-9a-f]{7,64}$/i.test(value);
473}
474
475function searchParams(search: string | undefined): ReadonlyArray<readonly [string, string]> {
476 const trimmed = search?.trim() ?? "";
477 return trimmed.length === 0 ? [] : [["search", trimmed]];
478}
479
480function query(params: ReadonlyArray<readonly [string, string]>): string {
481 return params.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");
482}
483
484function actionArgs(
485 action: PullRequestAction,
486 mergeMethod: PullRequestMergeMethod | undefined,
487): ReadonlyArray<string> {
488 switch (action) {
489 case "merge":
490 return [
491 "merge",
492 // glab turns on auto-merge whenever a pipeline is running. The button means merge now.
493 "--auto-merge=false",
494 "--yes",
495 ...(mergeMethod === "squash" ? ["--squash"] : []),
496 ...(mergeMethod === "rebase" ? ["--rebase"] : []),
497 ];
498 // The same command with the flag the other way up: here the wait is the whole point, so
499 // glab is told to arm the merge rather than talked out of it.
500 case "enable-auto-merge":
501 return [
502 "merge",
503 "--auto-merge=true",
504 "--yes",
505 ...(mergeMethod === "squash" ? ["--squash"] : []),
506 ...(mergeMethod === "rebase" ? ["--rebase"] : []),
507 ];
508 // Never reached: taking the arming back has no `glab mr` command, so it goes to the API.
509 case "disable-auto-merge":
510 return [];
511 case "ready":
512 return ["update", "--ready"];
513 case "draft":
514 return ["update", "--draft"];
515 case "close":
516 return ["close"];
517 // A rebase, because GitLab has no other way to move a branch onto its target: there is no
518 // merge-the-target-in equivalent of GitHub's update button, which is why this host declares
519 // `rebase` alone and never has to read the method it was handed.
520 case "update-branch":
521 return ["rebase"];
522 case "reopen":
523 return ["reopen"];
524 // Never reached: this host does not declare the action, so the service refuses it first.
525 case "revert":
526 case "approve-workflows":
527 throw new Error(`GitLab merge request action ${action} is unsupported`);
528 }
529}
530
531/** @public Service construction is part of the canonical Effect module API. */
532export const make = Effect.gen(function* () {
533 const gitlab = yield* GitLabCli.GitLabCli;
534
535 const api = (input: {
536 readonly cwd: string;
537 readonly path: string;
538 readonly method?: string;
539 readonly stdin?: string;
540 readonly maxOutputBytes?: number;
541 readonly timeoutMs?: number;
542 }) =>
543 gitlab.execute({
544 cwd: input.cwd,
545 args: [
546 "api",
547 input.path,
548 ...(input.method === undefined ? [] : ["--method", input.method]),
549 // A raw body from stdin: argv is visible in process listings and is echoed back
550 // inside process-runner failure messages. Unlike `gh`, `glab api --input` sends no
551 // Content-Type at all, and GitLab answers a bodyless content type with HTTP 415.
552 ...(input.stdin === undefined
553 ? []
554 : ["--input", "-", "--header", "Content-Type: application/json"]),
555 ],
556 ...(input.stdin === undefined ? {} : { stdin: input.stdin }),
557 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
558 ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
559 });
560
561 /**
562 * `per_page` stops at 100, so a larger page is walked one request at a time. The walk is
563 * bounded twice over: it stops on a short page or once the extra row that reveals a next
564 * page has been read, and it never asks for more pages than the caller's page needs. The
565 * second bound is what makes it terminate when every row on a page fails to decode, which
566 * leaves nothing collected but does not mean GitLab has run out of rows.
567 */
568 const listPage = (input: {
569 readonly cwd: string;
570 readonly repository: string;
571 readonly state: PullRequestListState;
572 readonly involvement: PullRequestInvolvement;
573 readonly viewer: string;
574 readonly limit: number;
575 readonly query?: string | undefined;
576 readonly cursor?: ProviderListCursor | undefined;
577 readonly page: number;
578 readonly collected: ReadonlyArray<GitLabMergeRequestListItem>;
579 readonly cursorAdvance: number;
580 }): Effect.Effect<GitLabMergeRequestListBatch, GitLabPullRequestCliError> => {
581 // A continuation uses GitLab's offset pagination. Its timestamp filter is inclusive and has
582 // no tie-breaker, so a page where many rows share the boundary would otherwise return the
583 // same prefix forever. `delivered` is the stable offset the service has already handed over.
584 const delivered = input.cursor?.delivered ?? 0;
585 const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE);
586 const firstPage = Math.floor(delivered / perPage) + 1;
587 const skipOnFirstPage = input.page === firstPage ? delivered % perPage : 0;
588 // A page made entirely of malformed rows has no item from which the service can build a
589 // continuation. Bound the walk to the raw span this request asked for rather than recursing
590 // forever on a host that keeps returning full unusable pages.
591 const lastPage = Math.floor((delivered + input.limit) / perPage) + 1;
592 return api({
593 cwd: input.cwd,
594 path: `projects/${projectPath(input.repository)}/merge_requests?${query([
595 ["state", stateParam(input.state)],
596 ...involvementParams(input),
597 // The listing is read through `glab api` rather than `glab mr list`, so the search is
598 // the REST API's own `search` parameter — the one `mr list --search` passes on. It
599 // matches title and description, and travels URL-encoded like every other value here,
600 // so no text in it can become a parameter of its own.
601 ...searchParams(input.query),
602 ["order_by", "updated_at"],
603 ["sort", "desc"],
604 ["per_page", String(perPage)],
605 ["page", String(input.page)],
606 ])}`,
607 }).pipe(
608 Effect.flatMap((result) => {
609 const raw = result.stdout.trim();
610 if (raw.length === 0) {
611 return Effect.succeed({
612 items: input.collected,
613 truncated: false,
614 cursorAdvance: input.cursorAdvance,
615 });
616 }
617 const decoded = decodeMergeRequestListJson(raw);
618 if (!Result.isSuccess(decoded)) {
619 return Effect.fail(
620 new GitLabMergeRequestReadError({
621 command: "glab",
622 cwd: input.cwd,
623 operation: "listMergeRequests",
624 cause: decoded.failure,
625 }),
626 );
627 }
628 const pageItems: GitLabMergeRequestListItem[] = [];
629 const pageRawIndexes: number[] = [];
630 for (const [index, item] of decoded.success.items.entries()) {
631 const rawIndex = decoded.success.rawIndexes[index]!;
632 if (rawIndex < skipOnFirstPage) continue;
633 pageItems.push(item);
634 pageRawIndexes.push(rawIndex);
635 }
636 const remaining = input.limit - input.collected.length;
637 const lastItemRawIndex = pageRawIndexes[remaining - 1];
638 if (lastItemRawIndex !== undefined) {
639 const consumed = lastItemRawIndex + 1 - skipOnFirstPage;
640 return Effect.succeed({
641 items: [...input.collected, ...pageItems.slice(0, remaining)],
642 truncated:
643 lastItemRawIndex + 1 < decoded.success.rawCount ||
644 decoded.success.rawCount === perPage,
645 cursorAdvance: input.cursorAdvance + consumed,
646 });
647 }
648 const collected = [...input.collected, ...pageItems];
649 const consumed = Math.max(0, decoded.success.rawCount - skipOnFirstPage);
650 // Counted before decoding, so a skipped malformed row cannot end paging early.
651 const exhausted = decoded.success.rawCount < perPage;
652 if (exhausted) {
653 return Effect.succeed({
654 items: collected,
655 truncated: false,
656 cursorAdvance: input.cursorAdvance + consumed,
657 });
658 }
659 if (input.page >= lastPage) {
660 return Effect.succeed({
661 items: collected,
662 truncated: true,
663 cursorAdvance: input.cursorAdvance + consumed,
664 });
665 }
666 return listPage({
667 ...input,
668 page: input.page + 1,
669 collected,
670 cursorAdvance: input.cursorAdvance + consumed,
671 });
672 }),
673 );
674 };
675
676 /**
677 * One page of a merge request's files, as a patch that stands on its own. GitLab pages
678 * `/diffs` by offset and has no cursor of its own, so the page number is the cursor; the
679 * caller carries on from it for as long as GitLab keeps handing full pages back.
680 *
681 * A named commit is read from the commit's own diff, which answers in the same shape and pages
682 * the same way.
683 */
684 const diffPage = (input: {
685 readonly cwd: string;
686 readonly repository: string;
687 readonly number: number;
688 readonly page: number;
689 readonly commit?: string | undefined;
690 }): Effect.Effect<GitLabMergeRequestDiffSlice, GitLabPullRequestCliError> =>
691 api({
692 cwd: input.cwd,
693 path: `projects/${projectPath(input.repository)}/${
694 input.commit === undefined
695 ? `merge_requests/${input.number}/diffs`
696 : `repository/commits/${input.commit}/diff`
697 }?${query([
698 ["per_page", String(MAX_PAGE_SIZE)],
699 ["page", String(input.page)],
700 ])}`,
701 maxOutputBytes: DIFF_MAX_OUTPUT_BYTES,
702 timeoutMs: DIFF_TIMEOUT_MS,
703 }).pipe(
704 Effect.flatMap((result) => {
705 // A byte-truncated response is a JSON prefix, so this page cannot be read at all.
706 // Answering with no cursor would call the diff whole while silently dropping this page
707 // and every one after it, so the read fails and says which page could not be had.
708 if (result.stdoutTruncated) {
709 return Effect.fail(
710 new GitLabMergeRequestReadError({
711 command: "glab",
712 cwd: input.cwd,
713 operation: "getMergeRequestDiff",
714 cause: new Error(
715 `Page ${input.page} of the merge request diff was too large to read.`,
716 ),
717 }),
718 );
719 }
720 const decoded = decodeMergeRequestDiffsJson(result.stdout.trim());
721 if (!Result.isSuccess(decoded)) {
722 return Effect.fail(
723 new GitLabMergeRequestReadError({
724 command: "glab",
725 cwd: input.cwd,
726 operation: "getMergeRequestDiff",
727 cause: decoded.failure,
728 }),
729 );
730 }
731 const patch = decoded.success.patch;
732 // Counted before decoding, so a page whose files all failed to decode still moves on
733 // rather than pointing the reader back at the page it just read.
734 const morePages = decoded.success.rawCount >= MAX_PAGE_SIZE;
735 return Effect.succeed({
736 // The slice ends on a newline, so a file GitLab gave a header and no hunks for does
737 // not run into the first line of the next slice.
738 patch: patch.length === 0 ? patch : patch.replace(/\n?$/, "\n"),
739 truncated: decoded.success.truncated,
740 nextCursor: morePages ? String(input.page + 1) : null,
741 });
742 }),
743 );
744
745 /**
746 * The conversation, a page at a time. GitLab pages by offset and reports no total, so a short
747 * page is the only thing that says it is done — and the raw count decides, not the kept one:
748 * the notes GitLab wrote itself are dropped, and a whole page of them still means there is
749 * more to read.
750 */
751 const notesPage = (input: {
752 readonly cwd: string;
753 readonly repository: string;
754 readonly number: number;
755 readonly page: number;
756 readonly collected: ReadonlyArray<PullRequestComment>;
757 }): Effect.Effect<
758 { readonly comments: ReadonlyArray<PullRequestComment>; readonly truncated: boolean },
759 GitLabPullRequestCliError
760 > =>
761 api({
762 cwd: input.cwd,
763 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes?${query(
764 [
765 ["per_page", String(MAX_PAGE_SIZE)],
766 ["page", String(input.page)],
767 ["order_by", "created_at"],
768 ["sort", "asc"],
769 ],
770 )}`,
771 }).pipe(
772 Effect.flatMap((result) => {
773 const decoded = decodeNotesJson(result.stdout.trim());
774 if (!Result.isSuccess(decoded)) {
775 return Effect.fail(
776 new GitLabMergeRequestReadError({
777 command: "glab",
778 cwd: input.cwd,
779 operation: "listNotes",
780 cause: decoded.failure,
781 }),
782 );
783 }
784 const collected = [...input.collected, ...decoded.success.comments];
785 if (decoded.success.rawCount < MAX_PAGE_SIZE) {
786 return Effect.succeed({ comments: collected, truncated: false });
787 }
788 return input.page >= CONVERSATION_PAGES
789 ? Effect.succeed({ comments: collected, truncated: true })
790 : notesPage({ ...input, page: input.page + 1, collected });
791 }),
792 );
793
794 /** The positioned discussions, walked the same way and stopped by the same bound. */
795 const discussionsPage = (input: {
796 readonly cwd: string;
797 readonly repository: string;
798 readonly number: number;
799 readonly page: number;
800 readonly collected: ReadonlyArray<PullRequestReviewThread>;
801 }): Effect.Effect<
802 { readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly truncated: boolean },
803 GitLabPullRequestCliError
804 > =>
805 api({
806 cwd: input.cwd,
807 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions?${query(
808 [
809 ["per_page", String(MAX_PAGE_SIZE)],
810 ["page", String(input.page)],
811 ],
812 )}`,
813 }).pipe(
814 Effect.flatMap((result) => {
815 const decoded = decodeDiscussionsJson(result.stdout.trim());
816 if (!Result.isSuccess(decoded)) {
817 return Effect.fail(
818 new GitLabMergeRequestReadError({
819 command: "glab",
820 cwd: input.cwd,
821 operation: "listDiscussions",
822 cause: decoded.failure,
823 }),
824 );
825 }
826 const collected = [...input.collected, ...decoded.success.threads];
827 // The raw count again: this endpoint returns the plain notes too, so a full page of
828 // those is not the end of the positioned ones.
829 if (decoded.success.rawCount < MAX_PAGE_SIZE) {
830 return Effect.succeed({ threads: collected, truncated: false });
831 }
832 return input.page >= CONVERSATION_PAGES
833 ? Effect.succeed({ threads: collected, truncated: true })
834 : discussionsPage({ ...input, page: input.page + 1, collected });
835 }),
836 );
837
838 /**
839 * The revisions a positioned comment is written against. GitLab resolves a comment's line
840 * against these three shas, so a review with line comments cannot be sent without them.
841 */
842 const getDiffRefs = (input: {
843 readonly cwd: string;
844 readonly repository: string;
845 readonly number: number;
846 }): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> =>
847 api({
848 cwd: input.cwd,
849 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`,
850 }).pipe(
851 Effect.flatMap((result): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> => {
852 const decoded = decodeDiffRefsJson(result.stdout.trim());
853 if (!Result.isSuccess(decoded)) {
854 return Effect.fail(
855 new GitLabMergeRequestReadError({
856 command: "glab",
857 cwd: input.cwd,
858 operation: "getDiffRefs",
859 cause: decoded.failure,
860 }),
861 );
862 }
863 // A merge request with no diff refs is a well-formed answer that cannot carry a
864 // positioned comment — a dead end, but not something that failed to be read.
865 return decoded.success === null
866 ? Effect.fail(
867 new GitLabDiffRefsUnavailableError({
868 command: "glab",
869 cwd: input.cwd,
870 number: input.number,
871 }),
872 )
873 : Effect.succeed(decoded.success);
874 }),
875 );
876
877 const getCommitDiffRefs = (input: {
878 readonly cwd: string;
879 readonly repository: string;
880 readonly commit: string;
881 readonly allowRoot: boolean;
882 }): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> =>
883 api({
884 cwd: input.cwd,
885 path: `projects/${projectPath(input.repository)}/repository/commits/${input.commit}`,
886 }).pipe(
887 Effect.flatMap((result): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> => {
888 const decoded = decodeCommitDiffRefsJson(result.stdout.trim());
889 if (!Result.isSuccess(decoded)) {
890 return Effect.fail(
891 new GitLabMergeRequestReadError({
892 command: "glab",
893 cwd: input.cwd,
894 operation: "getMergeRequestDiffFileContents",
895 cause: decoded.failure,
896 }),
897 );
898 }
899 return decoded.success === null
900 ? input.allowRoot
901 ? Effect.succeed({
902 baseSha: "",
903 headSha: input.commit,
904 startSha: "",
905 })
906 : Effect.fail(
907 new GitLabDiffCommitParentUnavailableError({
908 command: "glab",
909 cwd: input.cwd,
910 commit: input.commit,
911 }),
912 )
913 : Effect.succeed(decoded.success);
914 }),
915 );
916
917 /**
918 * The merge request itself, which several calls need for different parts of it: the detail for
919 * everything, and the reviewer paths for the ids GitLab writes a reviewer set with.
920 */
921 const mergeRequestDetail = (input: {
922 readonly cwd: string;
923 readonly repository: string;
924 readonly number: number;
925 }): Effect.Effect<GitLabMergeRequestDetail, GitLabPullRequestCliError> =>
926 api({
927 cwd: input.cwd,
928 // How far behind the target branch this one is comes only when asked for by name, and it
929 // is asked for here rather than on a second read because it is the same merge request.
930 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}?${query([
931 ["include_diverged_commits_count", "true"],
932 ])}`,
933 }).pipe(
934 Effect.flatMap((result) => {
935 const decoded = decodeMergeRequestDetailJson(result.stdout.trim());
936 return Result.isSuccess(decoded)
937 ? Effect.succeed(decoded.success)
938 : Effect.fail(
939 new GitLabMergeRequestReadError({
940 command: "glab",
941 cwd: input.cwd,
942 operation: "getMergeRequestDetail",
943 cause: decoded.failure,
944 }),
945 );
946 }),
947 );
948
949 /** The people with access to the project, one page deep. */
950 const projectUsers = (input: {
951 readonly cwd: string;
952 readonly repository: string;
953 }): Effect.Effect<GitLabProjectUsers, GitLabPullRequestCliError> =>
954 api({
955 cwd: input.cwd,
956 path: `projects/${projectPath(input.repository)}/users?${query([
957 ["per_page", String(MAX_PAGE_SIZE)],
958 ])}`,
959 }).pipe(
960 Effect.flatMap((result) => {
961 const decoded = decodeProjectUsersJson(result.stdout.trim());
962 return Result.isSuccess(decoded)
963 ? Effect.succeed(decoded.success)
964 : Effect.fail(
965 new GitLabMergeRequestReadError({
966 command: "glab",
967 cwd: input.cwd,
968 operation: "listReviewerCandidates",
969 cause: decoded.failure,
970 }),
971 );
972 }),
973 );
974
975 /** Where an award is written: a note of the merge request, or the merge request itself. */
976 const awardSubjectPath = (input: {
977 readonly repository: string;
978 readonly number: number;
979 readonly noteId?: string | undefined;
980 }) => {
981 const mergeRequest = `projects/${projectPath(input.repository)}/merge_requests/${input.number}`;
982 return input.noteId === undefined
983 ? `${mergeRequest}/award_emoji`
984 : `${mergeRequest}/notes/${encodeURIComponent(input.noteId)}/award_emoji`;
985 };
986
987 /**
988 * The awards on the merge request and its notes, a page of notes at a time. Bounded by the same
989 * count as the conversation itself: awards past the notes that were read belong to notes the
990 * page is not showing.
991 */
992 const awardsPage = (input: {
993 readonly cwd: string;
994 readonly repository: string;
995 readonly number: number;
996 readonly cursor: string | null;
997 readonly page: number;
998 readonly collected: {
999 readonly reactions: ReadonlyArray<PullRequestReaction>;
1000 readonly reactionsByNoteId: Map<string, ReadonlyArray<PullRequestReaction>>;
1001 } | null;
1002 }): Effect.Effect<
1003 {
1004 readonly reactions: ReadonlyArray<PullRequestReaction>;
1005 readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
1006 },
1007 GitLabPullRequestCliError
1008 > =>
1009 api({
1010 cwd: input.cwd,
1011 path: "graphql",
1012 method: "POST",
1013 stdin: JSON.stringify({
1014 query: AWARD_EMOJI_GRAPHQL_QUERY,
1015 variables: {
1016 fullPath: input.repository,
1017 iid: String(input.number),
1018 cursor: input.cursor,
1019 },
1020 }),
1021 }).pipe(
1022 Effect.flatMap((result) => {
1023 const decoded = decodeAwardEmojiJson(result.stdout.trim());
1024 if (!Result.isSuccess(decoded)) {
1025 return Effect.fail(
1026 new GitLabMergeRequestReadError({
1027 command: "glab",
1028 cwd: input.cwd,
1029 operation: "listReactions",
1030 cause: decoded.failure,
1031 }),
1032 );
1033 }
1034 const collected = input.collected ?? {
1035 reactions: decoded.success.reactions,
1036 reactionsByNoteId: new Map<string, ReadonlyArray<PullRequestReaction>>(),
1037 };
1038 for (const [id, reactions] of decoded.success.reactionsByNoteId)
1039 collected.reactionsByNoteId.set(id, reactions);
1040 return decoded.success.nextCursor === null || input.page >= CONVERSATION_PAGES
1041 ? Effect.succeed(collected)
1042 : awardsPage({
1043 ...input,
1044 cursor: decoded.success.nextCursor,
1045 page: input.page + 1,
1046 collected,
1047 });
1048 }),
1049 );
1050
1051 /**
1052 * GitLab charges the blobs query by how many paths it is handed, and its connection hands back
1053 * one page. A hundred at a time keeps each request inside both.
1054 */
1055 const BLOB_PATHS_PER_REQUEST = 100;
1056
1057 const blobsAt = (input: {
1058 readonly cwd: string;
1059 readonly repository: string;
1060 readonly ref: string;
1061 readonly paths: ReadonlyArray<string>;
1062 }): Effect.Effect<ReadonlyMap<string, string> | null, GitLabPullRequestCliError> =>
1063 api({
1064 cwd: input.cwd,
1065 path: "graphql",
1066 method: "POST",
1067 stdin: JSON.stringify({
1068 query: REPOSITORY_BLOBS_GRAPHQL_QUERY,
1069 variables: { fullPath: input.repository, ref: input.ref, paths: input.paths },
1070 }),
1071 }).pipe(
1072 Effect.flatMap(
1073 (result): Effect.Effect<ReadonlyMap<string, string> | null, GitLabPullRequestCliError> => {
1074 const decoded = decodeRepositoryBlobsJson(result.stdout.trim());
1075 return Result.isSuccess(decoded)
1076 ? Effect.succeed(decoded.success)
1077 : Effect.fail(
1078 new GitLabMergeRequestReadError({
1079 command: "glab",
1080 cwd: input.cwd,
1081 operation: "getFileRevisions",
1082 cause: decoded.failure,
1083 }),
1084 );
1085 },
1086 ),
1087 );
1088
1089 const fileRevisions = (input: {
1090 readonly cwd: string;
1091 readonly repository: string;
1092 readonly number: number;
1093 readonly paths: ReadonlyArray<string>;
1094 }): Effect.Effect<ReadonlyMap<string, string>, GitLabPullRequestCliError> =>
1095 input.paths.length === 0
1096 ? Effect.succeed(new Map())
1097 : getDiffRefs(input).pipe(
1098 Effect.flatMap((refs) => {
1099 const batches: Array<ReadonlyArray<string>> = [];
1100 for (let at = 0; at < input.paths.length; at += BLOB_PATHS_PER_REQUEST) {
1101 batches.push(input.paths.slice(at, at + BLOB_PATHS_PER_REQUEST));
1102 }
1103 return Effect.forEach(
1104 batches,
1105 (paths) =>
1106 blobsAt({ ...input, ref: refs.headSha, paths }).pipe(
1107 Effect.map((page) => ({ paths, page })),
1108 ),
1109 { concurrency: 2 },
1110 ).pipe(
1111 Effect.map((pages) => {
1112 const revisions = new Map<string, string>();
1113 for (const { paths, page } of pages) {
1114 // A batch GitLab did not answer says nothing about its paths, so they are left
1115 // out and the caller reads them as versions it could not learn, which leaves
1116 // the marks on them alone. Filling them in as removed would report every file
1117 // a reader has cleared as changed over a project the token cannot see.
1118 if (page === null) continue;
1119 for (const [path, oid] of page) revisions.set(path, oid);
1120 // Within a batch that was answered, every path was looked for at the head, so
1121 // one that is not there is one the merge request removed. Said as the empty
1122 // revision, which is an answer the caller can compare against and keep.
1123 for (const path of paths) {
1124 if (!revisions.has(path)) revisions.set(path, "");
1125 }
1126 }
1127 return revisions as ReadonlyMap<string, string>;
1128 }),
1129 );
1130 }),
1131 );
1132
1133 const viewerUsername = (input: { readonly cwd: string }) =>
1134 api({ cwd: input.cwd, path: "user" }).pipe(
1135 Effect.flatMap((result): Effect.Effect<string, GitLabPullRequestCliError> => {
1136 const decoded = decodeViewerJson(result.stdout.trim());
1137 if (!Result.isSuccess(decoded)) {
1138 return Effect.fail(
1139 new GitLabMergeRequestReadError({
1140 command: "glab",
1141 cwd: input.cwd,
1142 operation: "getViewerUsername",
1143 cause: decoded.failure,
1144 }),
1145 );
1146 }
1147 return decoded.success === null
1148 ? Effect.fail(new GitLabViewerUnavailableError({ command: "glab", cwd: input.cwd }))
1149 : Effect.succeed(decoded.success);
1150 }),
1151 );
1152
1153 return GitLabPullRequestCli.of({
1154 getViewerUsername: viewerUsername,
1155
1156 listMergeRequests: (input) => {
1157 const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE);
1158 const page = Math.floor((input.cursor?.delivered ?? 0) / perPage) + 1;
1159 return listPage({ ...input, page, collected: [], cursorAdvance: 0 });
1160 },
1161
1162 getMergeRequestDetail: mergeRequestDetail,
1163
1164 listNotes: (input) => notesPage({ ...input, page: 1, collected: [] }),
1165
1166 listReactions: (input) => awardsPage({ ...input, cursor: null, page: 1, collected: null }),
1167
1168 getFileRevisions: fileRevisions,
1169
1170 setReaction: (input) =>
1171 Effect.gen(function* () {
1172 const subject = awardSubjectPath(input);
1173 if (input.reacted) {
1174 yield* api({
1175 cwd: input.cwd,
1176 path: `${subject}?${query([["name", gitLabAwardName(input.content)]])}`,
1177 method: "POST",
1178 });
1179 return;
1180 }
1181 // GitLab deletes an award by its id and takes no emoji name there, so the reader's own
1182 // award of that name is looked up first. Nothing to delete is success: the reaction the
1183 // caller asked to take back is already gone.
1184 const viewer = yield* viewerUsername({ cwd: input.cwd });
1185 const listed = yield* api({ cwd: input.cwd, path: subject });
1186 const own = decodeOwnAwardIdJson(listed.stdout.trim(), {
1187 content: input.content,
1188 viewer,
1189 });
1190 if (!Result.isSuccess(own)) {
1191 return yield* new GitLabMergeRequestReadError({
1192 command: "glab",
1193 cwd: input.cwd,
1194 operation: "setReaction",
1195 cause: own.failure,
1196 });
1197 }
1198 if (own.success === null) return;
1199 yield* api({
1200 cwd: input.cwd,
1201 path: `${subject}/${own.success}`,
1202 method: "DELETE",
1203 });
1204 }),
1205
1206 listCommits: (input) =>
1207 api({
1208 cwd: input.cwd,
1209 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/commits?${query(
1210 [
1211 ["per_page", String(COMMIT_PAGE_SIZE)],
1212 ["with_stats", "true"],
1213 ],
1214 )}`,
1215 }).pipe(
1216 Effect.flatMap((result) => {
1217 const decoded = decodeCommitsJson(result.stdout.trim());
1218 return Result.isSuccess(decoded)
1219 ? Effect.succeed(decoded.success)
1220 : Effect.fail(
1221 new GitLabMergeRequestReadError({
1222 command: "glab",
1223 cwd: input.cwd,
1224 operation: "listCommits",
1225 cause: decoded.failure,
1226 }),
1227 );
1228 }),
1229 ),
1230
1231 getMergeRequestDiff: (input) => {
1232 if (input.commit !== undefined && !isCommitSha(input.commit)) {
1233 return Effect.fail(new GitLabDiffCommitError({ command: "glab", cwd: input.cwd }));
1234 }
1235 const target = {
1236 cwd: input.cwd,
1237 repository: input.repository,
1238 number: input.number,
1239 ...(input.commit === undefined ? {} : { commit: input.commit }),
1240 };
1241 if (input.cursor === undefined) {
1242 return diffPage({ ...target, page: 1 });
1243 }
1244 const page = diffCursorPage(input.cursor);
1245 return page === null
1246 ? Effect.fail(new GitLabDiffCursorError({ command: "glab", cwd: input.cwd }))
1247 : diffPage({ ...target, page });
1248 },
1249
1250 getMergeRequestDiffFileContents: (input) =>
1251 Effect.gen(function* () {
1252 if (input.commit !== undefined && !isCommitSha(input.commit)) {
1253 return yield* new GitLabDiffCommitError({ command: "glab", cwd: input.cwd });
1254 }
1255 const refs = yield* input.commit === undefined
1256 ? getDiffRefs(input)
1257 : getCommitDiffRefs({
1258 cwd: input.cwd,
1259 repository: input.repository,
1260 commit: input.commit,
1261 allowRoot: input.changeType === "new",
1262 });
1263
1264 const readFile = (revision: string, filePath: string) =>
1265 api({
1266 cwd: input.cwd,
1267 path: `projects/${projectPath(input.repository)}/repository/files/${encodeURIComponent(
1268 filePath,
1269 )}/raw?ref=${encodeURIComponent(revision)}`,
1270 maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES,
1271 timeoutMs: DIFF_TIMEOUT_MS,
1272 }).pipe(
1273 Effect.flatMap((result) =>
1274 result.stdoutTruncated ||
1275 result.stdout.includes("\0") ||
1276 result.stdoutInvalidUtf8 === true
1277 ? Effect.fail(
1278 new GitLabDiffFileContentsUnavailableError({
1279 command: "glab",
1280 cwd: input.cwd,
1281 path: filePath,
1282 reason: result.stdoutTruncated ? "oversized" : "binary",
1283 }),
1284 )
1285 : Effect.succeed(result.stdout),
1286 ),
1287 );
1288
1289 const [oldContents, newContents] = yield* Effect.all(
1290 [
1291 input.changeType === "new" ? Effect.succeed("") : readFile(refs.baseSha, input.oldPath),
1292 input.changeType === "deleted"
1293 ? Effect.succeed("")
1294 : readFile(refs.headSha, input.newPath),
1295 ],
1296 { concurrency: 2 },
1297 );
1298 return { oldContents, newContents };
1299 }),
1300
1301 getProjectMergeCapabilities: (input) =>
1302 api({
1303 cwd: input.cwd,
1304 path: `projects/${projectPath(input.repository)}?license=false`,
1305 }).pipe(
1306 Effect.flatMap((result) => {
1307 const decoded = decodeProjectMergeCapabilitiesJson(result.stdout.trim());
1308 return Result.isSuccess(decoded)
1309 ? Effect.succeed(decoded.success)
1310 : Effect.fail(
1311 new GitLabMergeRequestReadError({
1312 command: "glab",
1313 cwd: input.cwd,
1314 operation: "getProjectMergeCapabilities",
1315 cause: decoded.failure,
1316 }),
1317 );
1318 }),
1319 ),
1320
1321 listReviewerCandidates: (input) =>
1322 Effect.all([mergeRequestDetail(input), projectUsers(input)], { concurrency: 2 }).pipe(
1323 Effect.map(([mergeRequest, users]) => {
1324 const author = mergeRequest.author?.login;
1325 const requested = new Set(mergeRequest.reviewRequestLogins);
1326 return {
1327 // The author is dropped rather than shown unusable: GitLab refuses to make the person
1328 // who opened a merge request its reviewer.
1329 candidates: users.candidates.flatMap((candidate) =>
1330 candidate.login === author
1331 ? []
1332 : [{ ...candidate, isRequested: requested.has(candidate.login) }],
1333 ),
1334 truncated: users.rawCount >= MAX_PAGE_SIZE,
1335 };
1336 }),
1337 ),
1338
1339 setReviewerRequest: (input) =>
1340 mergeRequestDetail(input).pipe(
1341 Effect.flatMap((mergeRequest) => {
1342 // GitLab has no endpoint that adds or removes one reviewer: `reviewer_ids` replaces the
1343 // whole set, so the set that is already there is read first and the change applied to
1344 // it. Asking again for somebody already on it writes the same set back, which is how
1345 // GitLab re-requests a review.
1346 const ids = new Set(mergeRequest.reviewerIds);
1347 for (const reviewer of input.reviewers) {
1348 const id = Number(reviewer.id);
1349 // A candidate GitLab did not name is not an id it would accept, and sending it would
1350 // rewrite the reviewer set around a number nobody chose.
1351 if (!Number.isSafeInteger(id) || id <= 0) continue;
1352 if (input.requested) ids.add(id);
1353 else ids.delete(id);
1354 }
1355 return api({
1356 cwd: input.cwd,
1357 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`,
1358 method: "PUT",
1359 stdin: JSON.stringify({ reviewer_ids: [...ids] }),
1360 });
1361 }),
1362 Effect.asVoid,
1363 ),
1364
1365 runMergeRequestAction: (input) => {
1366 // `glab mr merge` arms auto-merge and never disarms it, so the one direction the CLI has
1367 // no flag for is asked of GitLab directly through the same `api` passthrough the rest of
1368 // this module writes with.
1369 if (input.action === "disable-auto-merge") {
1370 return api({
1371 cwd: input.cwd,
1372 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/cancel_merge_when_pipeline_succeeds`,
1373 method: "POST",
1374 }).pipe(Effect.asVoid);
1375 }
1376 const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod);
1377 return gitlab
1378 .execute({
1379 cwd: input.cwd,
1380 args: ["mr", subcommand!, String(input.number), "--repo", input.repository, ...flags],
1381 })
1382 .pipe(Effect.asVoid);
1383 },
1384
1385 updateMergeRequest: (input) =>
1386 api({
1387 cwd: input.cwd,
1388 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`,
1389 method: "PUT",
1390 // Only the fields the caller asked to change: GitLab leaves out what it is not sent, and
1391 // clears what it is sent empty — so a title corrected on its own must carry no
1392 // description at all.
1393 stdin: JSON.stringify({
1394 ...(input.title === undefined ? {} : { title: input.title }),
1395 ...(input.description === undefined ? {} : { description: input.description }),
1396 }),
1397 }).pipe(Effect.asVoid),
1398
1399 commentOnMergeRequest: (input) =>
1400 api({
1401 cwd: input.cwd,
1402 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes`,
1403 method: "POST",
1404 // A JSON body rather than a `--raw-field`: glab coerces a field that reads as a
1405 // literal `true` or a number, and a comment body is text either way.
1406 stdin: JSON.stringify({ body: input.body }),
1407 }).pipe(Effect.asVoid),
1408
1409 updateNote: (input) =>
1410 api({
1411 cwd: input.cwd,
1412 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes/${encodeURIComponent(
1413 input.noteId,
1414 )}`,
1415 method: "PUT",
1416 stdin: JSON.stringify({ body: input.body }),
1417 }).pipe(Effect.asVoid),
1418
1419 listDiscussions: (input) => discussionsPage({ ...input, page: 1, collected: [] }),
1420
1421 submitReview: (input) =>
1422 Effect.gen(function* () {
1423 const project = projectPath(input.repository);
1424 const mergeRequest = `projects/${project}/merge_requests/${input.number}`;
1425 // GitLab has no pending review to attach comments to, so a review is replayed as the
1426 // requests it is made of: the line comments, then the summary, then the verdict. A
1427 // failure part-way therefore leaves what was already posted in place, which is why
1428 // the verdict goes last — a half-sent review is never an approval.
1429 if (input.comments.length > 0) {
1430 const refs = yield* getDiffRefs(input);
1431 yield* Effect.forEach(
1432 input.comments,
1433 (comment) =>
1434 api({
1435 cwd: input.cwd,
1436 path: `${mergeRequest}/discussions`,
1437 method: "POST",
1438 stdin: JSON.stringify({
1439 body: comment.body,
1440 position: {
1441 base_sha: refs.baseSha,
1442 head_sha: refs.headSha,
1443 start_sha: refs.startSha,
1444 position_type: "text",
1445 // Both paths are sent because GitLab resolves a position against both
1446 // sides of the diff. They differ only for a renamed file, which is why the
1447 // draft carries the name the file had before the change.
1448 old_path: comment.oldPath ?? comment.path,
1449 new_path: comment.path,
1450 ...gitLabReviewPositionLines(comment.position),
1451 },
1452 }),
1453 }),
1454 { discard: true },
1455 );
1456 }
1457 if (input.body.trim().length > 0) {
1458 yield* api({
1459 cwd: input.cwd,
1460 path: `${mergeRequest}/notes`,
1461 method: "POST",
1462 // A JSON body rather than a `--raw-field`, for the reason the plain comment gives:
1463 // glab coerces a field that reads as a literal `true` or a number.
1464 // @effect-diagnostics-next-line preferSchemaOverJson:off
1465 stdin: JSON.stringify({ body: input.body }),
1466 });
1467 }
1468 if (input.verdict === "approve") {
1469 yield* api({ cwd: input.cwd, path: `${mergeRequest}/approve`, method: "POST" });
1470 }
1471 }),
1472
1473 replyToDiscussion: (input) =>
1474 api({
1475 cwd: input.cwd,
1476 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent(
1477 input.discussionId,
1478 )}/notes`,
1479 method: "POST",
1480 stdin: JSON.stringify({ body: input.body }),
1481 }).pipe(Effect.asVoid),
1482
1483 setDiscussionResolution: (input) =>
1484 api({
1485 cwd: input.cwd,
1486 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent(
1487 input.discussionId,
1488 )}`,
1489 method: "PUT",
1490 stdin: JSON.stringify({ resolved: input.resolved }),
1491 }).pipe(Effect.asVoid),
1492 });
1493});
1494
1495export const layer = Layer.effect(GitLabPullRequestCli, make);
packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts created+214
......@@ -0,0 +1,214 @@
1import { assert, describe, expect, it, vi } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
6import { gitLabViewerPermissions, make } from "./GitLabPullRequestProvider.ts";
7
8describe("gitLabViewerPermissions", () => {
9 it("offers everything to a viewer GitLab says can merge", () => {
10 expect(gitLabViewerPermissions({ viewerCanMerge: true })).toEqual({
11 // Arming a merge for later and taking the arming back answer to the same `can_merge`.
12 actions: [
13 "merge",
14 "ready",
15 "draft",
16 "close",
17 "reopen",
18 "update-branch",
19 "enable-auto-merge",
20 "disable-auto-merge",
21 ],
22 comment: true,
23 resolve: true,
24 verdicts: ["comment", "approve"],
25 // GitLab says nothing about who may set a reviewer, and an unreported permission is granted.
26 requestReviewers: true,
27 // Rebase and nothing else: GitLab cannot merge a target branch into a source branch, so
28 // offering the choice would be offering something no request could carry out.
29 updateMethods: ["rebase"],
30 });
31 });
32
33 it("keeps merge, now and later, from a viewer GitLab says cannot", () => {
34 // `user.can_merge` already accounts for the role, the approval rules and a protected target
35 // branch, so it is the one answer here that does not have to be inferred.
36 expect(gitLabViewerPermissions({ viewerCanMerge: false })).toEqual({
37 actions: ["ready", "draft", "close", "reopen"],
38 comment: true,
39 resolve: true,
40 verdicts: ["comment", "approve"],
41 requestReviewers: true,
42 });
43 });
44
45 it("names no way of updating a branch it will not let this viewer update", () => {
46 // The action and the strategy behind it go together: a button offered with nothing to press
47 // it with, or a strategy left standing next to a withheld button, is a half-refusal.
48 expect(gitLabViewerPermissions({ viewerCanMerge: false }).updateMethods).toBeUndefined();
49 });
50
51 it("treats an author with read access as any other reader, which is all GitLab says", () => {
52 // Its REST API names no relationship between the viewer and the merge request beyond
53 // `can_merge`, so the four an author keeps stay offered to everyone rather than being taken
54 // from the one person entitled to them.
55 expect(gitLabViewerPermissions({ viewerCanMerge: false }).actions).toEqual([
56 "ready",
57 "draft",
58 "close",
59 "reopen",
60 ]);
61 });
62});
63
64describe("getChangeRequest base freshness", () => {
65 const detail = {
66 number: 7,
67 title: "Merge request 7",
68 url: "https://gitlab.com/acme/web/-/merge_requests/7",
69 author: null,
70 headBranch: "feat/page",
71 baseBranch: "main",
72 state: "open" as const,
73 isDraft: false,
74 mergeability: "mergeable" as const,
75 additions: 0,
76 deletions: 0,
77 createdAt: "2026-07-01T00:00:00Z",
78 updatedAt: "2026-07-02T00:00:00Z",
79 reviewRequestLogins: [],
80 labels: [],
81 body: "",
82 changedFiles: 1,
83 mergedAt: null,
84 closedAt: null,
85 reviewers: [],
86 checks: [],
87 viewerCanMerge: true,
88 reviewerIds: [],
89 };
90
91 it.effect("reads checks without fetching project settings", () =>
92 Effect.gen(function* () {
93 const provider = yield* make.pipe(
94 Effect.provide(
95 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({
96 getMergeRequestDetail: () => Effect.succeed(detail),
97 }),
98 ),
99 );
100 const read = provider.getChangeRequestChecks;
101 if (read === undefined) return yield* Effect.die("checks read missing");
102 expect(
103 yield* read({ cwd: "/w", repository: "group/subgroup/web", host: "gitlab.com", number: 7 }),
104 ).toEqual({ state: "open", checks: [] });
105 }),
106 );
107
108 const readWith = (divergence: { readonly divergedCommits?: number }) =>
109 Effect.gen(function* () {
110 const provider = yield* make;
111 return yield* provider.getChangeRequest({
112 cwd: "/w",
113 repository: "acme/web",
114 host: "gitlab.com",
115 number: 7,
116 });
117 }).pipe(
118 Effect.provide(
119 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({
120 getMergeRequestDetail: () => Effect.succeed({ ...detail, ...divergence }),
121 getProjectMergeCapabilities: () =>
122 Effect.succeed({ merge: true, squash: true, rebase: true }),
123 }),
124 ),
125 );
126
127 it.effect("reads a counted divergence as a branch that has fallen behind", () =>
128 Effect.gen(function* () {
129 const changeRequest = yield* readWith({ divergedCommits: 3 });
130
131 expect(changeRequest.baseComparison).toBe("behind");
132 expect(changeRequest.behindBy).toBe(3);
133 }),
134 );
135
136 it.effect("reads a divergence of none as a branch that is current", () =>
137 Effect.gen(function* () {
138 const changeRequest = yield* readWith({ divergedCommits: 0 });
139
140 expect(changeRequest.baseComparison).toBe("up-to-date");
141 expect(changeRequest.behindBy).toBe(0);
142 }),
143 );
144
145 it.effect("says nothing at all where GitLab counted nothing", () =>
146 Effect.gen(function* () {
147 // An install too old to answer has to leave the page silent rather than let it claim the
148 // branch is current, which is the one wrong thing this banner could say.
149 const changeRequest = yield* readWith({});
150
151 expect(changeRequest.baseComparison).toBe("unknown");
152 expect(changeRequest.behindBy).toBeUndefined();
153 }),
154 );
155});
156
157describe("rewriting what has already been said", () => {
158 const updateMergeRequest = vi.fn(() => Effect.void);
159 const updateNote = vi.fn(() => Effect.void);
160
161 const providerWith = make.pipe(
162 Effect.provide(
163 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ updateMergeRequest, updateNote }),
164 ),
165 );
166
167 it.effect("sends only the half of the merge request the reader rewrote", () =>
168 Effect.gen(function* () {
169 const provider = yield* providerWith;
170 assert.isDefined(provider.updateChangeRequest);
171
172 yield* provider.updateChangeRequest({
173 cwd: "/w",
174 repository: "acme/web",
175 host: "gitlab.com",
176 number: 7,
177 body: "What this changes.",
178 });
179
180 // GitLab calls it the description, and the title stays out of the request entirely.
181 expect(updateMergeRequest).toHaveBeenCalledWith({
182 cwd: "/w",
183 repository: "acme/web",
184 number: 7,
185 description: "What this changes.",
186 });
187 }),
188 );
189
190 it.effect("rewrites a positioned comment through the same note as any other", () =>
191 Effect.gen(function* () {
192 const provider = yield* providerWith;
193 assert.isDefined(provider.updateComment);
194
195 yield* provider.updateComment({
196 cwd: "/w",
197 repository: "acme/web",
198 host: "gitlab.com",
199 number: 7,
200 commentId: "42",
201 kind: "review-comment",
202 body: "Reworded.",
203 });
204
205 expect(updateNote).toHaveBeenCalledWith({
206 cwd: "/w",
207 repository: "acme/web",
208 number: 7,
209 noteId: "42",
210 body: "Reworded.",
211 });
212 }),
213 );
214});
packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts created+339
......@@ -0,0 +1,339 @@
1import * as Effect from "effect/Effect";
2import type {
3 PullRequestCapabilities,
4 PullRequestReaction,
5 PullRequestViewerPermissions,
6} from "@t3tools/contracts";
7
8import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
9import {
10 PullRequestProviderError,
11 type PullRequestProviderFailure,
12 type ProviderChangeRequestActivity,
13 type ProviderChangeRequestDetail,
14 type PullRequestProviderApi,
15} from "@t3tools/source-control-core/server/PullRequestProvider";
16
17const CAPABILITIES: PullRequestCapabilities = {
18 diff: true,
19 comment: true,
20 actions: [
21 "merge",
22 "ready",
23 "draft",
24 "close",
25 "reopen",
26 "update-branch",
27 "enable-auto-merge",
28 "disable-auto-merge",
29 ],
30 // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it.
31 mergeMethods: ["merge", "squash", "rebase"],
32 // Rebase alone: GitLab moves a stale branch onto its target by replaying it, and has nothing
33 // that merges the target back in the way GitHub's update button can. Declaring only what it
34 // does is what lets a request to merge the target in be refused instead of quietly rebasing.
35 updateMethods: ["rebase"],
36 search: true,
37 reactions: true,
38 // GitLab keeps a reader's viewed files in one browser's local storage, where nothing outside
39 // that browser can read or write them. So the marks made here are this environment's own: they
40 // follow the reader between the clients connected to it, but they are not the ones gitlab.com
41 // shows, and the surface says so rather than implying a review can be carried on from there.
42 viewedFiles: "environment",
43 review: {
44 inlineComment: true,
45 reply: true,
46 resolve: true,
47 // No "changes requested": GitLab has approval and unresolved discussions, and nothing that
48 // says a merge request has been reviewed and rejected.
49 verdicts: ["comment", "approve"],
50 },
51 reviewers: { request: true, listCandidates: true },
52 edit: { changeRequest: true, comment: true },
53};
54
55/**
56 * The actions `user.can_merge` answers for. Rebasing writes to the source branch rather than to
57 * the target, so it is not literally the same permission — but GitLab reports nothing narrower,
58 * and someone it will not let land this change has no business rewriting its branch either.
59 */
60const MERGE_ACTIONS: ReadonlySet<string> = new Set([
61 "merge",
62 "update-branch",
63 "enable-auto-merge",
64 "disable-auto-merge",
65]);
66
67/**
68 * What the signed-in account may do here. GitLab answers exactly one of these questions per
69 * viewer, on the merge request itself: `user.can_merge`, which is why merging is the only thing
70 * narrowed.
71 *
72 * The rest stay granted. GitLab's REST API reports the viewer's role on the project but never
73 * whether they opened this merge request — and its author may close it, reopen it and move it in
74 * and out of draft whatever their role, just as the author of a note may resolve the discussion
75 * it started. Withholding those controls from the one person entitled to them is the worse of the
76 * two mistakes, so they are offered and GitLab explains any refusal itself.
77 *
78 * Asking for a review is granted for the same reason: GitLab takes a reviewer set from the author
79 * and from anyone with the Developer role, and states neither of those two facts here.
80 */
81export function gitLabViewerPermissions(input: {
82 readonly viewerCanMerge: boolean;
83}): PullRequestViewerPermissions {
84 return {
85 // Arming the merge and taking the arming back are the merge, deferred, so they answer to
86 // the same `can_merge` the merge itself does.
87 actions: CAPABILITIES.actions.filter(
88 (action) => !MERGE_ACTIONS.has(action) || input.viewerCanMerge,
89 ),
90 comment: true,
91 resolve: true,
92 verdicts: CAPABILITIES.review.verdicts,
93 requestReviewers: true,
94 ...(input.viewerCanMerge ? { updateMethods: CAPABILITIES.updateMethods } : {}),
95 };
96}
97
98/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */
99export function gitLabProviderFailure(
100 error: GitLabPullRequestCli.GitLabPullRequestCliError,
101): PullRequestProviderFailure {
102 if (error._tag === "GitLabCliUnavailableError") return { reason: "missing-tool" };
103 if (error._tag === "GitLabCliAuthenticationError") return { reason: "unauthenticated" };
104 if (error._tag === "GitLabCliRateLimitError") return { reason: "rate-limited" };
105 if (error._tag === "GitLabMergeRequestNotFoundError") return { reason: "not-found" };
106 return { reason: "failed" };
107}
108
109export const make = Effect.gen(function* () {
110 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
111
112 const fail = (operation: string) => (error: GitLabPullRequestCli.GitLabPullRequestCliError) =>
113 new PullRequestProviderError({
114 provider: "gitlab",
115 operation,
116 ...gitLabProviderFailure(error),
117 detail: error.detail,
118 cause: error,
119 });
120
121 const provider: PullRequestProviderApi = {
122 kind: "gitlab",
123 capabilities: CAPABILITIES,
124
125 getViewer: (input) =>
126 cli.getViewerUsername({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))),
127
128 listChangeRequests: (input) =>
129 cli
130 .listMergeRequests({
131 cwd: input.cwd,
132 repository: input.repository,
133 state: input.state,
134 involvement: input.involvement,
135 viewer: input.viewer,
136 limit: input.limit,
137 query: input.query,
138 cursor: input.cursor,
139 })
140 .pipe(
141 Effect.mapError(fail("listChangeRequests")),
142 // GitLab is asked for its merge requests by update, newest first, whether or not it is
143 // being carried on from — so every page it answers is one a cursor can continue.
144 Effect.map((batch) => ({ ...batch, continues: true })),
145 ),
146
147 getChangeRequestChecks: (input) =>
148 cli.getMergeRequestDetail(input).pipe(
149 Effect.map(({ state, checks }) => ({ state, checks })),
150 Effect.mapError(fail("getChangeRequestChecks")),
151 ),
152
153 getChangeRequest: (input) =>
154 Effect.all(
155 [
156 cli.getMergeRequestDetail(input),
157 cli.getProjectMergeCapabilities({ cwd: input.cwd, repository: input.repository }),
158 ],
159 { concurrency: 2 },
160 ).pipe(
161 Effect.mapError(fail("getChangeRequest")),
162 Effect.map(([mergeRequest, mergeCapabilities]): ProviderChangeRequestDetail => ({
163 ...mergeRequest,
164 mergeCapabilities,
165 viewerPermissions: gitLabViewerPermissions(mergeRequest),
166 // A GitLab too old to count the divergence says nothing here rather than "up to
167 // date": the banner is worth missing, and a wrong all-clear is not worth showing.
168 baseComparison:
169 mergeRequest.divergedCommits === undefined
170 ? "unknown"
171 : mergeRequest.divergedCommits > 0
172 ? "behind"
173 : "up-to-date",
174 ...(mergeRequest.divergedCommits === undefined
175 ? {}
176 : { behindBy: mergeRequest.divergedCommits }),
177 })),
178 ),
179
180 getChangeRequestActivity: (input) =>
181 Effect.all(
182 [
183 cli
184 .listNotes(input)
185 .pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: true }))),
186 cli.listCommits(input).pipe(Effect.orElseSucceed(() => [])),
187 cli
188 .listDiscussions(input)
189 .pipe(Effect.orElseSucceed(() => ({ threads: [], truncated: true }))),
190 // The notes endpoint carries no award of any kind, so they are read alongside it. A
191 // failed read costs the conversation its reactions rather than its words.
192 cli.listReactions(input).pipe(
193 Effect.orElseSucceed(() => ({
194 reactions: [] as ReadonlyArray<PullRequestReaction>,
195 reactionsByNoteId: new Map<string, ReadonlyArray<PullRequestReaction>>(),
196 })),
197 ),
198 ],
199 { concurrency: 4 },
200 ).pipe(
201 Effect.mapError(fail("getChangeRequestActivity")),
202 Effect.map(([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({
203 reactions: awards.reactions,
204 comments: notes.comments.map((comment) => ({
205 ...comment,
206 reactions: awards.reactionsByNoteId.get(comment.id) ?? [],
207 })),
208 // GitLab reports no count of its own, so the walk's own total is the host's: the
209 // notes endpoint carries every comment on the merge request, including the ones
210 // written under a discussion, and it is read until GitLab runs out.
211 commentCount: notes.comments.length,
212 commentsTruncated: notes.truncated || discussions.truncated,
213 reviewThreads: discussions.threads.map((thread) => ({
214 ...thread,
215 comments: thread.comments.map((comment) => ({
216 ...comment,
217 reactions: awards.reactionsByNoteId.get(comment.id) ?? [],
218 })),
219 })),
220 commits,
221 })),
222 ),
223
224 // The same read the detail takes it from, on its own: `user.can_merge` lives on the merge
225 // request, so there is no cheaper thing to ask GitLab.
226 getViewerPermissions: (input) =>
227 cli
228 .getMergeRequestDetail(input)
229 .pipe(Effect.mapError(fail("getViewerPermissions")), Effect.map(gitLabViewerPermissions)),
230
231 getDiff: (input) => cli.getMergeRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))),
232
233 // What each marked file is at the head, which is what tells a mark that still stands from one
234 // the branch has moved past. GitLab's own local-storage marks are keyed on the blob id too,
235 // so this stales at the same moment its web UI would.
236 getFileRevisions: (input) =>
237 cli.getFileRevisions(input).pipe(
238 Effect.mapError(fail("getFileRevisions")),
239 Effect.map((revisions) => ({ revisions })),
240 ),
241
242 // Users only: GitLab requests a review of a person, and the groups that can stand in for one
243 // appear in approval rules rather than in a merge request's reviewers.
244 listReviewerCandidates: (input) =>
245 cli
246 .listReviewerCandidates({
247 cwd: input.cwd,
248 repository: input.repository,
249 number: input.number,
250 })
251 .pipe(Effect.mapError(fail("listReviewerCandidates"))),
252
253 setReviewerRequest: (input) =>
254 cli
255 .setReviewerRequest({
256 cwd: input.cwd,
257 repository: input.repository,
258 number: input.number,
259 reviewers: input.reviewers,
260 requested: input.requested,
261 })
262 .pipe(Effect.mapError(fail("setReviewerRequest"))),
263
264 runAction: (input) =>
265 cli
266 .runMergeRequestAction({
267 cwd: input.cwd,
268 repository: input.repository,
269 number: input.number,
270 action: input.action,
271 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
272 })
273 .pipe(Effect.mapError(fail("runAction"))),
274
275 updateChangeRequest: (input) =>
276 cli
277 .updateMergeRequest({
278 cwd: input.cwd,
279 repository: input.repository,
280 number: input.number,
281 ...(input.title === undefined ? {} : { title: input.title }),
282 ...(input.body === undefined ? {} : { description: input.body }),
283 })
284 .pipe(Effect.mapError(fail("updateChangeRequest"))),
285
286 comment: (input) => cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))),
287
288 // The kind is not read: every comment this provider hands out, positioned or not, carries a
289 // plain REST note id, and one endpoint rewrites both.
290 updateComment: (input) =>
291 cli
292 .updateNote({
293 cwd: input.cwd,
294 repository: input.repository,
295 number: input.number,
296 noteId: input.commentId,
297 body: input.body,
298 })
299 .pipe(Effect.mapError(fail("updateComment"))),
300
301 submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))),
302
303 replyToThread: (input) =>
304 cli
305 .replyToDiscussion({
306 cwd: input.cwd,
307 repository: input.repository,
308 number: input.number,
309 discussionId: input.threadId,
310 body: input.body,
311 })
312 .pipe(Effect.mapError(fail("replyToThread"))),
313
314 setReaction: (input) =>
315 cli
316 .setReaction({
317 cwd: input.cwd,
318 repository: input.repository,
319 number: input.number,
320 ...(input.subjectId === undefined ? {} : { noteId: input.subjectId }),
321 content: input.content,
322 reacted: input.reacted,
323 })
324 .pipe(Effect.mapError(fail("setReaction"))),
325
326 setThreadResolution: (input) =>
327 cli
328 .setDiscussionResolution({
329 cwd: input.cwd,
330 repository: input.repository,
331 number: input.number,
332 discussionId: input.threadId,
333 resolved: input.resolved,
334 })
335 .pipe(Effect.mapError(fail("setThreadResolution"))),
336 };
337
338 return provider;
339});
packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts created+309
......@@ -0,0 +1,309 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5import { ChildProcessSpawner } from "effect/process";
6
7import * as GitLabCli from "./GitLabCli.ts";
8import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
9import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
10
11function makeProvider(gitlab: Partial<GitLabCli.GitLabCli["Service"]>) {
12 return GitLabSourceControlProvider.make.pipe(
13 Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)),
14 );
15}
16
17it.effect("maps GitLab MR summaries into provider-neutral change requests", () =>
18 Effect.gen(function* () {
19 const provider = yield* makeProvider({
20 getMergeRequest: () =>
21 Effect.succeed({
22 number: 42,
23 title: "Add GitLab provider",
24 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
25 baseRefName: "main",
26 headRefName: "feature/source-control",
27 state: "closed",
28 closedAt: "2026-08-23T10:00:00Z",
29 isCrossRepository: true,
30 headRepositoryNameWithOwner: "fork/t3code",
31 headRepositoryOwnerLogin: "fork",
32 }),
33 });
34
35 const changeRequest = yield* provider.getChangeRequest({
36 cwd: "/repo",
37 reference: "42",
38 });
39
40 assert.deepStrictEqual(changeRequest, {
41 provider: "gitlab",
42 number: 42,
43 title: "Add GitLab provider",
44 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
45 baseRefName: "main",
46 headRefName: "feature/source-control",
47 state: "closed",
48 closedAt: "2026-08-23T10:00:00Z",
49 mergedAt: null,
50 updatedAt: Option.none(),
51 isCrossRepository: true,
52 headRepositoryNameWithOwner: "fork/t3code",
53 headRepositoryOwnerLogin: "fork",
54 });
55 }),
56);
57
58it.effect("adds repository context while retaining GitLab CLI causes", () =>
59 Effect.gen(function* () {
60 const cause = new GitLabCli.GitLabCliCommandError({
61 operation: "execute",
62 command: "glab",
63 cwd: "/repo",
64 cause: new Error("raw upstream detail that should remain in the cause"),
65 });
66 const provider = yield* makeProvider({
67 createRepository: () => Effect.fail(cause),
68 });
69
70 const error = yield* provider
71 .createRepository({
72 cwd: "/repo",
73 repository: "owner/repo",
74 visibility: "private",
75 })
76 .pipe(Effect.flip);
77
78 assert.deepStrictEqual(
79 {
80 provider: error.provider,
81 operation: error.operation,
82 command: error.command,
83 cwd: error.cwd,
84 repository: error.repository,
85 detail: error.detail,
86 },
87 {
88 provider: "gitlab",
89 operation: "createRepository",
90 command: "glab",
91 cwd: "/repo",
92 repository: "owner/repo",
93 detail: "GitLab CLI command failed.",
94 },
95 );
96 assert.strictEqual(error.cause, cause);
97 assert.equal(error.message.includes("raw upstream detail"), false);
98 }),
99);
100
101it.effect("lists GitLab MRs through provider-neutral input names", () =>
102 Effect.gen(function* () {
103 let listInput: Parameters<GitLabCli.GitLabCli["Service"]["listMergeRequests"]>[0] | null = null;
104 const provider = yield* makeProvider({
105 listMergeRequests: (input) => {
106 listInput = input;
107 return Effect.succeed([]);
108 },
109 });
110
111 yield* provider.listChangeRequests({
112 cwd: "/repo",
113 headSelector: "feature/provider",
114 state: "all",
115 limit: 10,
116 });
117
118 assert.deepStrictEqual(listInput, {
119 cwd: "/repo",
120 headSelector: "feature/provider",
121 state: "all",
122 limit: 10,
123 });
124 }),
125);
126
127it.effect("creates GitLab MRs through provider-neutral input names", () =>
128 Effect.gen(function* () {
129 let createInput: Parameters<GitLabCli.GitLabCli["Service"]["createMergeRequest"]>[0] | null =
130 null;
131 const provider = yield* makeProvider({
132 createMergeRequest: (input) => {
133 createInput = input;
134 return Effect.void;
135 },
136 });
137
138 yield* provider.createChangeRequest({
139 cwd: "/repo",
140 baseRefName: "main",
141 headSelector: "owner:feature/provider",
142 title: "Provider MR",
143 bodyFile: "/tmp/body.md",
144 });
145
146 assert.deepStrictEqual(createInput, {
147 cwd: "/repo",
148 baseBranch: "main",
149 headSelector: "owner:feature/provider",
150 source: {
151 owner: "owner",
152 refName: "feature/provider",
153 },
154 title: "Provider MR",
155 bodyFile: "/tmp/body.md",
156 });
157 }),
158);
159
160it("accepts authenticated GitLab hosts when another configured host fails", () => {
161 const auth = GitLabSourceControlProvider.discovery.parseAuth({
162 exitCode: ChildProcessSpawner.ExitCode(1),
163 stdout: `gitlab.com
164 x gitlab.com: API call failed: 401 Unauthorized
165 ! No token found
166self-hosted.example.test
167 ✓ Logged in to self-hosted.example.test as gitlab-user
168 ✓ Token found: ******
169`,
170 stderr: "",
171 });
172
173 assert.deepStrictEqual(
174 {
175 status: auth.status,
176 account: auth.account,
177 host: auth.host,
178 },
179 {
180 status: "authenticated",
181 account: Option.some("gitlab-user"),
182 host: Option.some("self-hosted.example.test"),
183 },
184 );
185});
186
187it("refines unknown GitLab remotes with mixed-case provider hosts", () => {
188 const provider = GitLabSourceControlProvider.discovery.refineUnknownRemote?.({
189 cwd: "/repo",
190 context: {
191 provider: {
192 kind: "unknown",
193 name: "Self-Hosted.Example.Test",
194 baseUrl: "https://Self-Hosted.Example.Test",
195 },
196 remoteName: "origin",
197 remoteUrl: "https://Self-Hosted.Example.Test/group/project.git",
198 },
199 auth: {
200 exitCode: ChildProcessSpawner.ExitCode(0),
201 stdout: `self-hosted.example.test
202 ✓ Logged in to self-hosted.example.test as gitlab-user
203 ✓ Token found: ******
204`,
205 stderr: "",
206 },
207 });
208
209 assert.deepStrictEqual(provider, {
210 kind: "gitlab",
211 name: "GitLab Self-Hosted",
212 baseUrl: "https://Self-Hosted.Example.Test",
213 });
214});
215
216it("parses authenticated GitLab auth status hosts with ports and single-label names", () => {
217 assert.deepStrictEqual(
218 parseGitLabAuthStatusHosts(`localhost:8080
219 ✓ Logged in to localhost:8080 as local-user
220selfhosted
221 ✓ Logged in to selfhosted as single-label-user
222`),
223 [
224 { host: "localhost:8080", account: "local-user" },
225 { host: "selfhosted", account: "single-label-user" },
226 ],
227 );
228});
229
230it.effect.each(["merge_requests", "issues"])(
231 "resolves %s subjects on the linked host without using the checkout",
232 (kind) =>
233 Effect.gen(function* () {
234 const provider = yield* makeProvider({
235 execute: (input) => {
236 assert.deepStrictEqual(input.args, [
237 "api",
238 "--hostname",
239 "gitlab.com",
240 `projects/group%2Fsubgroup%2Fproject/${kind}/42`,
241 ]);
242 assert.strictEqual(input.maxOutputBytes, 32_000);
243 assert.strictEqual(input.timeoutMs, 3_000);
244 return Effect.succeed({
245 exitCode: ChildProcessSpawner.ExitCode(0),
246 stdout: JSON.stringify({
247 title: "Pairing expiry",
248 description: "Preserve remote access",
249 }),
250 stderr: "",
251 stdoutTruncated: false,
252 stderrTruncated: false,
253 });
254 },
255 });
256 const lookup = provider.resolveLink?.({
257 cwd: "/unrelated",
258 url: new URL(`https://gitlab.com/group/subgroup/project/-/${kind}/42`),
259 });
260 assert.ok(lookup);
261 assert.deepStrictEqual(yield* lookup, {
262 title: "Pairing expiry",
263 body: "Preserve remote access",
264 });
265 assert.strictEqual(
266 provider.resolveLink?.({
267 cwd: "/unrelated",
268 url: new URL("https://gitlab.com/owner/repo"),
269 }),
270 undefined,
271 );
272 }),
273);
274
275it.effect.each(["read", "decode"] as const)(
276 "retains the %s failure without exposing its raw contents",
277 (stage) =>
278 Effect.gen(function* () {
279 const cause = new GitLabCli.GitLabCliCommandError({
280 command: "glab",
281 cwd: "/repo",
282 operation: "execute",
283 cause: new Error("private response text"),
284 });
285 const provider = yield* makeProvider({
286 execute: () =>
287 stage === "read"
288 ? Effect.fail(cause)
289 : Effect.succeed({
290 exitCode: ChildProcessSpawner.ExitCode(0),
291 stdout: "private response text",
292 stderr: "",
293 stdoutTruncated: false,
294 stderrTruncated: false,
295 }),
296 });
297 const lookup = provider.resolveLink?.({
298 cwd: "/repo",
299 url: new URL("https://gitlab.com/owner/repo/-/issues/42"),
300 });
301 assert.ok(lookup);
302 const error = yield* Effect.flip(lookup);
303 assert.strictEqual(error.operation, stage === "read" ? "resolveLink" : "resolveLink.decode");
304 assert.strictEqual(error.detail, "The linked subject could not be read.");
305 assert.notInclude(error.message, "private response text");
306 if (stage === "read") assert.strictEqual(error.cause, cause);
307 else assert.propertyVal(error.cause, "_tag", "SchemaError");
308 }),
309);
packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts created+308
......@@ -0,0 +1,308 @@
1import * as Schema from "effect/Schema";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
5
6import * as GitLabCli from "./GitLabCli.ts";
7import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
8import {
9 combinedAuthOutput,
10 firstSafeAuthLine,
11 matchFirst,
12 parseCliHost,
13 providerAuth,
14 type SourceControlAuthProbeInput,
15 type SourceControlCliDiscoverySpec,
16 type SourceControlUnknownRemoteRefinementInput,
17} from "@t3tools/source-control-core/server/discovery";
18import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
19
20const decodeLinkSubject = Schema.decodeUnknownEffect(
21 Schema.fromJsonString(
22 Schema.Struct({ title: Schema.String, description: Schema.NullOr(Schema.String) }),
23 ),
24);
25
26function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest {
27 return {
28 provider: "gitlab",
29 number: summary.number,
30 title: summary.title,
31 url: summary.url,
32 baseRefName: summary.baseRefName,
33 headRefName: summary.headRefName,
34 state: summary.state ?? "open",
35 ...(summary.isDraft === true ? { isDraft: true } : {}),
36 closedAt: summary.closedAt ?? null,
37 mergedAt: summary.mergedAt ?? null,
38 updatedAt: summary.updatedAt ?? Option.none(),
39 ...(summary.isCrossRepository !== undefined
40 ? { isCrossRepository: summary.isCrossRepository }
41 : {}),
42 ...(summary.headRepositoryNameWithOwner !== undefined
43 ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner }
44 : {}),
45 ...(summary.headRepositoryOwnerLogin !== undefined
46 ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin }
47 : {}),
48 };
49}
50
51function parseGitLabAuth(input: SourceControlAuthProbeInput) {
52 const output = combinedAuthOutput(input);
53 const authenticatedHost = findAuthenticatedGitLabHost(parseGitLabAuthStatusHosts(output));
54 const account =
55 authenticatedHost?.account ??
56 matchFirst(output, [
57 /Logged in to .* as\s+([^\s(]+)/iu,
58 /Logged in to .* account\s+([^\s(]+)/iu,
59 /account:\s*([^\s(]+)/iu,
60 ]);
61 const host = authenticatedHost?.host ?? parseCliHost(output);
62
63 if (account) {
64 return providerAuth({ status: "authenticated", account, host });
65 }
66
67 if (input.exitCode !== 0) {
68 return providerAuth({
69 status: "unauthenticated",
70 host,
71 detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.",
72 });
73 }
74
75 return providerAuth({
76 status: "unknown",
77 host,
78 detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.",
79 });
80}
81
82function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementInput) {
83 const host = input.context.provider.name.toLowerCase();
84 const authenticated = parseGitLabAuthStatusHosts(combinedAuthOutput(input.auth)).some(
85 (entry) => entry.account !== null && entry.host === host,
86 );
87
88 if (!authenticated) {
89 return null;
90 }
91
92 return {
93 kind: "gitlab",
94 name: "GitLab Self-Hosted",
95 baseUrl: input.context.provider.baseUrl,
96 } as const;
97}
98
99export const discovery = {
100 type: "cli",
101 kind: "gitlab",
102 label: "GitLab",
103 executable: "glab",
104 versionArgs: ["--version"],
105 authArgs: ["auth", "status"],
106 parseAuth: parseGitLabAuth,
107 refineUnknownRemote: refineUnknownGitLabRemote,
108 installHint:
109 "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).",
110} satisfies SourceControlCliDiscoverySpec;
111
112export const make = Effect.gen(function* () {
113 const gitlab = yield* GitLabCli.GitLabCli;
114
115 const readLinkSubject = Effect.fn("GitLabSourceControlProvider.readLinkSubject")(function* (
116 input: { readonly cwd: string; readonly url: URL },
117 endpoint: string,
118 ) {
119 const result = yield* gitlab
120 .execute({
121 cwd: input.cwd,
122 args: ["api", "--hostname", input.url.host, endpoint],
123 timeoutMs: 3_000,
124 maxOutputBytes: 32_000,
125 })
126 .pipe(
127 Effect.mapError(
128 (cause) =>
129 new SourceControlProviderError({
130 provider: "gitlab",
131 operation: "resolveLink",
132 cwd: input.cwd,
133 detail: "The linked subject could not be read.",
134 cause,
135 }),
136 ),
137 );
138 const subject = yield* decodeLinkSubject(result.stdout).pipe(
139 Effect.mapError(
140 (cause) =>
141 new SourceControlProviderError({
142 provider: "gitlab",
143 operation: "resolveLink.decode",
144 cwd: input.cwd,
145 detail: "The linked subject could not be read.",
146 cause,
147 }),
148 ),
149 );
150 return { title: subject.title, body: subject.description };
151 });
152
153 return SourceControlProvider.SourceControlProvider.of({
154 kind: "gitlab",
155 resolveLink: (input) => {
156 // Automatic enrichment must not send ambient CLI credentials to a host from message text.
157 if (input.url.host !== "gitlab.com") return undefined;
158 const match = /^\/(.+)\/-\/(merge_requests|issues)\/([1-9]\d*)(?:\/.*)?$/.exec(
159 input.url.pathname,
160 );
161 if (!match) return undefined;
162 return readLinkSubject(
163 input,
164 `projects/${encodeURIComponent(match[1]!)}/${match[2]}/${match[3]}`,
165 );
166 },
167 listChangeRequests: (input) => {
168 const source = SourceControlProvider.sourceControlRefFromInput(input);
169 return gitlab
170 .listMergeRequests({
171 cwd: input.cwd,
172 headSelector: input.headSelector,
173 ...(source ? { source } : {}),
174 state: input.state,
175 ...(input.limit !== undefined ? { limit: input.limit } : {}),
176 })
177 .pipe(
178 Effect.map((items) => items.map(toChangeRequest)),
179 Effect.mapError(
180 (error) =>
181 new SourceControlProviderError({
182 provider: "gitlab",
183 operation: "listChangeRequests",
184 command: error.command,
185 cwd: input.cwd,
186 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
187 input.headSelector,
188 ),
189 detail: error.detail,
190 cause: error,
191 }),
192 ),
193 );
194 },
195 getChangeRequest: (input) =>
196 gitlab.getMergeRequest(input).pipe(
197 Effect.map(toChangeRequest),
198 Effect.mapError(
199 (error) =>
200 new SourceControlProviderError({
201 provider: "gitlab",
202 operation: "getChangeRequest",
203 command: error.command,
204 cwd: input.cwd,
205 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
206 input.reference,
207 ),
208 detail: error.detail,
209 cause: error,
210 }),
211 ),
212 ),
213 createChangeRequest: (input) => {
214 const source = SourceControlProvider.sourceControlRefFromInput(input);
215 return gitlab
216 .createMergeRequest({
217 cwd: input.cwd,
218 baseBranch: input.baseRefName,
219 headSelector: input.headSelector,
220 ...(source ? { source } : {}),
221 ...(input.target ? { target: input.target } : {}),
222 title: input.title,
223 bodyFile: input.bodyFile,
224 })
225 .pipe(
226 Effect.mapError(
227 (error) =>
228 new SourceControlProviderError({
229 provider: "gitlab",
230 operation: "createChangeRequest",
231 command: error.command,
232 cwd: input.cwd,
233 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
234 input.headSelector,
235 ),
236 detail: error.detail,
237 cause: error,
238 }),
239 ),
240 );
241 },
242 getRepositoryCloneUrls: (input) =>
243 gitlab.getRepositoryCloneUrls(input).pipe(
244 Effect.mapError(
245 (error) =>
246 new SourceControlProviderError({
247 provider: "gitlab",
248 operation: "getRepositoryCloneUrls",
249 command: error.command,
250 cwd: input.cwd,
251 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
252 input.repository,
253 ),
254 detail: error.detail,
255 cause: error,
256 }),
257 ),
258 ),
259 createRepository: (input) =>
260 gitlab.createRepository(input).pipe(
261 Effect.mapError(
262 (error) =>
263 new SourceControlProviderError({
264 provider: "gitlab",
265 operation: "createRepository",
266 command: error.command,
267 cwd: input.cwd,
268 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
269 input.repository,
270 ),
271 detail: error.detail,
272 cause: error,
273 }),
274 ),
275 ),
276 getDefaultBranch: (input) =>
277 gitlab.getDefaultBranch(input).pipe(
278 Effect.mapError(
279 (error) =>
280 new SourceControlProviderError({
281 provider: "gitlab",
282 operation: "getDefaultBranch",
283 command: error.command,
284 cwd: input.cwd,
285 detail: error.detail,
286 cause: error,
287 }),
288 ),
289 ),
290 checkoutChangeRequest: (input) =>
291 gitlab.checkoutMergeRequest(input).pipe(
292 Effect.mapError(
293 (error) =>
294 new SourceControlProviderError({
295 provider: "gitlab",
296 operation: "checkoutChangeRequest",
297 command: error.command,
298 cwd: input.cwd,
299 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
300 input.reference,
301 ),
302 detail: error.detail,
303 cause: error,
304 }),
305 ),
306 ),
307 });
308});
packages/source-control-gitlab/src/server/driver.ts created+19
......@@ -0,0 +1,19 @@
1/**
2 * The GitLab driver: repository operations, discovery, and merge requests through `glab`.
3 *
4 * @module source-control-gitlab/server/driver
5 */
6import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver";
7import * as Effect from "effect/Effect";
8
9import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts";
10import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
11
12export const driver = defineSourceControlDriver({
13 kind: "gitlab",
14 make: Effect.all({
15 sourceControl: GitLabSourceControlProvider.make,
16 discovery: Effect.succeed(GitLabSourceControlProvider.discovery),
17 pullRequests: GitLabPullRequestProvider.make,
18 }),
19});
packages/source-control-gitlab/src/server/gitLabAuthStatus.ts created+48
......@@ -0,0 +1,48 @@
1const HOST_LINE_PATTERN = /^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\[[a-f0-9:.]+\])(?::\d+)?$/iu;
2const LOGGED_IN_PATTERN = /Logged in to .+? as\s+([^\s(]+)/iu;
3
4export interface GitLabAuthStatusHost {
5 readonly host: string;
6 readonly account: string | null;
7}
8
9export function parseGitLabAuthStatusHosts(text: string): ReadonlyArray<GitLabAuthStatusHost> {
10 const hosts: GitLabAuthStatusHost[] = [];
11 let currentHost: string | null = null;
12 let currentLines: string[] = [];
13
14 const flush = () => {
15 if (currentHost === null) return;
16
17 const account = LOGGED_IN_PATTERN.exec(currentLines.join("\n"))?.[1]?.trim() || null;
18 hosts.push({ host: currentHost, account });
19 currentHost = null;
20 currentLines = [];
21 };
22
23 for (const rawLine of text.split(/\r?\n/)) {
24 const line = rawLine.trim();
25 if (line.length === 0) continue;
26
27 const isHostLine =
28 rawLine.length === rawLine.trimStart().length && HOST_LINE_PATTERN.test(line);
29 if (isHostLine) {
30 flush();
31 currentHost = line.toLowerCase();
32 continue;
33 }
34
35 if (currentHost !== null) {
36 currentLines.push(line);
37 }
38 }
39
40 flush();
41 return hosts;
42}
43
44export function findAuthenticatedGitLabHost(
45 hosts: ReadonlyArray<GitLabAuthStatusHost>,
46): GitLabAuthStatusHost | undefined {
47 return hosts.find((host) => host.account !== null);
48}
packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts created+803
......@@ -0,0 +1,803 @@
1import * as Result from "effect/Result";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 decodeAwardEmojiJson,
6 decodeRepositoryBlobsJson,
7 decodeCommitsJson,
8 decodeMergeRequestDetailJson,
9 decodeMergeRequestDiffsJson,
10 decodeMergeRequestListJson,
11 decodeNotesJson,
12 decodeOwnAwardIdJson,
13 decodeViewerJson,
14 gitLabAwardName,
15} from "./gitLabMergeRequestJson.ts";
16
17function listJson(entries: ReadonlyArray<Record<string, unknown>>): string {
18 return JSON.stringify(
19 entries.map((entry) => ({
20 iid: 1,
21 title: "Add the merge requests page",
22 web_url: "https://gitlab.com/acme/web/-/merge_requests/1",
23 source_branch: "feat/page",
24 target_branch: "main",
25 created_at: "2026-07-01T00:00:00Z",
26 updated_at: "2026-07-02T00:00:00Z",
27 ...entry,
28 })),
29 );
30}
31
32function detailJson(entry: Record<string, unknown>): string {
33 return JSON.stringify({
34 iid: 1,
35 title: "Add the merge requests page",
36 web_url: "https://gitlab.com/acme/web/-/merge_requests/1",
37 source_branch: "feat/page",
38 target_branch: "main",
39 created_at: "2026-07-01T00:00:00Z",
40 updated_at: "2026-07-02T00:00:00Z",
41 ...entry,
42 });
43}
44
45function expectSuccess<A>(result: Result.Result<A, unknown>): A {
46 expect(Result.isSuccess(result)).toBe(true);
47 if (!Result.isSuccess(result)) throw new Error("expected a successful decode");
48 return result.success;
49}
50
51describe("decodeMergeRequestListJson", () => {
52 it("reads a merge request as a change request", () => {
53 const batch = expectSuccess(
54 decodeMergeRequestListJson(
55 listJson([
56 {
57 iid: 42,
58 author: { username: "bilal", name: "Bilal" },
59 state: "opened",
60 merge_status: "can_be_merged",
61 draft: false,
62 reviewers: [{ username: "julius" }],
63 labels: ["backend", " "],
64 },
65 ]),
66 ),
67 );
68
69 expect(batch.items).toHaveLength(1);
70 expect(batch.items[0]).toMatchObject({
71 number: 42,
72 author: { login: "bilal", name: "Bilal" },
73 headBranch: "feat/page",
74 baseBranch: "main",
75 state: "open",
76 isDraft: false,
77 mergeability: "mergeable",
78 reviewRequestLogins: ["julius"],
79 labels: [{ name: "backend", color: null }],
80 });
81 });
82
83 it("reports no line counts, which GitLab does not expose", () => {
84 const batch = expectSuccess(decodeMergeRequestListJson(listJson([{}])));
85
86 expect(batch.items[0]).toMatchObject({ additions: 0, deletions: 0 });
87 });
88
89 it("treats a merged timestamp as merged whatever the state says", () => {
90 const batch = expectSuccess(
91 decodeMergeRequestListJson(
92 listJson([{ state: "opened", merged_at: "2026-07-03T00:00:00Z" }]),
93 ),
94 );
95
96 expect(batch.items[0]?.state).toBe("merged");
97 });
98
99 it("keeps a locked merge request open", () => {
100 const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ state: "locked" }])));
101
102 expect(batch.items[0]?.state).toBe("open");
103 });
104
105 it("reads the legacy draft flag", () => {
106 const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ work_in_progress: true }])));
107
108 expect(batch.items[0]?.isDraft).toBe(true);
109 });
110
111 it("calls a conflicted merge request conflicting even while the merge check is pending", () => {
112 const batch = expectSuccess(
113 decodeMergeRequestListJson(listJson([{ merge_status: "checking", has_conflicts: true }])),
114 );
115
116 expect(batch.items[0]?.mergeability).toBe("conflicting");
117 });
118
119 it("leaves an unfinished merge check unknown", () => {
120 const batch = expectSuccess(
121 decodeMergeRequestListJson(listJson([{ merge_status: "checking" }])),
122 );
123
124 expect(batch.items[0]?.mergeability).toBe("unknown");
125 });
126
127 it("skips a malformed row but still counts it, so paging does not stop early", () => {
128 const batch = expectSuccess(
129 decodeMergeRequestListJson(
130 JSON.stringify([{ iid: "not a number" }, ...JSON.parse(listJson([{}]))]),
131 ),
132 );
133
134 expect(batch.items).toHaveLength(1);
135 expect(batch.rawIndexes).toEqual([1]);
136 expect(batch.rawCount).toBe(2);
137 });
138});
139
140describe("decodeMergeRequestDetailJson", () => {
141 it("reads the description, file count and pipeline", () => {
142 const detail = expectSuccess(
143 decodeMergeRequestDetailJson(
144 detailJson({
145 description: "Ships the page.",
146 changes_count: "3",
147 reviewers: [{ username: "julius", name: "Julius" }],
148 head_pipeline: {
149 status: "success",
150 web_url: "https://gitlab.com/acme/web/-/pipelines/9",
151 source: "merge_request_event",
152 },
153 }),
154 ),
155 );
156
157 expect(detail.body).toBe("Ships the page.");
158 expect(detail.changedFiles).toBe(3);
159 expect(detail.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]);
160 expect(detail.checks).toEqual([
161 {
162 name: "Pipeline",
163 status: "success",
164 description: "merge_request_event",
165 url: "https://gitlab.com/acme/web/-/pipelines/9",
166 },
167 ]);
168 });
169
170 it("reads an uncounted change set as its floor", () => {
171 const detail = expectSuccess(
172 decodeMergeRequestDetailJson(detailJson({ changes_count: "1000+" })),
173 );
174
175 expect(detail.changedFiles).toBe(1000);
176 });
177
178 it("falls back to no file count when GitLab omits one", () => {
179 const detail = expectSuccess(decodeMergeRequestDetailJson(detailJson({})));
180
181 expect(detail.changedFiles).toBe(0);
182 });
183
184 it("reads either auto-merge field, and says nothing where GitLab named neither", () => {
185 const armed = (entry: Record<string, unknown>) =>
186 expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).autoMergeEnabled;
187
188 expect(armed({ merge_when_pipeline_succeeds: true })).toBe(true);
189 // The newer name for the same fact, which older GitLab installs do not send.
190 expect(armed({ auto_merge_enabled: true })).toBe(true);
191 expect(armed({ merge_when_pipeline_succeeds: false })).toBe(false);
192 // Absent is GitLab not saying, which the page must not read as "not armed".
193 expect(armed({})).toBeUndefined();
194 });
195
196 it("keeps the squash choice stored with auto-merge", () => {
197 expect(
198 expectSuccess(
199 decodeMergeRequestDetailJson(
200 detailJson({ auto_merge_enabled: true, squash_on_merge: true }),
201 ),
202 ),
203 ).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" });
204
205 expect(
206 expectSuccess(
207 decodeMergeRequestDetailJson(
208 detailJson({
209 auto_merge_enabled: true,
210 squash: true,
211 squash_on_merge: false,
212 }),
213 ),
214 ).autoMergeMethod,
215 ).toBeUndefined();
216 });
217
218 it("keeps a divergence GitLab did not count apart from a divergence of none", () => {
219 const behind = (entry: Record<string, unknown>) =>
220 expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).divergedCommits;
221
222 expect(behind({ diverged_commits_count: 3 })).toBe(3);
223 // Counted and found level, which is the one answer that entitles the page to say so.
224 expect(behind({ diverged_commits_count: 0 })).toBe(0);
225 // An install that does not answer, and a null where the answer would have gone, are both
226 // silence: reading either as zero would tell a stale branch it is current.
227 expect(behind({})).toBeUndefined();
228 expect(behind({ diverged_commits_count: null })).toBeUndefined();
229 });
230
231 it("maps a pipeline waiting on a person to neutral, not failure", () => {
232 const detail = expectSuccess(
233 decodeMergeRequestDetailJson(detailJson({ head_pipeline: { status: "manual" } })),
234 );
235
236 expect(detail.checks[0]?.status).toBe("neutral");
237 });
238});
239
240describe("decodeViewerJson", () => {
241 it("reads the signed-in username", () => {
242 expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: "bilal" })))).toBe("bilal");
243 });
244
245 it("returns nothing when the account has no username", () => {
246 expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: " " })))).toBeNull();
247 });
248});
249
250describe("decodeNotesJson", () => {
251 it("keeps comments and drops GitLab's own activity notes", () => {
252 const notes = expectSuccess(
253 decodeNotesJson(
254 JSON.stringify([
255 {
256 id: 1,
257 body: "assigned to @bilal",
258 system: true,
259 created_at: "2026-07-01T00:00:00Z",
260 },
261 {
262 id: 2,
263 body: "Looks good.",
264 author: { username: "julius" },
265 created_at: "2026-07-02T00:00:00Z",
266 },
267 { id: 3, body: " ", created_at: "2026-07-03T00:00:00Z" },
268 ]),
269 ),
270 );
271
272 expect(notes.comments).toHaveLength(1);
273 expect(notes.comments[0]).toMatchObject({
274 id: "2",
275 kind: "issue-comment",
276 body: "Looks good.",
277 });
278 // The raw count keeps the dropped notes visible to the caller, which needs them to page.
279 expect(notes.rawCount).toBe(3);
280 });
281
282 it("reads a line note as a review comment on its file", () => {
283 const notes = expectSuccess(
284 decodeNotesJson(
285 JSON.stringify([
286 {
287 id: 7,
288 type: "DiffNote",
289 body: "Rename this.",
290 created_at: "2026-07-02T00:00:00Z",
291 position: { new_path: "src/app.ts", old_path: "src/old.ts" },
292 },
293 ]),
294 ),
295 );
296
297 expect(notes.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" });
298 });
299
300 it("falls back to the old path for a note on a deleted line", () => {
301 const notes = expectSuccess(
302 decodeNotesJson(
303 JSON.stringify([
304 {
305 id: 8,
306 type: "DiffNote",
307 body: "Gone.",
308 created_at: "2026-07-02T00:00:00Z",
309 position: { new_path: null, old_path: "src/old.ts" },
310 },
311 ]),
312 ),
313 );
314
315 expect(notes.comments[0]?.path).toBe("src/old.ts");
316 });
317});
318
319describe("decodeCommitsJson", () => {
320 it("returns commits oldest first", () => {
321 const commits = expectSuccess(
322 decodeCommitsJson(
323 JSON.stringify([
324 { id: "bbb", title: "second", committed_date: "2026-07-02T00:00:00Z" },
325 { id: "aaa", title: "first", committed_date: "2026-07-01T00:00:00Z" },
326 ]),
327 ),
328 );
329
330 expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]);
331 });
332
333 it("skips commits whose id is empty", () => {
334 const commits = expectSuccess(
335 decodeCommitsJson(
336 JSON.stringify([
337 { id: " ", title: "invalid", committed_date: "2026-07-02T00:00:00Z" },
338 { id: "aaa", committed_date: "2026-07-01T00:00:00Z" },
339 ]),
340 ),
341 );
342
343 expect(commits.map((commit) => commit.oid)).toEqual(["aaa"]);
344 });
345
346 it("falls back to the creation timestamp when there is no commit date", () => {
347 const commits = expectSuccess(
348 decodeCommitsJson(
349 JSON.stringify([
350 {
351 id: "aaa",
352 created_at: "2026-07-01T00:00:00+08:00",
353 author_name: "Ada Lovelace",
354 author_email: "ada@example.com",
355 },
356 ]),
357 ),
358 );
359
360 expect(commits[0]).toMatchObject({
361 oid: "aaa",
362 committedDate: "2026-07-01T00:00:00+08:00",
363 authors: [{ login: "Ada Lovelace", name: "Ada Lovelace", avatarUrl: null }],
364 });
365 });
366
367 it("carries commit additions and deletions when GitLab returns stats", () => {
368 const commits = expectSuccess(
369 decodeCommitsJson(
370 JSON.stringify([
371 {
372 id: "aaa",
373 committed_date: "2026-07-01T00:00:00Z",
374 stats: { additions: 21, deletions: 8, total: 29 },
375 },
376 ]),
377 ),
378 );
379
380 expect(commits[0]).toMatchObject({ additions: 21, deletions: 8 });
381 });
382});
383
384describe("decodeMergeRequestDiffsJson", () => {
385 it("quotes literal backslashes without interpreting them as escapes", () => {
386 const result = expectSuccess(
387 decodeMergeRequestDiffsJson(
388 JSON.stringify([
389 {
390 old_path: String.raw`src\notes.ts`,
391 new_path: String.raw`src\notes.ts`,
392 diff: "@@ -1 +1 @@\n-old\n+new",
393 },
394 ]),
395 ),
396 );
397
398 expect(result.patch).toBe(
399 [
400 String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`,
401 String.raw`--- "a/src\\notes.ts"`,
402 String.raw`+++ "b/src\\notes.ts"`,
403 "@@ -1 +1 @@",
404 "-old",
405 "+new",
406 "",
407 ].join("\n"),
408 );
409 });
410
411 it("preserves spaces and literal backslashes in both rename paths", () => {
412 const result = expectSuccess(
413 decodeMergeRequestDiffsJson(
414 JSON.stringify([
415 {
416 old_path: String.raw` old\name.ts `,
417 new_path: String.raw` new\name.ts `,
418 renamed_file: true,
419 diff: "",
420 },
421 ]),
422 ),
423 );
424
425 expect(result.patch).toBe(
426 [
427 String.raw`diff --git "a/ old\\name.ts " "b/ new\\name.ts "`,
428 String.raw`rename from " old\\name.ts "`,
429 String.raw`rename to " new\\name.ts "`,
430 String.raw`--- "a/ old\\name.ts "`,
431 String.raw`+++ "b/ new\\name.ts "`,
432 ].join("\n"),
433 );
434 });
435
436 it("assembles a unified patch GitLab does not return", () => {
437 const result = expectSuccess(
438 decodeMergeRequestDiffsJson(
439 JSON.stringify([
440 {
441 old_path: "src/app.ts",
442 new_path: "src/app.ts",
443 diff: "@@ -1 +1 @@\n-old\n+new\n",
444 },
445 ]),
446 ),
447 );
448
449 expect(result.patch).toBe(
450 [
451 "diff --git a/src/app.ts b/src/app.ts",
452 "--- a/src/app.ts",
453 "+++ b/src/app.ts",
454 "@@ -1 +1 @@",
455 "-old",
456 "+new",
457 "",
458 ].join("\n"),
459 );
460 expect(result.truncated).toBe(false);
461 });
462
463 it("points a new file at /dev/null on the left and a deleted file on the right", () => {
464 const result = expectSuccess(
465 decodeMergeRequestDiffsJson(
466 JSON.stringify([
467 {
468 old_path: "src/new.ts",
469 new_path: "src/new.ts",
470 new_file: true,
471 b_mode: "100755",
472 diff: "@@ -0,0 +1 @@\n+hello\n",
473 },
474 {
475 old_path: "src/gone.ts",
476 new_path: "src/gone.ts",
477 deleted_file: true,
478 diff: "@@ -1 +0,0 @@\n-bye\n",
479 },
480 ]),
481 ),
482 );
483
484 expect(result.patch).toContain("new file mode 100755");
485 expect(result.patch).toContain("--- /dev/null");
486 expect(result.patch).toContain("deleted file mode 100644");
487 expect(result.patch).toContain("+++ /dev/null");
488 });
489
490 it("records a rename so the patch names both paths", () => {
491 const result = expectSuccess(
492 decodeMergeRequestDiffsJson(
493 JSON.stringify([
494 { old_path: "src/old.ts", new_path: "src/new.ts", renamed_file: true, diff: "" },
495 ]),
496 ),
497 );
498
499 expect(result.patch).toContain("rename from src/old.ts");
500 expect(result.patch).toContain("rename to src/new.ts");
501 });
502
503 it("reports truncation for a file GitLab refused to inline", () => {
504 const result = expectSuccess(
505 decodeMergeRequestDiffsJson(
506 JSON.stringify([{ old_path: "big.bin", new_path: "big.bin", diff: "", too_large: true }]),
507 ),
508 );
509
510 expect(result.truncated).toBe(true);
511 expect(result.patch).toContain("diff --git a/big.bin b/big.bin");
512 });
513
514 it("reports how many files GitLab returned, so the caller can page", () => {
515 const result = expectSuccess(
516 decodeMergeRequestDiffsJson(
517 JSON.stringify(
518 Array.from({ length: 3 }, (_, index) => ({
519 old_path: `src/${index}.ts`,
520 new_path: `src/${index}.ts`,
521 diff: "@@ -1 +1 @@\n-a\n+b\n",
522 })),
523 ),
524 ),
525 );
526
527 expect(result.rawCount).toBe(3);
528 expect(result.truncated).toBe(false);
529 expect(result.patch).toContain("src/2.ts");
530 });
531
532 it("fails when GitLab did not return a list", () => {
533 expect(Result.isFailure(decodeMergeRequestDiffsJson('{"message":"404"}'))).toBe(true);
534 });
535});
536
537describe("merge request viewer fields", () => {
538 it("carries GitLab's own answer for whether this viewer can merge", () => {
539 expect(
540 expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: { can_merge: false } })))
541 .viewerCanMerge,
542 ).toBe(false);
543 expect(
544 expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: { can_merge: true } })))
545 .viewerCanMerge,
546 ).toBe(true);
547 });
548
549 it("leaves merging permitted where GitLab answered without the field", () => {
550 // Only the single-merge-request endpoint carries `user`, and an install that answers without
551 // it has said nothing about the viewer rather than said no.
552 expect(expectSuccess(decodeMergeRequestDetailJson(detailJson({}))).viewerCanMerge).toBe(true);
553 expect(
554 expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: null }))).viewerCanMerge,
555 ).toBe(true);
556 });
557});
558
559describe("decodeAwardEmojiJson", () => {
560 it("reads MR-level awards, keys per-note awards by the REST id inside their gid, and ignores an award outside the eight", () => {
561 const result = expectSuccess(
562 decodeAwardEmojiJson(
563 JSON.stringify({
564 data: {
565 currentUser: { username: "bilal" },
566 project: {
567 mergeRequest: {
568 awardEmoji: {
569 nodes: [
570 { name: "thumbsup", user: { username: "bilal" } },
571 { name: "thumbsup", user: { username: "julius" } },
572 ],
573 },
574 notes: {
575 pageInfo: { hasNextPage: false, endCursor: null },
576 nodes: [
577 {
578 id: "gid://gitlab/DiffNote/42",
579 awardEmoji: { nodes: [{ name: "heart", user: { username: "julius" } }] },
580 },
581 {
582 id: "gid://gitlab/Note/7",
583 // Not one of the eight the contract carries.
584 awardEmoji: {
585 nodes: [{ name: "partyparrot", user: { username: "bilal" } }],
586 },
587 },
588 ],
589 },
590 },
591 },
592 },
593 }),
594 ),
595 );
596
597 // `bilal` is `currentUser`, so the group they are in reads back as reacted, but their own
598 // username is left out of `actors` — the page names them "You" instead — while `count` still
599 // counts them; `julius` alone does not turn a group's own `viewerHasReacted` on.
600 expect(result.reactions).toEqual([
601 { content: "thumbs-up", count: 2, actors: ["julius"], viewerHasReacted: true },
602 ]);
603 // Note 7's only award named nobody the eight recognise, so it carries no reactions and is
604 // left out of the map rather than kept empty.
605 expect([...result.reactionsByNoteId]).toEqual([
606 ["42", [{ content: "heart", count: 1, actors: ["julius"], viewerHasReacted: false }]],
607 ]);
608 expect(result.nextCursor).toBeNull();
609 });
610
611 it("hands back a cursor when GitLab has more notes to page", () => {
612 const result = expectSuccess(
613 decodeAwardEmojiJson(
614 JSON.stringify({
615 data: {
616 currentUser: null,
617 project: {
618 mergeRequest: {
619 awardEmoji: { nodes: [] },
620 notes: { pageInfo: { hasNextPage: true, endCursor: "Y3Vyc29yOjE" }, nodes: [] },
621 },
622 },
623 },
624 }),
625 ),
626 );
627
628 expect(result.nextCursor).toBe("Y3Vyc29yOjE");
629 });
630
631 it("matches the viewer's username case-insensitively, since GitLab is not consistent about case", () => {
632 const result = expectSuccess(
633 decodeAwardEmojiJson(
634 JSON.stringify({
635 data: {
636 currentUser: { username: "Bilal" },
637 project: {
638 mergeRequest: {
639 awardEmoji: {
640 nodes: [
641 { name: "heart", user: { username: "bilal" } },
642 { name: "heart", user: { username: "julius" } },
643 ],
644 },
645 notes: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
646 },
647 },
648 },
649 }),
650 ),
651 );
652
653 expect(result.reactions).toEqual([
654 { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true },
655 ]);
656 });
657});
658
659describe("decodeOwnAwardIdJson", () => {
660 const awards = JSON.stringify([
661 { id: 101, name: "thumbsup", user: { username: "julius" } },
662 { id: 102, name: "thumbsup", user: { username: "bilal" } },
663 ]);
664
665 it("finds the reader's own award of that name, which is the one a removal deletes", () => {
666 expect(
667 expectSuccess(decodeOwnAwardIdJson(awards, { content: "thumbs-up", viewer: "bilal" })),
668 ).toBe(102);
669 });
670
671 it("returns nothing where the reader has no award of that name", () => {
672 expect(
673 expectSuccess(decodeOwnAwardIdJson(awards, { content: "heart", viewer: "bilal" })),
674 ).toBeNull();
675 });
676});
677
678describe("gitLabAwardName", () => {
679 it("spells the contents whose GitLab award name is not their own kebab-case", () => {
680 expect(gitLabAwardName("thumbs-up")).toBe("thumbsup");
681 expect(gitLabAwardName("laugh")).toBe("laughing");
682 expect(gitLabAwardName("hooray")).toBe("tada");
683 });
684});
685
686describe("decodeRepositoryBlobsJson", () => {
687 /** Null is the query going unanswered, which these cases are not about. */
688 function expectBlobs(result: Result.Result<ReadonlyMap<string, string> | null, unknown>) {
689 const blobs = expectSuccess(result);
690 expect(blobs).not.toBe(null);
691 if (blobs === null) throw new Error("expected an answered blobs query");
692 return blobs;
693 }
694
695 it("reads a blob id per path", () => {
696 const blobs = expectBlobs(
697 decodeRepositoryBlobsJson(
698 JSON.stringify({
699 data: {
700 project: {
701 repository: {
702 blobs: {
703 nodes: [
704 { path: "src/a.ts", oid: "aaa111" },
705 { path: "src/b.ts", oid: "bbb222" },
706 ],
707 },
708 },
709 },
710 },
711 }),
712 ),
713 );
714
715 expect([...blobs]).toEqual([
716 ["src/a.ts", "aaa111"],
717 ["src/b.ts", "bbb222"],
718 ]);
719 });
720
721 it("leaves out a node missing either half, which names no version", () => {
722 const blobs = expectBlobs(
723 decodeRepositoryBlobsJson(
724 JSON.stringify({
725 data: {
726 project: {
727 repository: {
728 blobs: {
729 nodes: [
730 { path: "src/a.ts", oid: null },
731 { path: null, oid: "bbb222" },
732 null,
733 { path: "src/c.ts", oid: "ccc333" },
734 ],
735 },
736 },
737 },
738 },
739 }),
740 ),
741 );
742
743 expect([...blobs]).toEqual([["src/c.ts", "ccc333"]]);
744 });
745
746 it("keys a blob by the path the host spelled, spaces and all", () => {
747 // A leading or trailing space is a legal part of a file's name. Trimmed here, the id lands
748 // under a key the asked-for path is not spelled with, and the caller fills that path in as
749 // the empty revision: a mark on the file then never compares against the real head blob.
750 const blobs = expectBlobs(
751 decodeRepositoryBlobsJson(
752 JSON.stringify({
753 data: {
754 project: {
755 repository: {
756 blobs: {
757 nodes: [
758 { path: " leading.ts", oid: "aaa111" },
759 { path: "trailing.ts ", oid: "bbb222" },
760 { path: " ", oid: "ccc333" },
761 { path: "", oid: "ddd444" },
762 ],
763 },
764 },
765 },
766 },
767 }),
768 ),
769 );
770
771 expect([...blobs]).toEqual([
772 [" leading.ts", "aaa111"],
773 ["trailing.ts ", "bbb222"],
774 // A name that is only spaces is one Git carries too, so it is a path like any other.
775 [" ", "ccc333"],
776 ]);
777 });
778
779 it("tells a project the reader cannot see from a revision with none of the files", () => {
780 // Null is the query going unanswered. Read as an empty answer it would say the head has none
781 // of the asked-for files, which reports every file a reader has cleared as changed.
782 expect(
783 expectSuccess(decodeRepositoryBlobsJson(JSON.stringify({ data: { project: null } }))),
784 ).toBe(null);
785 expect(
786 expectSuccess(
787 decodeRepositoryBlobsJson(JSON.stringify({ data: { project: { repository: null } } })),
788 ),
789 ).toBe(null);
790 expect(
791 expectSuccess(
792 decodeRepositoryBlobsJson(
793 JSON.stringify({ data: { project: { repository: { blobs: { nodes: [] } } } } }),
794 ),
795 ),
796 ).toEqual(new Map());
797 });
798
799 it("fails on output that is not the query's shape", () => {
800 expect(Result.isSuccess(decodeRepositoryBlobsJson("not json"))).toBe(false);
801 expect(Result.isSuccess(decodeRepositoryBlobsJson(JSON.stringify({ errors: [] })))).toBe(false);
802 });
803});
packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts created+1032
......@@ -0,0 +1,1032 @@
1import * as Cause from "effect/Cause";
2import * as Exit from "effect/Exit";
3import * as Result from "effect/Result";
4import * as Schema from "effect/Schema";
5import type {
6 PullRequestActor,
7 PullRequestCheck,
8 PullRequestCheckStatus,
9 PullRequestComment,
10 PullRequestCommit,
11 PullRequestLabel,
12 PullRequestMergeability,
13 PullRequestMergeCapabilities,
14 PullRequestMergeMethod,
15 PullRequestReaction,
16 PullRequestReactionContent,
17 PullRequestReviewThread,
18 PullRequestReviewerCandidate,
19 PullRequestState,
20} from "@t3tools/contracts";
21import { TrimmedNonEmptyString } from "@t3tools/contracts";
22import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
23import { decodeJsonResult } from "@t3tools/shared/schemaJson";
24
25/**
26 * GitLab's REST enums are decoded as plain strings and normalized here: a GitLab release that
27 * adds a pipeline status or a merge status must not fail the whole payload.
28 */
29const RawUserSchema = Schema.Struct({
30 /**
31 * GitLab writes a merge request's reviewers as numeric ids and takes no usernames there, so the
32 * id is carried alongside the handle rather than looked up again when a review is asked for.
33 */
34 id: Schema.optional(Schema.Int),
35 username: Schema.String,
36 name: Schema.optional(Schema.NullOr(Schema.String)),
37 avatar_url: Schema.optional(Schema.NullOr(Schema.String)),
38});
39
40const RawPipelineSchema = Schema.Struct({
41 status: Schema.optional(Schema.NullOr(Schema.String)),
42 web_url: Schema.optional(Schema.NullOr(Schema.String)),
43 source: Schema.optional(Schema.NullOr(Schema.String)),
44});
45
46const RawMergeRequestSchema = Schema.Struct({
47 iid: Schema.Int,
48 title: Schema.String,
49 web_url: Schema.String,
50 description: Schema.optional(Schema.NullOr(Schema.String)),
51 author: Schema.optional(Schema.NullOr(RawUserSchema)),
52 source_branch: Schema.String,
53 target_branch: Schema.String,
54 state: Schema.optional(Schema.NullOr(Schema.String)),
55 draft: Schema.optional(Schema.Boolean),
56 work_in_progress: Schema.optional(Schema.Boolean),
57 merge_status: Schema.optional(Schema.NullOr(Schema.String)),
58 has_conflicts: Schema.optional(Schema.NullOr(Schema.Boolean)),
59 created_at: Schema.String,
60 updated_at: Schema.String,
61 merged_at: Schema.optional(Schema.NullOr(Schema.String)),
62 closed_at: Schema.optional(Schema.NullOr(Schema.String)),
63 reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))),
64 labels: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))),
65 // A string, and "1000+" past GitLab's counting limit, so it is parsed rather than decoded.
66 changes_count: Schema.optional(Schema.NullOr(Schema.String)),
67 head_pipeline: Schema.optional(Schema.NullOr(RawPipelineSchema)),
68 /**
69 * What the requesting account may do, which only the single-merge-request endpoint carries.
70 * GitLab answers `can_merge` for this viewer against this merge request, so it already accounts
71 * for the role, the approval rules and a protected target branch — none of which a project's
72 * access level on its own would tell apart.
73 */
74 user: Schema.optional(
75 Schema.NullOr(Schema.Struct({ can_merge: Schema.optional(Schema.Boolean) })),
76 ),
77 /**
78 * Whether GitLab is holding this merge request to merge it once its pipeline goes green.
79 * `merge_when_pipeline_succeeds` is the field every version answers with; newer ones also
80 * carry `auto_merge_enabled`, which is the same fact under the name GitLab settled on, so
81 * either one saying yes is a yes.
82 */
83 merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)),
84 auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)),
85 /** The merge request's stored squash choice, including project-policy overrides. */
86 squash_on_merge: Schema.optional(Schema.NullOr(Schema.Boolean)),
87 squash: Schema.optional(Schema.NullOr(Schema.Boolean)),
88 /**
89 * How far the target branch has moved on since this one left it, which is the same number
90 * GitLab's own "out of date" wording counts. It costs a walk of the two branches, so GitLab
91 * withholds it unless `include_diverged_commits_count` asks for it, and answers it only for a
92 * single merge request — a list never carries it, however it is asked for.
93 */
94 diverged_commits_count: Schema.optional(Schema.NullOr(Schema.Int)),
95});
96
97const RawNoteSchema = Schema.Struct({
98 id: Schema.Int,
99 body: Schema.optional(Schema.NullOr(Schema.String)),
100 author: Schema.optional(Schema.NullOr(RawUserSchema)),
101 created_at: Schema.String,
102 /** True for notes GitLab writes itself ("assigned to…"), which are events, not comments. */
103 system: Schema.optional(Schema.Boolean),
104 type: Schema.optional(Schema.NullOr(Schema.String)),
105 position: Schema.optional(
106 Schema.NullOr(
107 Schema.Struct({
108 new_path: Schema.optional(Schema.NullOr(Schema.String)),
109 old_path: Schema.optional(Schema.NullOr(Schema.String)),
110 }),
111 ),
112 ),
113});
114
115/**
116 * A discussion note carrying its place in the diff, which is the shape the whole thread view
117 * is built from. `resolved` lives on the note rather than on the discussion: GitLab calls a
118 * discussion resolved once every resolvable note in it is.
119 */
120const RawDiscussionNoteSchema = Schema.Struct({
121 id: Schema.Int,
122 body: Schema.optional(Schema.NullOr(Schema.String)),
123 author: Schema.optional(Schema.NullOr(RawUserSchema)),
124 created_at: Schema.String,
125 system: Schema.optional(Schema.Boolean),
126 resolvable: Schema.optional(Schema.Boolean),
127 resolved: Schema.optional(Schema.NullOr(Schema.Boolean)),
128 position: Schema.optional(
129 Schema.NullOr(
130 Schema.Struct({
131 position_type: Schema.optional(Schema.NullOr(Schema.String)),
132 new_path: Schema.optional(Schema.NullOr(Schema.String)),
133 old_path: Schema.optional(Schema.NullOr(Schema.String)),
134 new_line: Schema.optional(Schema.NullOr(Schema.Int)),
135 old_line: Schema.optional(Schema.NullOr(Schema.Int)),
136 }),
137 ),
138 ),
139});
140
141const RawDiscussionSchema = Schema.Struct({
142 id: Schema.String,
143 notes: Schema.optional(Schema.NullOr(Schema.Array(RawDiscussionNoteSchema))),
144});
145
146const RawDiffRefsSchema = Schema.Struct({
147 diff_refs: Schema.optional(
148 Schema.NullOr(
149 Schema.Struct({
150 base_sha: Schema.String,
151 head_sha: Schema.String,
152 start_sha: Schema.String,
153 }),
154 ),
155 ),
156});
157
158const RawCommitSchema = Schema.Struct({
159 id: TrimmedNonEmptyString,
160 title: Schema.optional(Schema.NullOr(Schema.String)),
161 committed_date: Schema.optional(Schema.NullOr(Schema.String)),
162 created_at: Schema.optional(Schema.NullOr(Schema.String)),
163 parent_ids: Schema.optional(Schema.Array(Schema.String)),
164 author_name: Schema.optional(Schema.NullOr(Schema.String)),
165 author_email: Schema.optional(Schema.NullOr(Schema.String)),
166 stats: Schema.optional(
167 Schema.NullOr(
168 Schema.Struct({
169 additions: Schema.optional(Schema.Int),
170 deletions: Schema.optional(Schema.Int),
171 }),
172 ),
173 ),
174});
175
176const RawDiffSchema = Schema.Struct({
177 old_path: Schema.String,
178 new_path: Schema.String,
179 a_mode: Schema.optional(Schema.NullOr(Schema.String)),
180 b_mode: Schema.optional(Schema.NullOr(Schema.String)),
181 new_file: Schema.optional(Schema.Boolean),
182 renamed_file: Schema.optional(Schema.Boolean),
183 deleted_file: Schema.optional(Schema.Boolean),
184 diff: Schema.optional(Schema.NullOr(Schema.String)),
185 /** GitLab omits the hunks for a file it considers too large to inline. */
186 too_large: Schema.optional(Schema.NullOr(Schema.Boolean)),
187 /** And for one it collapsed, which withholds them the same way. */
188 collapsed: Schema.optional(Schema.NullOr(Schema.Boolean)),
189});
190
191const RawViewerSchema = Schema.Struct({
192 username: Schema.optional(Schema.NullOr(Schema.String)),
193});
194
195/** A GitLab project settles on one merge strategy plus an optional squash. */
196const RawProjectMergeSettingsSchema = Schema.Struct({
197 merge_method: Schema.optional(Schema.NullOr(Schema.String)),
198 squash_option: Schema.optional(Schema.NullOr(Schema.String)),
199});
200
201export interface GitLabMergeRequestListItem {
202 readonly number: number;
203 readonly title: string;
204 readonly url: string;
205 readonly author: PullRequestActor | null;
206 readonly headBranch: string;
207 readonly baseBranch: string;
208 readonly state: PullRequestState;
209 readonly isDraft: boolean;
210 readonly mergeability: PullRequestMergeability;
211 /**
212 * GitLab reports neither added nor removed lines on a merge request, so both stay zero and
213 * the surface omits the stat. The Code tab counts them from the patch it already fetched.
214 */
215 readonly additions: number;
216 readonly deletions: number;
217 readonly createdAt: string;
218 readonly updatedAt: string;
219 readonly reviewRequestLogins: ReadonlyArray<string>;
220 readonly labels: ReadonlyArray<PullRequestLabel>;
221}
222
223export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem {
224 readonly body: string;
225 readonly changedFiles: number;
226 readonly mergedAt: string | null;
227 readonly closedAt: string | null;
228 readonly reviewers: ReadonlyArray<PullRequestActor>;
229 readonly checks: ReadonlyArray<PullRequestCheck>;
230 /** False only where GitLab said so; an answer without the field leaves merging permitted. */
231 readonly viewerCanMerge: boolean;
232 /** The reviewers as GitLab addresses them, which is what writing the set back takes. */
233 readonly reviewerIds: ReadonlyArray<number>;
234 /** Absent where GitLab named neither auto-merge field, which is not the same as off. */
235 readonly autoMergeEnabled?: boolean;
236 /** GitLab only exposes the stored strategy separately when that strategy is squash. */
237 readonly autoMergeMethod?: PullRequestMergeMethod;
238 /**
239 * Absent where GitLab did not count, which is not the same as a branch that has nothing behind
240 * it: an install too old to answer must not be read as saying the branch is current.
241 */
242 readonly divergedCommits?: number;
243}
244
245function trimmed(value: string | null | undefined): string | null {
246 const text = value?.trim() ?? "";
247 return text.length > 0 ? text : null;
248}
249
250function toActor(raw: Schema.Schema.Type<typeof RawUserSchema> | null | undefined) {
251 const login = trimmed(raw?.username);
252 return login === null
253 ? null
254 : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatar_url) };
255}
256
257function toState(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): PullRequestState {
258 if (trimmed(raw.merged_at) !== null) return "merged";
259 switch (raw.state?.trim().toLowerCase()) {
260 case "merged":
261 return "merged";
262 case "closed":
263 return "closed";
264 default:
265 // `locked` is an open merge request whose discussion is locked.
266 return "open";
267 }
268}
269
270function toMergeability(
271 raw: Schema.Schema.Type<typeof RawMergeRequestSchema>,
272): PullRequestMergeability {
273 if (raw.has_conflicts === true) return "conflicting";
274 switch (raw.merge_status?.trim().toLowerCase()) {
275 case "can_be_merged":
276 return "mergeable";
277 case "cannot_be_merged":
278 return "conflicting";
279 default:
280 // `unchecked` and `checking` mean GitLab has not finished the merge check yet.
281 return "unknown";
282 }
283}
284
285function toLabels(raw: ReadonlyArray<string> | null | undefined): ReadonlyArray<PullRequestLabel> {
286 // GitLab returns label names only, so there is no colour to carry.
287 return (raw ?? []).flatMap((label) => {
288 const name = trimmed(label);
289 return name === null ? [] : [{ name, color: null }];
290 });
291}
292
293/**
294 * "3" for a counted change set, "1000+" once GitLab gives up counting. The leading number is
295 * the floor either way, which reads better than dropping an uncounted change set to nothing.
296 */
297function toChangedFiles(value: string | null | undefined): number {
298 const parsed = Number.parseInt(value?.trim() ?? "", 10);
299 return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
300}
301
302function toPipelineStatus(value: string | null | undefined): PullRequestCheckStatus {
303 switch (value?.trim().toLowerCase()) {
304 case "success":
305 return "success";
306 case "failed":
307 return "failure";
308 case "canceled":
309 case "cancelling":
310 return "cancelled";
311 case "skipped":
312 return "skipped";
313 // A pipeline waiting on a person is not progress, and it is not a failure either.
314 case "manual":
315 case "scheduled":
316 return "neutral";
317 default:
318 return "pending";
319 }
320}
321
322/**
323 * GitLab has no per-job check list on a merge request, so its pipeline is reported as the one
324 * check. The jobs behind it stay one click away through the pipeline URL.
325 */
326function toChecks(
327 raw: Schema.Schema.Type<typeof RawMergeRequestSchema>,
328): ReadonlyArray<PullRequestCheck> {
329 const pipeline = raw.head_pipeline;
330 if (!pipeline) return [];
331 return [
332 {
333 name: "Pipeline",
334 status: toPipelineStatus(pipeline.status),
335 description: trimmed(pipeline.source),
336 url: trimmed(pipeline.web_url),
337 },
338 ];
339}
340
341function toListItem(
342 raw: Schema.Schema.Type<typeof RawMergeRequestSchema>,
343): GitLabMergeRequestListItem {
344 return {
345 number: raw.iid,
346 title: raw.title,
347 url: raw.web_url,
348 author: toActor(raw.author),
349 headBranch: raw.source_branch,
350 baseBranch: raw.target_branch,
351 state: toState(raw),
352 isDraft: raw.draft ?? raw.work_in_progress ?? false,
353 mergeability: toMergeability(raw),
354 additions: 0,
355 deletions: 0,
356 createdAt: raw.created_at,
357 updatedAt: raw.updated_at,
358 reviewRequestLogins: (raw.reviewers ?? []).flatMap((reviewer) => {
359 const login = trimmed(reviewer.username);
360 return login === null ? [] : [login];
361 }),
362 labels: toLabels(raw.labels),
363 };
364}
365
366function toDetail(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): GitLabMergeRequestDetail {
367 const listItem = toListItem(raw);
368 const autoMerge =
369 raw.merge_when_pipeline_succeeds == null && raw.auto_merge_enabled == null
370 ? undefined
371 : raw.merge_when_pipeline_succeeds === true || raw.auto_merge_enabled === true;
372 return {
373 ...listItem,
374 body: raw.description ?? "",
375 changedFiles: toChangedFiles(raw.changes_count),
376 mergedAt: trimmed(raw.merged_at),
377 closedAt: trimmed(raw.closed_at),
378 // Built from the reviewers themselves rather than from their logins, so the avatars survive.
379 reviewers: (raw.reviewers ?? []).flatMap((reviewer) => {
380 const actor = toActor(reviewer);
381 return actor === null ? [] : [actor];
382 }),
383 checks: toChecks(raw),
384 viewerCanMerge: raw.user?.can_merge !== false,
385 reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) =>
386 reviewer.id === undefined ? [] : [reviewer.id],
387 ),
388 ...(autoMerge === undefined ? {} : { autoMergeEnabled: autoMerge }),
389 ...(autoMerge === true && raw.squash_on_merge === true
390 ? { autoMergeMethod: "squash" as const }
391 : {}),
392 ...(raw.diverged_commits_count == null ? {} : { divergedCommits: raw.diverged_commits_count }),
393 };
394}
395
396const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown));
397const decodeMergeRequestEntry = Schema.decodeUnknownExit(RawMergeRequestSchema);
398const decodeMergeRequest = decodeJsonResult(RawMergeRequestSchema);
399const decodeNoteEntry = Schema.decodeUnknownExit(RawNoteSchema);
400const decodeUserEntry = Schema.decodeUnknownExit(RawUserSchema);
401const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema);
402const decodeCommit = decodeJsonResult(RawCommitSchema);
403const decodeDiffEntry = Schema.decodeUnknownExit(RawDiffSchema);
404const decodeDiscussionEntry = Schema.decodeUnknownExit(RawDiscussionSchema);
405const decodeDiffRefs = decodeJsonResult(RawDiffRefsSchema);
406const decodeViewer = decodeJsonResult(RawViewerSchema);
407const decodeProjectMergeSettings = decodeJsonResult(RawProjectMergeSettingsSchema);
408
409type DecodeFailure = Cause.Cause<Schema.SchemaError>;
410
411export interface GitLabProjectUsers {
412 readonly candidates: ReadonlyArray<PullRequestReviewerCandidate>;
413 /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */
414 readonly rawCount: number;
415}
416
417export interface GitLabMergeRequestListBatch {
418 readonly items: ReadonlyArray<GitLabMergeRequestListItem>;
419 /** Zero-based positions of the decoded items in GitLab's raw page. */
420 readonly rawIndexes: ReadonlyArray<number>;
421 /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */
422 readonly rawCount: number;
423}
424
425/** Malformed entries are skipped rather than failing the batch: one unexpected merge request
426 * must not blank the whole list. */
427export function decodeMergeRequestListJson(
428 raw: string,
429): Result.Result<GitLabMergeRequestListBatch, DecodeFailure> {
430 const decoded = decodeUnknownList(raw);
431 if (!Result.isSuccess(decoded)) {
432 return Result.fail(decoded.failure);
433 }
434 const items: GitLabMergeRequestListItem[] = [];
435 const rawIndexes: number[] = [];
436 for (const [rawIndex, entry] of decoded.success.entries()) {
437 const item = decodeMergeRequestEntry(entry);
438 if (Exit.isSuccess(item)) {
439 items.push(toListItem(item.value));
440 rawIndexes.push(rawIndex);
441 }
442 }
443 return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length });
444}
445
446export function decodeMergeRequestDetailJson(
447 raw: string,
448): Result.Result<GitLabMergeRequestDetail, DecodeFailure> {
449 const decoded = decodeMergeRequest(raw);
450 return Result.isSuccess(decoded)
451 ? Result.succeed(toDetail(decoded.success))
452 : Result.fail(decoded.failure);
453}
454
455export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> {
456 const decoded = decodeViewer(raw);
457 return Result.isSuccess(decoded)
458 ? Result.succeed(trimmed(decoded.success.username))
459 : Result.fail(decoded.failure);
460}
461
462/**
463 * The people with access to the project, which `GET /projects/:id/users` answers with — the same
464 * list GitLab's own reviewer field is filled from, including the members a group above the project
465 * lends it. A malformed row is skipped rather than failing the menu it belongs to.
466 *
467 * Nobody is marked requested here: who has been asked lives on the merge request, and only the
468 * caller holds both.
469 */
470export function decodeProjectUsersJson(
471 raw: string,
472): Result.Result<GitLabProjectUsers, DecodeFailure> {
473 const decoded = decodeUnknownList(raw);
474 if (!Result.isSuccess(decoded)) {
475 return Result.fail(decoded.failure);
476 }
477 const candidates: PullRequestReviewerCandidate[] = [];
478 for (const entry of decoded.success) {
479 const user = decodeUserEntry(entry);
480 if (Exit.isFailure(user) || user.value.id === undefined) continue;
481 const actor = toActor(user.value);
482 if (actor === null) continue;
483 candidates.push({
484 ...actor,
485 id: String(user.value.id),
486 kind: "user",
487 isRequested: false,
488 });
489 }
490 return Result.succeed({ candidates, rawCount: decoded.success.length });
491}
492
493/**
494 * GitLab settles the strategy per project rather than offering all three per merge request:
495 * `merge_method` picks one of merge commit, semi-linear or fast-forward, and squashing is a
496 * separate switch. An unrecognized setting offers nothing rather than offering a strategy the
497 * project forbids.
498 */
499export function decodeProjectMergeCapabilitiesJson(
500 raw: string,
501): Result.Result<PullRequestMergeCapabilities, DecodeFailure> {
502 const decoded = decodeProjectMergeSettings(raw);
503 if (!Result.isSuccess(decoded)) {
504 return Result.fail(decoded.failure);
505 }
506 const mergeMethod = decoded.success.merge_method?.trim().toLowerCase();
507 const squashOption = decoded.success.squash_option?.trim().toLowerCase();
508 return Result.succeed({
509 merge: mergeMethod === "merge",
510 // Both semi-linear and fast-forward histories are reached by rebasing onto the target.
511 rebase: mergeMethod === "rebase_merge" || mergeMethod === "ff",
512 // Only GitLab's own enabling values. An absent or unrecognized setting offers nothing,
513 // rather than offering a squash the project may forbid.
514 squash:
515 squashOption === "always" || squashOption === "default_on" || squashOption === "default_off",
516 });
517}
518
519/**
520 * Comments only. System notes are GitLab's own activity feed entries, and a `DiffNote` is the
521 * root of a line-level discussion, which is what the review-comment kind means.
522 *
523 * The raw note count comes back alongside, because dropping notes hides whether the page was
524 * full: a caller cannot tell "no more notes" from "a page of activity entries" without it.
525 */
526/** The three revisions a positioned comment is written against. */
527export interface GitLabDiffRefs {
528 readonly baseSha: string;
529 readonly headSha: string;
530 readonly startSha: string;
531}
532
533export interface GitLabDiscussions {
534 readonly threads: ReadonlyArray<PullRequestReviewThread>;
535 /** Discussions GitLab returned, counted before decoding, so a skipped one still counts. */
536 readonly rawCount: number;
537}
538
539/**
540 * Positioned discussions only. GitLab returns the merge request's whole conversation here,
541 * including the plain notes the timeline already shows, and only a positioned one belongs
542 * against a line of the diff.
543 */
544export function decodeDiscussionsJson(
545 raw: string,
546): Result.Result<GitLabDiscussions, DecodeFailure> {
547 const decoded = decodeUnknownList(raw);
548 if (!Result.isSuccess(decoded)) {
549 return Result.fail(decoded.failure);
550 }
551 const threads: PullRequestReviewThread[] = [];
552 for (const entry of decoded.success) {
553 const discussion = decodeDiscussionEntry(entry);
554 if (!Exit.isSuccess(discussion)) continue;
555 const notes = (discussion.value.notes ?? []).filter((note) => note.system !== true);
556 const root = notes[0];
557 const position = root?.position;
558 if (root === undefined || !position || position.position_type !== "text") continue;
559 // A comment on an added or context line carries `new_line`; one on a removed line carries
560 // only `old_line`, and belongs against the file as it was.
561 const side = position.new_line === null || position.new_line === undefined ? "left" : "right";
562 const path = trimmed(side === "left" ? position.old_path : position.new_path);
563 const line = side === "left" ? position.old_line : position.new_line;
564 if (path === null) continue;
565 threads.push({
566 id: discussion.value.id,
567 path,
568 line: typeof line === "number" && line > 0 ? line : null,
569 side,
570 isResolved: root.resolved === true,
571 // GitLab reports no equivalent of "written against a line that has since moved", so a
572 // thread the diff cannot place is worked out from the diff itself rather than claimed
573 // here.
574 isOutdated: false,
575 comments: notes.map((note) => ({
576 id: String(note.id),
577 author: toActor(note.author),
578 body: note.body ?? "",
579 createdAt: note.created_at,
580 url: null,
581 })),
582 });
583 }
584 return Result.succeed({ threads, rawCount: decoded.success.length });
585}
586
587export function decodeDiffRefsJson(
588 raw: string,
589): Result.Result<GitLabDiffRefs | null, DecodeFailure> {
590 const decoded = decodeDiffRefs(raw);
591 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
592 const refs = decoded.success.diff_refs;
593 return Result.succeed(
594 refs ? { baseSha: refs.base_sha, headSha: refs.head_sha, startSha: refs.start_sha } : null,
595 );
596}
597
598export function decodeNotesJson(
599 raw: string,
600): Result.Result<
601 { readonly comments: ReadonlyArray<PullRequestComment>; readonly rawCount: number },
602 DecodeFailure
603> {
604 const decoded = decodeUnknownList(raw);
605 if (!Result.isSuccess(decoded)) {
606 return Result.fail(decoded.failure);
607 }
608 const comments: PullRequestComment[] = [];
609 for (const entry of decoded.success) {
610 const note = decodeNoteEntry(entry);
611 if (Exit.isFailure(note)) continue;
612 const value = note.value;
613 if (value.system === true) continue;
614 const body = value.body ?? "";
615 if (body.trim().length === 0) continue;
616 const isDiffNote = value.type?.trim() === "DiffNote";
617 comments.push({
618 id: String(value.id),
619 kind: isDiffNote ? "review-comment" : "issue-comment",
620 author: toActor(value.author),
621 body,
622 createdAt: value.created_at,
623 url: null,
624 path: trimmed(value.position?.new_path) ?? trimmed(value.position?.old_path),
625 reviewState: null,
626 });
627 }
628 return Result.succeed({ comments, rawCount: decoded.success.length });
629}
630
631export function decodeCommitsJson(
632 raw: string,
633): Result.Result<ReadonlyArray<PullRequestCommit>, DecodeFailure> {
634 const decoded = decodeUnknownList(raw);
635 if (!Result.isSuccess(decoded)) {
636 return Result.fail(decoded.failure);
637 }
638 const commits: PullRequestCommit[] = [];
639 for (const entry of decoded.success) {
640 const commit = decodeCommitEntry(entry);
641 if (Exit.isFailure(commit)) continue;
642 const committedDate = trimmed(commit.value.committed_date) ?? trimmed(commit.value.created_at);
643 if (committedDate === null) continue;
644 commits.push({
645 oid: commit.value.id,
646 messageHeadline: commit.value.title ?? "",
647 committedDate,
648 ...(commit.value.stats === null || commit.value.stats === undefined
649 ? {}
650 : {
651 additions: Math.max(0, commit.value.stats.additions ?? 0),
652 deletions: Math.max(0, commit.value.stats.deletions ?? 0),
653 }),
654 authors: (() => {
655 const login = trimmed(commit.value.author_name) ?? trimmed(commit.value.author_email);
656 return login === null
657 ? []
658 : [{ login, name: trimmed(commit.value.author_name), avatarUrl: null }];
659 })(),
660 });
661 }
662 // GitLab lists a merge request's commits newest first; the timeline reads oldest first.
663 return Result.succeed(commits.toReversed());
664}
665
666/** The exact comparison GitLab uses for a commit-scoped diff. */
667export function decodeCommitDiffRefsJson(
668 raw: string,
669): Result.Result<GitLabDiffRefs | null, DecodeFailure> {
670 const decoded = decodeCommit(raw);
671 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
672 const baseSha = trimmed(decoded.success.parent_ids?.[0]);
673 const headSha = trimmed(decoded.success.id);
674 return Result.succeed(
675 baseSha === null || headSha === null ? null : { baseSha, headSha, startSha: baseSha },
676 );
677}
678
679function diffHeaderPaths(raw: Schema.Schema.Type<typeof RawDiffSchema>): {
680 readonly from: string;
681 readonly to: string;
682} {
683 return {
684 from: raw.new_file === true ? "/dev/null" : quoteGitPatchPath(`a/${raw.old_path}`),
685 to: raw.deleted_file === true ? "/dev/null" : quoteGitPatchPath(`b/${raw.new_path}`),
686 };
687}
688
689export interface GitLabMergeRequestPatch {
690 readonly patch: string;
691 /** At least one file's hunks were withheld by GitLab as too large to inline. */
692 readonly truncated: boolean;
693 /** Files GitLab returned, counted before decoding, so the caller can page. */
694 readonly rawCount: number;
695}
696
697/**
698 * GitLab returns hunks per file with no `diff --git` header, so the unified patch every diff
699 * viewer expects is assembled here. This decodes one page; walking pages is the caller's job,
700 * which is why the raw file count comes back with the patch.
701 */
702export function decodeMergeRequestDiffsJson(
703 raw: string,
704): Result.Result<GitLabMergeRequestPatch, DecodeFailure> {
705 const decoded = decodeUnknownList(raw);
706 if (!Result.isSuccess(decoded)) {
707 return Result.fail(decoded.failure);
708 }
709 const sections: string[] = [];
710 let truncated = false;
711 for (const entry of decoded.success) {
712 const file = decodeDiffEntry(entry);
713 if (Exit.isFailure(file)) continue;
714 const value = file.value;
715 const hunks = value.diff ?? "";
716 if (hunks.length === 0) {
717 // A file GitLab declined to inline still belongs in the file list, header only.
718 truncated = truncated || value.too_large === true || value.collapsed === true;
719 }
720 const { from, to } = diffHeaderPaths(value);
721 const header = [
722 `diff --git ${quoteGitPatchPath(`a/${value.old_path}`)} ${quoteGitPatchPath(`b/${value.new_path}`)}`,
723 ...(value.new_file === true ? [`new file mode ${value.b_mode ?? "100644"}`] : []),
724 ...(value.deleted_file === true ? [`deleted file mode ${value.a_mode ?? "100644"}`] : []),
725 ...(value.renamed_file === true
726 ? [
727 `rename from ${quoteGitPatchPath(value.old_path)}`,
728 `rename to ${quoteGitPatchPath(value.new_path)}`,
729 ]
730 : []),
731 `--- ${from}`,
732 `+++ ${to}`,
733 ].join("\n");
734 sections.push(hunks.length === 0 ? header : `${header}\n${hunks.replace(/\n?$/, "\n")}`);
735 }
736 return Result.succeed({
737 patch: sections.join("\n"),
738 truncated,
739 rawCount: decoded.success.length,
740 });
741}
742
743/** GitLab's award names for the eight reactions the contract carries. */
744const GITLAB_AWARD_BY_CONTENT: Readonly<Record<PullRequestReactionContent, string>> = {
745 "thumbs-up": "thumbsup",
746 "thumbs-down": "thumbsdown",
747 laugh: "laughing",
748 hooray: "tada",
749 confused: "confused",
750 heart: "heart",
751 rocket: "rocket",
752 eyes: "eyes",
753};
754
755const CONTENT_BY_GITLAB_AWARD: Readonly<Record<string, PullRequestReactionContent>> =
756 Object.fromEntries(
757 Object.entries(GITLAB_AWARD_BY_CONTENT).map(([content, name]) => [name, content]),
758 ) as Readonly<Record<string, PullRequestReactionContent>>;
759
760export function gitLabAwardName(content: PullRequestReactionContent): string {
761 return GITLAB_AWARD_BY_CONTENT[content];
762}
763
764/**
765 * Awards on the merge request and on every note of it, in one read. The REST notes endpoint the
766 * conversation comes from carries no award at all, and asking per note would be a request each.
767 *
768 * `currentUser` rides along because GitLab names who awarded but never says whether that is the
769 * reader — so the comparison is made here rather than paid for with a request of its own.
770 */
771export const AWARD_EMOJI_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!, $cursor: String) {
772 currentUser { username }
773 project(fullPath: $fullPath) {
774 mergeRequest(iid: $iid) {
775 awardEmoji { nodes { name user { username } } }
776 notes(first: 100, after: $cursor) {
777 pageInfo { hasNextPage endCursor }
778 nodes { id awardEmoji { nodes { name user { username } } } }
779 }
780 }
781 }
782}`;
783
784const RawAwardEmojiNodesSchema = Schema.optional(
785 Schema.NullOr(
786 Schema.Struct({
787 nodes: Schema.optional(
788 Schema.NullOr(
789 Schema.Array(
790 Schema.NullOr(
791 Schema.Struct({
792 name: Schema.optional(Schema.NullOr(Schema.String)),
793 user: Schema.optional(
794 Schema.NullOr(
795 Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) }),
796 ),
797 ),
798 }),
799 ),
800 ),
801 ),
802 ),
803 }),
804 ),
805);
806
807const RawAwardEmojiPageSchema = Schema.Struct({
808 data: Schema.Struct({
809 currentUser: Schema.optional(
810 Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })),
811 ),
812 project: Schema.NullOr(
813 Schema.Struct({
814 mergeRequest: Schema.NullOr(
815 Schema.Struct({
816 awardEmoji: RawAwardEmojiNodesSchema,
817 notes: Schema.optional(
818 Schema.NullOr(
819 Schema.Struct({
820 pageInfo: Schema.optional(
821 Schema.Struct({
822 hasNextPage: Schema.optional(Schema.Boolean),
823 endCursor: Schema.optional(Schema.NullOr(Schema.String)),
824 }),
825 ),
826 nodes: Schema.Array(
827 Schema.NullOr(
828 Schema.Struct({
829 id: Schema.optional(Schema.NullOr(Schema.String)),
830 awardEmoji: RawAwardEmojiNodesSchema,
831 }),
832 ),
833 ),
834 }),
835 ),
836 ),
837 }),
838 ),
839 }),
840 ),
841 }),
842});
843
844const decodeAwardEmojiPage = decodeJsonResult(RawAwardEmojiPageSchema);
845
846/**
847 * The awards on one subject, grouped the way a reaction pill is drawn. The viewer's own username
848 * is left out of `actors` — the page names them "You" instead, and leaving it in would name them
849 * twice — but `count` still counts them along with everyone else.
850 */
851function toReactions(
852 nodes: Schema.Schema.Type<typeof RawAwardEmojiNodesSchema>,
853 viewer: string | null,
854): ReadonlyArray<PullRequestReaction> {
855 const normalizedViewer = viewer?.toLowerCase() ?? null;
856 const groups = new Map<
857 PullRequestReactionContent,
858 { count: number; actors: string[]; viewer: boolean }
859 >();
860 for (const node of nodes?.nodes ?? []) {
861 // An award outside the eight is left out rather than shown under a name the picker has no
862 // way to take back: GitLab accepts any emoji, and the other hosts accept none of them.
863 const content = CONTENT_BY_GITLAB_AWARD[trimmed(node?.name)?.toLowerCase() ?? ""];
864 if (content === undefined) continue;
865 const username = trimmed(node?.user?.username);
866 if (username === null) continue;
867 const group = groups.get(content) ?? { count: 0, actors: [], viewer: false };
868 group.count++;
869 if (normalizedViewer !== null && username.toLowerCase() === normalizedViewer) {
870 group.viewer = true;
871 } else {
872 group.actors.push(username);
873 }
874 groups.set(content, group);
875 }
876 return [...groups].flatMap(([content, group]) =>
877 group.count === 0
878 ? []
879 : [{ content, count: group.count, actors: group.actors, viewerHasReacted: group.viewer }],
880 );
881}
882
883/** `gid://gitlab/DiffNote/42` is note 42, which is the id the REST conversation carries. */
884function noteIdOf(gid: string | null | undefined): string | null {
885 const id = trimmed(gid)?.split("/").at(-1);
886 return id !== undefined && /^\d+$/.test(id) ? id : null;
887}
888
889export interface GitLabAwardEmojiPage {
890 /** The merge request's own awards, which are the ones on its description. */
891 readonly reactions: ReadonlyArray<PullRequestReaction>;
892 readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
893 readonly nextCursor: string | null;
894}
895
896export function decodeAwardEmojiJson(
897 raw: string,
898): Result.Result<GitLabAwardEmojiPage, DecodeFailure> {
899 const decoded = decodeAwardEmojiPage(raw);
900 if (!Result.isSuccess(decoded)) {
901 return Result.fail(decoded.failure);
902 }
903 const data = decoded.success.data;
904 const viewer = trimmed(data.currentUser?.username);
905 const mergeRequest = data.project?.mergeRequest;
906 const reactionsByNoteId = new Map<string, ReadonlyArray<PullRequestReaction>>();
907 for (const node of mergeRequest?.notes?.nodes ?? []) {
908 const id = noteIdOf(node?.id);
909 if (id === null) continue;
910 const reactions = toReactions(node?.awardEmoji, viewer);
911 if (reactions.length > 0) reactionsByNoteId.set(id, reactions);
912 }
913 const pageInfo = mergeRequest?.notes?.pageInfo;
914 return Result.succeed({
915 reactions: toReactions(mergeRequest?.awardEmoji, viewer),
916 reactionsByNoteId,
917 nextCursor: pageInfo?.hasNextPage === true ? (trimmed(pageInfo.endCursor) ?? null) : null,
918 });
919}
920
921const RawAwardSchema = Schema.Struct({
922 id: Schema.Int,
923 name: Schema.optional(Schema.NullOr(Schema.String)),
924 user: Schema.optional(
925 Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })),
926 ),
927});
928
929const decodeAward = Schema.decodeUnknownExit(RawAwardSchema);
930
931/**
932 * The reader's own award of one name on a subject, which is what taking a reaction back is
933 * addressed by: GitLab deletes an award by its id and has no way to name one by its emoji.
934 */
935export function decodeOwnAwardIdJson(
936 raw: string,
937 input: { readonly content: PullRequestReactionContent; readonly viewer: string },
938): Result.Result<number | null, DecodeFailure> {
939 const decoded = decodeUnknownList(raw);
940 if (!Result.isSuccess(decoded)) {
941 return Result.fail(decoded.failure);
942 }
943 const name = gitLabAwardName(input.content);
944 for (const entry of decoded.success) {
945 const award = decodeAward(entry);
946 if (Exit.isFailure(award)) continue;
947 const value = award.value;
948 if (trimmed(value.name)?.toLowerCase() !== name) continue;
949 if (trimmed(value.user?.username) !== input.viewer) continue;
950 return Result.succeed(value.id);
951 }
952 return Result.succeed(null);
953}
954
955/**
956 * What the given paths are at one revision, as blob ids. Asked for by path rather than by walking
957 * the tree, since GitLab charges this query by how many paths it is given. A path the revision
958 * does not have comes back missing rather than as an error, which is the answer for a deleted file.
959 */
960export const REPOSITORY_BLOBS_GRAPHQL_QUERY = `query($fullPath: ID!, $ref: String!, $paths: [String!]!) {
961 project(fullPath: $fullPath) {
962 repository {
963 blobs(ref: $ref, paths: $paths) {
964 nodes { path oid }
965 }
966 }
967 }
968}`;
969
970const RawRepositoryBlobsSchema = Schema.Struct({
971 data: Schema.Struct({
972 project: Schema.NullOr(
973 Schema.Struct({
974 repository: Schema.optional(
975 Schema.NullOr(
976 Schema.Struct({
977 blobs: Schema.optional(
978 Schema.NullOr(
979 Schema.Struct({
980 nodes: Schema.optional(
981 Schema.NullOr(
982 Schema.Array(
983 Schema.NullOr(
984 Schema.Struct({
985 path: Schema.optional(Schema.NullOr(Schema.String)),
986 oid: Schema.optional(Schema.NullOr(Schema.String)),
987 }),
988 ),
989 ),
990 ),
991 ),
992 }),
993 ),
994 ),
995 }),
996 ),
997 ),
998 }),
999 ),
1000 }),
1001});
1002
1003const decodeRepositoryBlobs = decodeJsonResult(RawRepositoryBlobsSchema);
1004
1005/**
1006 * Blob ids by path, or null where GitLab did not answer the query at all (a project the token
1007 * cannot see, or a repository with no blobs connection). That case must be told apart from an
1008 * empty answer: read as "the revision has none of these files", it would report every cleared
1009 * file as changed again. A node missing either half is left out, since the caller treats an
1010 * absent path as one the revision does not carry.
1011 */
1012export function decodeRepositoryBlobsJson(
1013 raw: string,
1014): Result.Result<ReadonlyMap<string, string> | null, DecodeFailure> {
1015 const decoded = decodeRepositoryBlobs(raw);
1016 if (!Result.isSuccess(decoded)) {
1017 return Result.fail(decoded.failure);
1018 }
1019 const nodes = decoded.success.data.project?.repository?.blobs?.nodes;
1020 if (nodes === undefined || nodes === null) return Result.succeed(null);
1021 const blobs = new Map<string, string>();
1022 for (const node of nodes) {
1023 // Not trimmed, unlike everything else read out of this payload: a leading or trailing space
1024 // is a legal part of a file's name, and trimming it would key this map under a name the
1025 // caller's asked-for path never matches.
1026 const path = node?.path;
1027 const oid = trimmed(node?.oid);
1028 if (path === undefined || path === null || path.length === 0 || oid === null) continue;
1029 blobs.set(path, oid);
1030 }
1031 return Result.succeed(blobs);
1032}
packages/source-control-gitlab/src/server/gitLabMergeRequests.ts created+161
......@@ -0,0 +1,161 @@
1import * as Cause from "effect/Cause";
2import * as DateTime from "effect/DateTime";
3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";
7import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
8import { decodeJsonResult } from "@t3tools/shared/schemaJson";
9
10export interface NormalizedGitLabMergeRequestRecord {
11 readonly number: number;
12 readonly title: string;
13 readonly url: string;
14 readonly baseRefName: string;
15 readonly headRefName: string;
16 readonly state: "open" | "closed" | "merged";
17 readonly isDraft?: boolean;
18 readonly closedAt?: string | null;
19 readonly mergedAt?: string | null;
20 readonly updatedAt: Option.Option<DateTime.Utc>;
21 readonly isCrossRepository?: boolean;
22 readonly headRepositoryNameWithOwner?: string | null;
23 readonly headRepositoryOwnerLogin?: string | null;
24}
25
26const GitLabProjectReferenceSchema = Schema.Struct({
27 path_with_namespace: Schema.optional(Schema.String),
28 pathWithNamespace: Schema.optional(Schema.String),
29 namespace: Schema.optional(
30 Schema.NullOr(
31 Schema.Struct({
32 path: Schema.optional(Schema.String),
33 full_path: Schema.optional(Schema.String),
34 fullPath: Schema.optional(Schema.String),
35 }),
36 ),
37 ),
38});
39
40const GitLabMergeRequestSchema = Schema.Struct({
41 iid: PositiveInt,
42 title: TrimmedNonEmptyString,
43 web_url: TrimmedNonEmptyString,
44 source_branch: TrimmedNonEmptyString,
45 target_branch: TrimmedNonEmptyString,
46 state: Schema.optional(Schema.NullOr(Schema.String)),
47 draft: Schema.optional(Schema.Boolean),
48 work_in_progress: Schema.optional(Schema.Boolean),
49 closed_at: Schema.optional(Schema.NullOr(Schema.String)),
50 merged_at: Schema.optional(Schema.NullOr(Schema.String)),
51 updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
52 source_project_id: Schema.optional(Schema.NullOr(Schema.Number)),
53 target_project_id: Schema.optional(Schema.NullOr(Schema.Number)),
54 source_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)),
55 target_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)),
56});
57
58function trimOptionalString(value: string | null | undefined): string | null {
59 const trimmed = value?.trim() ?? "";
60 return trimmed.length > 0 ? trimmed : null;
61}
62
63function normalizeGitLabMergeRequestState(
64 state: string | null | undefined,
65): "open" | "closed" | "merged" {
66 const normalized = state?.trim().toLowerCase();
67 if (normalized === "merged") {
68 return "merged";
69 }
70 if (normalized === "closed") {
71 return "closed";
72 }
73 return "open";
74}
75
76function projectPathWithNamespace(
77 project: Schema.Schema.Type<typeof GitLabProjectReferenceSchema> | null | undefined,
78): string | null {
79 const explicit =
80 trimOptionalString(project?.path_with_namespace) ??
81 trimOptionalString(project?.pathWithNamespace);
82 if (explicit) {
83 return explicit;
84 }
85
86 const namespacePath =
87 trimOptionalString(project?.namespace?.full_path) ??
88 trimOptionalString(project?.namespace?.fullPath) ??
89 trimOptionalString(project?.namespace?.path);
90 return namespacePath;
91}
92
93function ownerLoginFromPathWithNamespace(pathWithNamespace: string | null): string | null {
94 const [owner] = pathWithNamespace?.split("/") ?? [];
95 return trimOptionalString(owner);
96}
97
98function normalizeGitLabMergeRequestRecord(
99 raw: Schema.Schema.Type<typeof GitLabMergeRequestSchema>,
100): NormalizedGitLabMergeRequestRecord {
101 const sourceProjectPath = projectPathWithNamespace(raw.source_project);
102 const targetProjectPath = projectPathWithNamespace(raw.target_project);
103 const isCrossRepository =
104 typeof raw.source_project_id === "number" && typeof raw.target_project_id === "number"
105 ? raw.source_project_id !== raw.target_project_id
106 : sourceProjectPath !== null && targetProjectPath !== null
107 ? sourceProjectPath.toLowerCase() !== targetProjectPath.toLowerCase()
108 : undefined;
109 const headRepositoryOwnerLogin = ownerLoginFromPathWithNamespace(sourceProjectPath);
110
111 return {
112 number: raw.iid,
113 title: raw.title,
114 url: raw.web_url,
115 baseRefName: raw.target_branch,
116 headRefName: raw.source_branch,
117 state: normalizeGitLabMergeRequestState(raw.state),
118 ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}),
119 closedAt: raw.closed_at ?? null,
120 mergedAt: raw.merged_at ?? null,
121 updatedAt: raw.updated_at ?? Option.none(),
122 ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}),
123 ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}),
124 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
125 };
126}
127
128const decodeGitLabMergeRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
129const decodeGitLabMergeRequest = decodeJsonResult(GitLabMergeRequestSchema);
130const decodeGitLabMergeRequestEntry = Schema.decodeUnknownExit(GitLabMergeRequestSchema);
131
132export function decodeGitLabMergeRequestListJson(
133 raw: string,
134): Result.Result<
135 ReadonlyArray<NormalizedGitLabMergeRequestRecord>,
136 Cause.Cause<Schema.SchemaError>
137> {
138 const result = decodeGitLabMergeRequestList(raw);
139 if (Result.isSuccess(result)) {
140 const mergeRequests: NormalizedGitLabMergeRequestRecord[] = [];
141 for (const entry of result.success) {
142 const decodedEntry = decodeGitLabMergeRequestEntry(entry);
143 if (Exit.isFailure(decodedEntry)) {
144 continue;
145 }
146 mergeRequests.push(normalizeGitLabMergeRequestRecord(decodedEntry.value));
147 }
148 return Result.succeed(mergeRequests);
149 }
150 return Result.fail(result.failure);
151}
152
153export function decodeGitLabMergeRequestJson(
154 raw: string,
155): Result.Result<NormalizedGitLabMergeRequestRecord, Cause.Cause<Schema.SchemaError>> {
156 const result = decodeGitLabMergeRequest(raw);
157 if (Result.isSuccess(result)) {
158 return Result.succeed(normalizeGitLabMergeRequestRecord(result.success));
159 }
160 return Result.fail(result.failure);
161}
packages/source-control-gitlab/tsconfig.json created+7
......@@ -0,0 +1,7 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "types": ["node"]
5 },
6 "include": ["src"]
7}
pnpm-lock.yaml+34
......@@ -657,6 +657,9 @@ importers:
657657 '@t3tools/source-control-forgejo':
658658 specifier: workspace:*
659659 version: link:../../packages/source-control-forgejo
660 '@t3tools/source-control-gitlab':
661 specifier: workspace:*
662 version: link:../../packages/source-control-gitlab
660663 effect:
661664 specifier: 4.0.2
662665 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)
......@@ -700,6 +703,9 @@ importers:
700703 '@t3tools/shared':
701704 specifier: workspace:*
702705 version: link:../../packages/shared
706 '@t3tools/source-control-testing':
707 specifier: workspace:*
708 version: link:../../packages/source-control-testing
703709 '@t3tools/ssh':
704710 specifier: workspace:*
705711 version: link:../../packages/ssh
......@@ -1603,6 +1609,34 @@ importers:
16031609 specifier: 1.0.0
16041610 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
16051611
1612 packages/source-control-gitlab:
1613 dependencies:
1614 '@t3tools/contracts':
1615 specifier: workspace:*
1616 version: link:../contracts
1617 '@t3tools/shared':
1618 specifier: workspace:*
1619 version: link:../shared
1620 '@t3tools/source-control-core':
1621 specifier: workspace:*
1622 version: link:../source-control-core
1623 effect:
1624 specifier: 4.0.2
1625 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)
1626 devDependencies:
1627 '@effect/vitest':
1628 specifier: 4.0.2
1629 version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1630 '@t3tools/source-control-testing':
1631 specifier: workspace:*
1632 version: link:../source-control-testing
1633 '@types/node':
1634 specifier: 24.12.4
1635 version: 24.12.4
1636 vite-plus:
1637 specifier: 1.0.0
1638 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1639
16061640 packages/source-control-testing:
16071641 dependencies:
16081642 '@t3tools/contracts':
scripts/release-smoke.ts+1
......@@ -40,6 +40,7 @@ const workspaceFiles = [
4040 "packages/source-control-azure-devops/package.json",
4141 "packages/source-control-core/package.json",
4242 "packages/source-control-forgejo/package.json",
43 "packages/source-control-gitlab/package.json",
4344 "packages/source-control-testing/package.json",
4445 "packages/effect-acp/package.json",
4546 "packages/effect-codex-app-server/package.json",