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 @@...@@ -48,6 +48,7 @@
48 "@t3tools/source-control-azure-devops": "workspace:*",48 "@t3tools/source-control-azure-devops": "workspace:*",
49 "@t3tools/source-control-core": "workspace:*",49 "@t3tools/source-control-core": "workspace:*",
50 "@t3tools/source-control-forgejo": "workspace:*",50 "@t3tools/source-control-forgejo": "workspace:*",
51 "@t3tools/source-control-gitlab": "workspace:*",
51 "effect": "catalog:",52 "effect": "catalog:",
52 "jose": "catalog:",53 "jose": "catalog:",
53 "node-pty": "^1.2.0-beta.15",54 "node-pty": "^1.2.0-beta.15",
...@@ -64,6 +65,7 @@...@@ -64,6 +65,7 @@
64 "@t3tools/contracts": "workspace:*",65 "@t3tools/contracts": "workspace:*",
65 "@t3tools/provider-testing": "workspace:*",66 "@t3tools/provider-testing": "workspace:*",
66 "@t3tools/shared": "workspace:*",67 "@t3tools/shared": "workspace:*",
68 "@t3tools/source-control-testing": "workspace:*",
67 "@t3tools/ssh": "workspace:*",69 "@t3tools/ssh": "workspace:*",
68 "@t3tools/tailscale": "workspace:*",70 "@t3tools/tailscale": "workspace:*",
69 "@t3tools/web": "workspace:*",71 "@t3tools/web": "workspace:*",
apps/server/scripts/evaluate-thread-titles.ts-2
...@@ -24,7 +24,6 @@ import {...@@ -24,7 +24,6 @@ import {
24import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts";24import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts";
25import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts";25import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts";
26import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";26import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";
27import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";
28import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts";27import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts";
29import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts";28import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts";
30import * as ServerSettings from "../src/serverSettings.ts";29import * as ServerSettings from "../src/serverSettings.ts";
...@@ -152,7 +151,6 @@ await Effect.runPromise(...@@ -152,7 +151,6 @@ await Effect.runPromise(
152 Layer.provide(151 Layer.provide(
153 Layer.mergeAll(152 Layer.mergeAll(
154 GitHubApi.layerWithDependencies,153 GitHubApi.layerWithDependencies,
155 GitLabCli.layer,
156 SourceControlBuiltInDrivers.layer,154 SourceControlBuiltInDrivers.layer,
157 // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables.155 // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables.
158 BitbucketApi.layer,156 BitbucketApi.layer,
apps/server/src/git/GitManager.test.ts+13-9
...@@ -44,7 +44,9 @@ import {...@@ -44,7 +44,9 @@ import {
44} from "@t3tools/contracts";44} from "@t3tools/contracts";
45import * as DateTime from "effect/DateTime";45import * as DateTime from "effect/DateTime";
46import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts";46import { 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";
48import * as TextGeneration from "../textGeneration/TextGeneration.ts";50import * as TextGeneration from "../textGeneration/TextGeneration.ts";
49import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";51import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
50import * as VcsProcess from "../vcs/VcsProcess.ts";52import * as VcsProcess from "../vcs/VcsProcess.ts";
...@@ -53,7 +55,7 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts";...@@ -53,7 +55,7 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts";
53import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";55import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
54import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";56import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
55import * as GitWorkflowService from "./GitWorkflowService.ts";57import * as GitWorkflowService from "./GitWorkflowService.ts";
56import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts";58import * as GitLabSourceControlProvider from "@t3tools/source-control-gitlab/server/GitLabSourceControlProvider";
57import {59import {
58 ForgejoPullRequestSchema,60 ForgejoPullRequestSchema,
59 toForgejoChangeRequest,61 toForgejoChangeRequest,
...@@ -2108,17 +2110,19 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2108,17 +2110,19 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2108 source_project: { path_with_namespace: "Group/Subgroup/Fork" },2110 source_project: { path_with_namespace: "Group/Subgroup/Fork" },
2109 },2111 },
2110 ]);2112 ]);
2111 const calls: VcsProcess.VcsProcessInput[] = [];2113 const calls: SourceControlHost.SourceControlProcessInput[] = [];
2112 const provider = yield* GitLabSourceControlProvider.make.pipe(2114 const provider = yield* GitLabSourceControlProvider.make.pipe(
2113 Effect.provide(2115 Effect.provide(
2114 GitLabCli.layer.pipe(2116 GitLabCli.layer.pipe(
2115 Layer.provide(2117 Layer.provide(
2116 Layer.mock(VcsProcess.VcsProcess)({2118 TestSourceControlHost.layer({
2117 run: (input) =>2119 process: {
2118 Effect.sync(() => {2120 run: (input) =>
2119 calls.push(input);2121 Effect.sync(() => {
2120 return fakeGhOutput(output);2122 calls.push(input);
2121 }),2123 return fakeGhOutput(output);
2124 }),
2125 },
2122 }),2126 }),
2123 ),2127 ),
2124 ),2128 ),
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...@@ -4,11 +4,11 @@ import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/Azu
4import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";4import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
5import * as GitHubApi from "../sourceControl/GitHubApi.ts";5import * as GitHubApi from "../sourceControl/GitHubApi.ts";
6import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";6import * 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";
8import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider";8import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider";
9import { bitbucketProviderFailure } from "./BitbucketPullRequestProvider.ts";9import { bitbucketProviderFailure } from "./BitbucketPullRequestProvider.ts";
10import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts";10import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts";
11import { gitLabProviderFailure } from "./GitLabPullRequestProvider.ts";11import { gitLabProviderFailure } from "@t3tools/source-control-gitlab/server/GitLabPullRequestProvider";
1212
13const cause = new Error("redacted provider failure");13const cause = new Error("redacted provider failure");
1414
apps/server/src/pullRequest/PullRequestProviderRegistry.ts-5
...@@ -5,13 +5,10 @@ import type { SourceControlProviderKind } from "@t3tools/contracts";...@@ -5,13 +5,10 @@ import type { SourceControlProviderKind } from "@t3tools/contracts";
55
6import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";6import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
7import * as GitHubApi from "../sourceControl/GitHubApi.ts";7import * as GitHubApi from "../sourceControl/GitHubApi.ts";
8import * as GitLabCli from "../sourceControl/GitLabCli.ts";
9import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";8import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
10import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts";9import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts";
11import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";10import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
12import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";11import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";
13import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
14import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts";
15import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts";12import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts";
16import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider";13import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider";
1714
...@@ -44,7 +41,6 @@ export function fromProviders(...@@ -44,7 +41,6 @@ export function fromProviders(
44export const make = Effect.gen(function* () {41export const make = Effect.gen(function* () {
45 const providers = yield* Effect.all([42 const providers = yield* Effect.all([
46 GitHubPullRequestProvider.make,43 GitHubPullRequestProvider.make,
47 GitLabPullRequestProvider.make,
48 BitbucketPullRequestProvider.make,44 BitbucketPullRequestProvider.make,
49 ]);45 ]);
50 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>46 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>
...@@ -65,7 +61,6 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(...@@ -65,7 +61,6 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
65 ),61 ),
66 ),62 ),
67 ),63 ),
68 Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))),
69 Layer.provide(BuiltInDrivers.layer),64 Layer.provide(BuiltInDrivers.layer),
70 Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))),65 Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))),
71);66);
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";...@@ -54,7 +54,6 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
54import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";54import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";
55import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";55import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
56import * as GitHubApi from "./sourceControl/GitHubApi.ts";56import * as GitHubApi from "./sourceControl/GitHubApi.ts";
57import * as GitLabCli from "./sourceControl/GitLabCli.ts";
58import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";57import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
59import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";58import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";
60import * as TextGeneration from "./textGeneration/TextGeneration.ts";59import * as TextGeneration from "./textGeneration/TextGeneration.ts";
...@@ -302,7 +301,6 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p...@@ -302,7 +301,6 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p
302 Layer.mergeAll(301 Layer.mergeAll(
303 BitbucketApi.layer,302 BitbucketApi.layer,
304 GitHubApi.layerWithDependencies,303 GitHubApi.layerWithDependencies,
305 GitLabCli.layer,
306 SourceControlBuiltInDrivers.layer,304 SourceControlBuiltInDrivers.layer,
307 ),305 ),
308 ),306 ),
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...@@ -20,7 +20,8 @@ import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/Azu
20import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli";20import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli";
21import * as BitbucketApi from "./BitbucketApi.ts";21import * as BitbucketApi from "./BitbucketApi.ts";
22import * as GitHubApi from "./GitHubApi.ts";22import * 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";
24import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";25import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
25import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider";26import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider";
26import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider";27import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider";
...@@ -46,6 +47,7 @@ const layerSourceControlProviderRegistryTest = (input: {...@@ -46,6 +47,7 @@ const layerSourceControlProviderRegistryTest = (input: {
46 Layer.mock(GitHubApi.GitHubApi)({}),47 Layer.mock(GitHubApi.GitHubApi)({}),
47 Layer.mock(GitVcsDriver.GitVcsDriver)({}),48 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
48 Layer.mock(GitLabCli.GitLabCli)({}),49 Layer.mock(GitLabCli.GitLabCli)({}),
50 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}),
49 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),51 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
50 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),52 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
51 Layer.mock(VcsProcess.VcsProcess)(input.process),53 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";...@@ -19,7 +19,8 @@ import * as BitbucketApi from "./BitbucketApi.ts";
19import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";19import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
20import * as GitHubApi from "./GitHubApi.ts";20import * as GitHubApi from "./GitHubApi.ts";
21import * as GitHubCredentials from "./GitHubCredentials.ts";21import * 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";
23import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";24import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
24import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";25import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
25import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";26import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
...@@ -114,6 +115,7 @@ function makeRegistry(input: {...@@ -114,6 +115,7 @@ function makeRegistry(input: {
114 }),115 }),
115 Layer.mock(GitVcsDriver.GitVcsDriver)({}),116 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
116 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),117 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),
118 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}),
117 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),119 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
118 ServerConfig.layerTest(process.cwd(), {120 ServerConfig.layerTest(process.cwd(), {
119 prefix: "t3-source-control-registry-test-",121 prefix: "t3-source-control-registry-test-",
apps/server/src/sourceControl/SourceControlProviderRegistry.ts-7
...@@ -13,7 +13,6 @@ import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/source...@@ -13,7 +13,6 @@ import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/source
1313
14import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";14import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
15import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";15import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
16import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
17import * as BuiltInDrivers from "./builtInDrivers.ts";16import * as BuiltInDrivers from "./builtInDrivers.ts";
18import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";17import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
19import {18import {
...@@ -306,7 +305,6 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit...@@ -306,7 +305,6 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
306export const make = Effect.gen(function* () {305export const make = Effect.gen(function* () {
307 const github = yield* GitHubSourceControlProvider.make;306 const github = yield* GitHubSourceControlProvider.make;
308 const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery;307 const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery;
309 const gitlab = yield* GitLabSourceControlProvider.make;
310 const bitbucket = yield* BitbucketSourceControlProvider.make;308 const bitbucket = yield* BitbucketSourceControlProvider.make;
311 const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery;309 const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery;
312 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>310 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>
...@@ -326,11 +324,6 @@ export const make = Effect.gen(function* () {...@@ -326,11 +324,6 @@ export const make = Effect.gen(function* () {
326 provider: github,324 provider: github,
327 discovery: githubDiscovery,325 discovery: githubDiscovery,
328 },326 },
329 {
330 kind: "gitlab",
331 provider: gitlab,
332 discovery: GitLabSourceControlProvider.discovery,
333 },
334 {327 {
335 kind: "bitbucket",328 kind: "bitbucket",
336 provider: bitbucket,329 provider: bitbucket,
apps/server/src/sourceControl/builtInDrivers.ts+5-1
...@@ -12,12 +12,15 @@ import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops...@@ -12,12 +12,15 @@ import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops
12import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver";12import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver";
13import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";13import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
14import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver";14import * 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";
15import type { SourceControlDriver } from "@t3tools/source-control-core/server/driver";18import type { SourceControlDriver } from "@t3tools/source-control-core/server/driver";
16import * as Layer from "effect/Layer";19import * as Layer from "effect/Layer";
1720
18import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";21import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
1922
20const drivers = [AzureDevOpsDriver.driver, ForgejoDriver.driver];23const drivers = [GitLabDriver.driver, AzureDevOpsDriver.driver, ForgejoDriver.driver];
2124
22/** Every service a built-in driver's `make` needs; the server's layers must provide them all. */25/** Every service a built-in driver's `make` needs; the server's layers must provide them all. */
23export type BuiltInSourceControlDriversEnv =26export type BuiltInSourceControlDriversEnv =
...@@ -31,4 +34,5 @@ export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray<...@@ -31,4 +34,5 @@ export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray<
31export const layer = Layer.mergeAll(34export const layer = Layer.mergeAll(
32 AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)),35 AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)),
33 ForgejoCli.layer,36 ForgejoCli.layer,
37 GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)),
34).pipe(Layer.provideMerge(ServerSourceControlHost.layer));38).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....@@ -231,7 +231,6 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.
231import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";231import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
232import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";232import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
233import * as GitHubApi from "./sourceControl/GitHubApi.ts";233import * as GitHubApi from "./sourceControl/GitHubApi.ts";
234import * as GitLabCli from "./sourceControl/GitLabCli.ts";
235import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";234import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";
236import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";235import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
237import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";236import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
...@@ -3205,7 +3204,6 @@ export const layer = Layer.unwrap(...@@ -3205,7 +3204,6 @@ export const layer = Layer.unwrap(
3205 Layer.mergeAll(3204 Layer.mergeAll(
3206 BitbucketApi.layer,3205 BitbucketApi.layer,
3207 GitHubApi.layerWithDependencies,3206 GitHubApi.layerWithDependencies,
3208 GitLabCli.layer,
3209 SourceControlBuiltInDrivers.layer,3207 SourceControlBuiltInDrivers.layer,
3210 ),3208 ),
3211 ),3209 ),
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...@@ -7,7 +7,7 @@ one. No type distinguishes those meanings and no single file states the whole pa
7this page is for.7this page is for.
88
9The failure is on record rather than hypothetical. The interface doc above9The failure is on record rather than hypothetical. The interface doc above
10[GitLab's `getFileRevisions`](../../apps/server/src/pullRequest/GitLabPullRequestCli.ts) once said10[GitLab's `getFileRevisions`](../../packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts) once said
11the opposite of the code beneath it, because it described a missing path the way the layer below11the opposite of the code beneath it, because it described a missing path the way the layer below
12means it. Anything written against that reading would treat a version the host declined to give as12means it. Anything written against that reading would treat a version the host declined to give as
13a file the change request deleted, which reports every file a reader has cleared as changed across13a 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...@@ -30,7 +30,7 @@ What "never got to look at" is belongs to the host, and the contract cannot know
30- [Bitbucket](../../apps/server/src/pullRequest/BitbucketPullRequestApi.ts) reads them off the30- [Bitbucket](../../apps/server/src/pullRequest/BitbucketPullRequestApi.ts) reads them off the
31 pull request's own patch, the only place it states a file's version, so a patch cut short at the31 pull request's own patch, the only place it states a file's version, so a patch cut short at the
32 byte ceiling leaves the paths past the cut out.32 byte ceiling leaves the paths past the cut out.
33- [GitLab](../../apps/server/src/pullRequest/GitLabPullRequestCli.ts) asks in batches, so a batch33- [GitLab](../../packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts) asks in batches, so a batch
34 it could not read leaves that batch's paths out.34 it could not read leaves that batch's paths out.
3535
36Those three facts stay with their providers. The contract states only that absence means the read36Those three facts stay with their providers. The contract states only that absence means the read
package.json+1-1
...@@ -28,7 +28,7 @@...@@ -28,7 +28,7 @@
28 "tc": "vp run -r --concurrency-limit 2 typecheck",28 "tc": "vp run -r --concurrency-limit 2 typecheck",
29 "lint": "vp lint --report-unused-disable-directives",29 "lint": "vp lint --report-unused-disable-directives",
30 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",30 "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",
32 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",32 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",
33 "lint:mobile": "node scripts/mobile-native-static-check.ts",33 "lint:mobile": "node scripts/mobile-native-static-check.ts",
34 "test": "vp run -r test",34 "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:...@@ -657,6 +657,9 @@ importers:
657 '@t3tools/source-control-forgejo':657 '@t3tools/source-control-forgejo':
658 specifier: workspace:*658 specifier: workspace:*
659 version: link:../../packages/source-control-forgejo659 version: link:../../packages/source-control-forgejo
660 '@t3tools/source-control-gitlab':
661 specifier: workspace:*
662 version: link:../../packages/source-control-gitlab
660 effect:663 effect:
661 specifier: 4.0.2664 specifier: 4.0.2
662 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)665 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)
...@@ -700,6 +703,9 @@ importers:...@@ -700,6 +703,9 @@ importers:
700 '@t3tools/shared':703 '@t3tools/shared':
701 specifier: workspace:*704 specifier: workspace:*
702 version: link:../../packages/shared705 version: link:../../packages/shared
706 '@t3tools/source-control-testing':
707 specifier: workspace:*
708 version: link:../../packages/source-control-testing
703 '@t3tools/ssh':709 '@t3tools/ssh':
704 specifier: workspace:*710 specifier: workspace:*
705 version: link:../../packages/ssh711 version: link:../../packages/ssh
...@@ -1603,6 +1609,34 @@ importers:...@@ -1603,6 +1609,34 @@ importers:
1603 specifier: 1.0.01609 specifier: 1.0.0
1604 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)1610 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
1606 packages/source-control-testing:1640 packages/source-control-testing:
1607 dependencies:1641 dependencies:
1608 '@t3tools/contracts':1642 '@t3tools/contracts':
scripts/release-smoke.ts+1
...@@ -40,6 +40,7 @@ const workspaceFiles = [...@@ -40,6 +40,7 @@ const workspaceFiles = [
40 "packages/source-control-azure-devops/package.json",40 "packages/source-control-azure-devops/package.json",
41 "packages/source-control-core/package.json",41 "packages/source-control-core/package.json",
42 "packages/source-control-forgejo/package.json",42 "packages/source-control-forgejo/package.json",
43 "packages/source-control-gitlab/package.json",
43 "packages/source-control-testing/package.json",44 "packages/source-control-testing/package.json",
44 "packages/effect-acp/package.json",45 "packages/effect-acp/package.json",
45 "packages/effect-codex-app-server/package.json",46 "packages/effect-codex-app-server/package.json",