gitlab-mcp 0.1.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.dockerignore +7 -0
  2. package/.editorconfig +9 -0
  3. package/.env.example +75 -0
  4. package/.github/workflows/nodejs.yml +31 -0
  5. package/.github/workflows/npm-publish.yml +31 -0
  6. package/.husky/pre-commit +1 -0
  7. package/.nvmrc +1 -0
  8. package/.prettierrc.json +6 -0
  9. package/Dockerfile +20 -0
  10. package/README.md +416 -251
  11. package/docker-compose.yml +10 -0
  12. package/docs/architecture.md +310 -0
  13. package/docs/authentication.md +299 -0
  14. package/docs/configuration.md +149 -0
  15. package/docs/deployment.md +336 -0
  16. package/docs/tools.md +294 -0
  17. package/eslint.config.js +23 -0
  18. package/package.json +70 -32
  19. package/scripts/get-oauth-token.example.sh +15 -0
  20. package/src/config/env.ts +171 -0
  21. package/src/http.ts +605 -0
  22. package/src/index.ts +77 -0
  23. package/src/lib/auth-context.ts +19 -0
  24. package/src/lib/gitlab-client.ts +1810 -0
  25. package/src/lib/logger.ts +17 -0
  26. package/src/lib/network.ts +45 -0
  27. package/src/lib/oauth.ts +287 -0
  28. package/src/lib/output.ts +51 -0
  29. package/src/lib/policy.ts +78 -0
  30. package/src/lib/request-runtime.ts +376 -0
  31. package/src/lib/sanitize.ts +25 -0
  32. package/src/server/build-server.ts +17 -0
  33. package/src/tools/gitlab.ts +3128 -0
  34. package/src/tools/health.ts +27 -0
  35. package/src/tools/mr-code-context.ts +473 -0
  36. package/src/types/context.ts +13 -0
  37. package/tests/auth-context.test.ts +102 -0
  38. package/tests/gitlab-client.test.ts +674 -0
  39. package/tests/graphql-guard.test.ts +121 -0
  40. package/tests/integration/agent-loop.integration.test.ts +552 -0
  41. package/tests/integration/server.integration.test.ts +543 -0
  42. package/tests/mr-code-context.test.ts +600 -0
  43. package/tests/oauth.test.ts +43 -0
  44. package/tests/output.test.ts +186 -0
  45. package/tests/policy.test.ts +324 -0
  46. package/tests/request-runtime.test.ts +252 -0
  47. package/tests/sanitize.test.ts +123 -0
  48. package/tests/upload-reference.test.ts +84 -0
  49. package/tsconfig.build.json +11 -0
  50. package/tsconfig.json +21 -0
  51. package/vitest.config.ts +12 -0
  52. package/LICENSE +0 -21
  53. package/build/index.js +0 -1641
  54. package/build/schemas.js +0 -684
  55. package/build/test-note.js +0 -54
@@ -0,0 +1,674 @@
1
+ import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { GitLabApiError, GitLabClient, getEffectiveSessionAuth } from "../src/lib/gitlab-client.js";
4
+
5
+ const fetchMock = vi.fn();
6
+
7
+ vi.stubGlobal("fetch", fetchMock);
8
+
9
+ afterEach(() => {
10
+ fetchMock.mockReset();
11
+ });
12
+
13
+ afterAll(() => {
14
+ vi.unstubAllGlobals();
15
+ });
16
+
17
+ function jsonResponse(data: unknown, status = 200) {
18
+ return new Response(JSON.stringify(data), {
19
+ status,
20
+ statusText: status === 200 ? "OK" : "Error",
21
+ headers: { "content-type": "application/json" }
22
+ });
23
+ }
24
+
25
+ function textResponse(text: string, status = 200) {
26
+ return new Response(text, {
27
+ status,
28
+ statusText: status === 200 ? "OK" : "Error",
29
+ headers: { "content-type": "text/plain" }
30
+ });
31
+ }
32
+
33
+ describe("GitLabClient", () => {
34
+ describe("authentication", () => {
35
+ it("sends private token header when token is provided", async () => {
36
+ fetchMock.mockResolvedValue(jsonResponse({ id: 1, name: "demo" }));
37
+
38
+ const client = new GitLabClient("https://gitlab.example.com", "token-123");
39
+ await client.getProject("group/demo");
40
+
41
+ expect(fetchMock).toHaveBeenCalledTimes(1);
42
+ const [requestUrl, init] = fetchMock.mock.calls[0] as [URL | string, RequestInit];
43
+
44
+ expect(String(requestUrl)).toContain("/api/v4/projects/group%2Fdemo");
45
+ expect(new Headers(init.headers).get("PRIVATE-TOKEN")).toBe("token-123");
46
+ });
47
+
48
+ it("does not send private token header when no token is provided", async () => {
49
+ fetchMock.mockResolvedValue(jsonResponse([]));
50
+
51
+ const client = new GitLabClient("https://gitlab.example.com");
52
+ await client.listProjects();
53
+
54
+ const [, init] = fetchMock.mock.calls[0] as [URL | string, RequestInit];
55
+ expect(new Headers(init.headers).has("PRIVATE-TOKEN")).toBe(false);
56
+ });
57
+
58
+ it("uses token from request options over default token", async () => {
59
+ fetchMock.mockResolvedValue(jsonResponse({ id: 1 }));
60
+
61
+ const client = new GitLabClient("https://gitlab.example.com", "default-token");
62
+ await client.getProject("p1", { token: "override-token" });
63
+
64
+ const [, init] = fetchMock.mock.calls[0] as [URL | string, RequestInit];
65
+ expect(new Headers(init.headers).get("PRIVATE-TOKEN")).toBe("override-token");
66
+ });
67
+ });
68
+
69
+ describe("error handling", () => {
70
+ it("throws GitLabApiError for non-2xx responses", async () => {
71
+ fetchMock.mockResolvedValue(jsonResponse({ message: "404 Project Not Found" }, 404));
72
+
73
+ const client = new GitLabClient("https://gitlab.example.com", "token-123");
74
+ const error = await client.getProject("missing/project").catch((reason) => reason);
75
+
76
+ expect(error).toBeInstanceOf(GitLabApiError);
77
+ expect(error).toMatchObject({ status: 404 });
78
+ });
79
+
80
+ it("includes error details from JSON response", async () => {
81
+ fetchMock.mockResolvedValue(
82
+ jsonResponse({ message: "Forbidden", error_description: "scope required" }, 403)
83
+ );
84
+
85
+ const client = new GitLabClient("https://gitlab.example.com", "token");
86
+ const error = (await client.getProject("p1").catch((reason) => reason)) as GitLabApiError;
87
+
88
+ expect(error.status).toBe(403);
89
+ expect(error.details).toEqual({ message: "Forbidden", error_description: "scope required" });
90
+ });
91
+
92
+ it("handles text error responses", async () => {
93
+ fetchMock.mockResolvedValue(textResponse("Server Error", 500));
94
+
95
+ const client = new GitLabClient("https://gitlab.example.com", "token");
96
+ const error = (await client.getProject("p1").catch((reason) => reason)) as GitLabApiError;
97
+
98
+ expect(error.status).toBe(500);
99
+ expect(error.details).toBe("Server Error");
100
+ });
101
+
102
+ it("GitLabApiError has correct name", () => {
103
+ const error = new GitLabApiError("test", 400, { info: "details" });
104
+ expect(error.name).toBe("GitLabApiError");
105
+ expect(error.message).toBe("test");
106
+ expect(error.status).toBe(400);
107
+ expect(error.details).toEqual({ info: "details" });
108
+ });
109
+ });
110
+
111
+ describe("URL normalization", () => {
112
+ it("normalizes base URL to include /api/v4", async () => {
113
+ fetchMock.mockResolvedValue(jsonResponse([]));
114
+
115
+ const client = new GitLabClient("https://gitlab.example.com");
116
+ await client.listProjects();
117
+
118
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
119
+ expect(String(requestUrl)).toContain("https://gitlab.example.com/api/v4/projects");
120
+ });
121
+
122
+ it("does not double-add /api/v4 when already present", async () => {
123
+ fetchMock.mockResolvedValue(jsonResponse([]));
124
+
125
+ const client = new GitLabClient("https://gitlab.example.com/api/v4");
126
+ await client.listProjects();
127
+
128
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
129
+ const urlStr = String(requestUrl);
130
+ expect(urlStr).toContain("/api/v4/projects");
131
+ expect(urlStr).not.toContain("/api/v4/api/v4");
132
+ });
133
+
134
+ it("handles trailing slash in base URL", async () => {
135
+ fetchMock.mockResolvedValue(jsonResponse([]));
136
+
137
+ const client = new GitLabClient("https://gitlab.example.com/api/v4/");
138
+ await client.listProjects();
139
+
140
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
141
+ expect(String(requestUrl)).toContain("/api/v4/projects");
142
+ });
143
+
144
+ it("handles subpath GitLab installations", async () => {
145
+ fetchMock.mockResolvedValue(jsonResponse([]));
146
+
147
+ const client = new GitLabClient("https://company.com/gitlab");
148
+ await client.listProjects();
149
+
150
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
151
+ expect(String(requestUrl)).toContain("/gitlab/api/v4/projects");
152
+ });
153
+ });
154
+
155
+ describe("query parameters", () => {
156
+ it("adds query params for project search", async () => {
157
+ fetchMock.mockResolvedValue(jsonResponse([]));
158
+
159
+ const client = new GitLabClient("https://gitlab.example.com");
160
+ await client.searchProjects("backend", 7);
161
+
162
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
163
+ const url = new URL(String(requestUrl));
164
+
165
+ expect(url.pathname).toBe("/api/v4/projects");
166
+ expect(url.searchParams.get("search")).toBe("backend");
167
+ expect(url.searchParams.get("simple")).toBe("true");
168
+ expect(url.searchParams.get("per_page")).toBe("7");
169
+ });
170
+
171
+ it("skips null and undefined query parameters", async () => {
172
+ fetchMock.mockResolvedValue(jsonResponse([]));
173
+
174
+ const client = new GitLabClient("https://gitlab.example.com");
175
+ await client.listProjects({ query: { key: "val", empty: undefined, nil: null } });
176
+
177
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
178
+ const url = new URL(String(requestUrl));
179
+
180
+ expect(url.searchParams.get("key")).toBe("val");
181
+ expect(url.searchParams.has("empty")).toBe(false);
182
+ expect(url.searchParams.has("nil")).toBe(false);
183
+ });
184
+ });
185
+
186
+ describe("HTTP methods", () => {
187
+ it("uses GET for read methods", async () => {
188
+ fetchMock.mockResolvedValue(jsonResponse([]));
189
+
190
+ const client = new GitLabClient("https://gitlab.example.com", "token");
191
+ await client.listProjects();
192
+
193
+ const [, init] = fetchMock.mock.calls[0] as [URL | string, RequestInit];
194
+ expect(init.method).toBe("GET");
195
+ });
196
+
197
+ it("uses POST for create methods", async () => {
198
+ fetchMock.mockResolvedValue(jsonResponse({ id: 1 }));
199
+
200
+ const client = new GitLabClient("https://gitlab.example.com", "token");
201
+ await client.createIssue("proj", { title: "Bug" });
202
+
203
+ const [, init] = fetchMock.mock.calls[0] as [URL | string, RequestInit];
204
+ expect(init.method).toBe("POST");
205
+ });
206
+
207
+ it("uses PUT for update methods", async () => {
208
+ fetchMock.mockResolvedValue(jsonResponse({ id: 1 }));
209
+
210
+ const client = new GitLabClient("https://gitlab.example.com", "token");
211
+ await client.updateIssue("proj", "1", { title: "Updated" });
212
+
213
+ const [, init] = fetchMock.mock.calls[0] as [URL | string, RequestInit];
214
+ expect(init.method).toBe("PUT");
215
+ });
216
+
217
+ it("uses DELETE for delete methods", async () => {
218
+ fetchMock.mockResolvedValue(jsonResponse({}));
219
+
220
+ const client = new GitLabClient("https://gitlab.example.com", "token");
221
+ await client.deleteIssue("proj", "1");
222
+
223
+ const [, init] = fetchMock.mock.calls[0] as [URL | string, RequestInit];
224
+ expect(init.method).toBe("DELETE");
225
+ });
226
+ });
227
+
228
+ describe("global endpoints", () => {
229
+ it("supports global merge request listing endpoint", async () => {
230
+ fetchMock.mockResolvedValue(jsonResponse([]));
231
+
232
+ const client = new GitLabClient("https://gitlab.example.com");
233
+ await client.listGlobalMergeRequests({
234
+ query: { state: "opened", per_page: 5 }
235
+ });
236
+
237
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
238
+ const url = new URL(String(requestUrl));
239
+ expect(url.pathname).toBe("/api/v4/merge_requests");
240
+ expect(url.searchParams.get("state")).toBe("opened");
241
+ expect(url.searchParams.get("per_page")).toBe("5");
242
+ });
243
+
244
+ it("supports global issue listing endpoint", async () => {
245
+ fetchMock.mockResolvedValue(jsonResponse([]));
246
+
247
+ const client = new GitLabClient("https://gitlab.example.com");
248
+ await client.listGlobalIssues({
249
+ query: { scope: "assigned_to_me", page: 2 }
250
+ });
251
+
252
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
253
+ const url = new URL(String(requestUrl));
254
+ expect(url.pathname).toBe("/api/v4/issues");
255
+ expect(url.searchParams.get("scope")).toBe("assigned_to_me");
256
+ expect(url.searchParams.get("page")).toBe("2");
257
+ });
258
+ });
259
+
260
+ describe("attachment downloads", () => {
261
+ it("rejects cross-origin attachment URLs", async () => {
262
+ const client = new GitLabClient("https://gitlab.example.com", "token-123");
263
+
264
+ const error = await client
265
+ .downloadAttachment("https://evil.example.net/uploads/secret/file.txt")
266
+ .catch((reason) => reason);
267
+
268
+ expect(error).toBeInstanceOf(Error);
269
+ expect((error as Error).message).toContain("cross-origin");
270
+ expect(fetchMock).not.toHaveBeenCalled();
271
+ });
272
+
273
+ it("allows same-origin attachment URLs", async () => {
274
+ fetchMock.mockResolvedValue(
275
+ new Response("hello", {
276
+ status: 200,
277
+ headers: {
278
+ "content-type": "text/plain",
279
+ "content-disposition": 'attachment; filename="hello.txt"'
280
+ }
281
+ })
282
+ );
283
+
284
+ const client = new GitLabClient("https://gitlab.example.com", "token-123");
285
+ const result = await client.downloadAttachment(
286
+ "https://gitlab.example.com/uploads/secret/hello.txt"
287
+ );
288
+
289
+ expect(result.fileName).toBe("hello.txt");
290
+ expect(result.contentType).toBe("text/plain");
291
+ expect(Buffer.from(result.base64, "base64").toString("utf8")).toBe("hello");
292
+ expect(fetchMock).toHaveBeenCalledTimes(1);
293
+ });
294
+
295
+ it("applies beforeRequest token overrides to attachment downloads", async () => {
296
+ fetchMock.mockResolvedValue(
297
+ new Response("ok", {
298
+ status: 200,
299
+ headers: {
300
+ "content-type": "text/plain",
301
+ "content-disposition": 'attachment; filename="ok.txt"'
302
+ }
303
+ })
304
+ );
305
+
306
+ const client = new GitLabClient("https://gitlab.example.com", undefined, {
307
+ beforeRequest: async () => ({ token: "token-from-hook" })
308
+ });
309
+
310
+ await client.downloadAttachment("https://gitlab.example.com/uploads/secret/ok.txt");
311
+
312
+ const [, init] = fetchMock.mock.calls[0] as [URL | string, RequestInit];
313
+ expect(new Headers(init.headers).get("PRIVATE-TOKEN")).toBe("token-from-hook");
314
+ });
315
+
316
+ it("handles relative attachment URLs", async () => {
317
+ fetchMock.mockResolvedValue(
318
+ new Response("data", {
319
+ status: 200,
320
+ headers: {
321
+ "content-type": "application/octet-stream",
322
+ "content-disposition": 'attachment; filename="data.bin"'
323
+ }
324
+ })
325
+ );
326
+
327
+ const client = new GitLabClient("https://gitlab.example.com", "token");
328
+ const result = await client.downloadAttachment("/uploads/secret/data.bin");
329
+
330
+ expect(result.fileName).toBe("data.bin");
331
+ expect(fetchMock).toHaveBeenCalledTimes(1);
332
+ });
333
+
334
+ it("returns fallback filename when content-disposition is missing", async () => {
335
+ fetchMock.mockResolvedValue(
336
+ new Response("data", {
337
+ status: 200,
338
+ headers: { "content-type": "application/octet-stream" }
339
+ })
340
+ );
341
+
342
+ const client = new GitLabClient("https://gitlab.example.com", "token");
343
+ const result = await client.downloadAttachment(
344
+ "https://gitlab.example.com/uploads/abc/file.bin"
345
+ );
346
+
347
+ expect(result.fileName).toContain("attachment-");
348
+ });
349
+
350
+ it("throws GitLabApiError for failed attachment download", async () => {
351
+ fetchMock.mockResolvedValue(jsonResponse({ message: "Not Found" }, 404));
352
+
353
+ const client = new GitLabClient("https://gitlab.example.com", "token");
354
+ const error = await client
355
+ .downloadAttachment("https://gitlab.example.com/uploads/abc/file.txt")
356
+ .catch((reason) => reason);
357
+
358
+ expect(error).toBeInstanceOf(GitLabApiError);
359
+ expect((error as GitLabApiError).status).toBe(404);
360
+ });
361
+ });
362
+
363
+ describe("beforeRequest hook", () => {
364
+ it("allows overriding headers", async () => {
365
+ fetchMock.mockResolvedValue(jsonResponse([]));
366
+
367
+ const customHeaders = new Headers();
368
+ customHeaders.set("X-Custom", "value");
369
+ customHeaders.set("Accept", "application/json");
370
+
371
+ const client = new GitLabClient("https://gitlab.example.com", "token", {
372
+ beforeRequest: async () => ({ headers: customHeaders })
373
+ });
374
+
375
+ await client.listProjects();
376
+
377
+ const [, init] = fetchMock.mock.calls[0] as [URL | string, RequestInit];
378
+ const headers = new Headers(init.headers);
379
+ expect(headers.get("X-Custom")).toBe("value");
380
+ });
381
+
382
+ it("allows overriding token", async () => {
383
+ fetchMock.mockResolvedValue(jsonResponse([]));
384
+
385
+ const client = new GitLabClient("https://gitlab.example.com", "original-token", {
386
+ beforeRequest: async () => ({ token: "dynamic-token" })
387
+ });
388
+
389
+ await client.listProjects();
390
+
391
+ const [, init] = fetchMock.mock.calls[0] as [URL | string, RequestInit];
392
+ expect(new Headers(init.headers).get("PRIVATE-TOKEN")).toBe("dynamic-token");
393
+ });
394
+
395
+ it("allows overriding fetch implementation", async () => {
396
+ const customFetch = vi.fn().mockResolvedValue(jsonResponse([]));
397
+
398
+ const client = new GitLabClient("https://gitlab.example.com", "token", {
399
+ beforeRequest: async () => ({ fetchImpl: customFetch as typeof fetch })
400
+ });
401
+
402
+ await client.listProjects();
403
+
404
+ expect(customFetch).toHaveBeenCalledTimes(1);
405
+ expect(fetchMock).not.toHaveBeenCalled();
406
+ });
407
+ });
408
+
409
+ describe("API URL round-robin", () => {
410
+ it("rotates through multiple API URLs", async () => {
411
+ fetchMock
412
+ .mockResolvedValueOnce(jsonResponse([]))
413
+ .mockResolvedValueOnce(jsonResponse([]))
414
+ .mockResolvedValueOnce(jsonResponse([]));
415
+
416
+ const client = new GitLabClient("https://primary.example.com", "token", {
417
+ apiUrls: ["https://a.example.com/api/v4", "https://b.example.com/api/v4"]
418
+ });
419
+
420
+ await client.listProjects();
421
+ await client.listProjects();
422
+ await client.listProjects();
423
+
424
+ const urls = fetchMock.mock.calls.map(
425
+ (call: [URL | string, RequestInit]) => new URL(String(call[0])).origin
426
+ );
427
+
428
+ expect(urls[0]).toBe("https://a.example.com");
429
+ expect(urls[1]).toBe("https://b.example.com");
430
+ expect(urls[2]).toBe("https://a.example.com");
431
+ });
432
+
433
+ it("uses base URL when no apiUrls provided", async () => {
434
+ fetchMock.mockResolvedValue(jsonResponse([]));
435
+
436
+ const client = new GitLabClient("https://gitlab.example.com", "token");
437
+ await client.listProjects();
438
+
439
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
440
+ expect(String(requestUrl)).toContain("gitlab.example.com");
441
+ });
442
+ });
443
+
444
+ describe("GraphQL", () => {
445
+ it("sends GraphQL requests to the graphql endpoint", async () => {
446
+ fetchMock.mockResolvedValue(jsonResponse({ data: {} }));
447
+
448
+ const client = new GitLabClient("https://gitlab.example.com/api/v4", "token");
449
+ await client.executeGraphql("query { currentUser { id } }", undefined);
450
+
451
+ const [requestUrl, init] = fetchMock.mock.calls[0] as [URL | string, RequestInit];
452
+ const url = new URL(String(requestUrl));
453
+
454
+ expect(url.pathname).toContain("graphql");
455
+ expect(init.method).toBe("POST");
456
+
457
+ const body = JSON.parse(init.body as string);
458
+ expect(body.query).toBe("query { currentUser { id } }");
459
+ });
460
+
461
+ it("includes variables in GraphQL request", async () => {
462
+ fetchMock.mockResolvedValue(jsonResponse({ data: {} }));
463
+
464
+ const client = new GitLabClient("https://gitlab.example.com", "token");
465
+ await client.executeGraphql("query ($id: ID!) { project(id: $id) { name } }", {
466
+ id: "gid://gitlab/Project/1"
467
+ });
468
+
469
+ const [, init] = fetchMock.mock.calls[0] as [URL | string, RequestInit];
470
+ const body = JSON.parse(init.body as string);
471
+
472
+ expect(body.variables).toEqual({ id: "gid://gitlab/Project/1" });
473
+ });
474
+ });
475
+
476
+ describe("specific API methods", () => {
477
+ it("encodes project ID in URLs", async () => {
478
+ fetchMock.mockResolvedValue(jsonResponse({}));
479
+
480
+ const client = new GitLabClient("https://gitlab.example.com", "token");
481
+ await client.getProject("group/subgroup/project");
482
+
483
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
484
+ expect(String(requestUrl)).toContain("group%2Fsubgroup%2Fproject");
485
+ });
486
+
487
+ it("creates merge request with correct payload", async () => {
488
+ fetchMock.mockResolvedValue(jsonResponse({ iid: 1 }));
489
+
490
+ const client = new GitLabClient("https://gitlab.example.com", "token");
491
+ await client.createMergeRequest("proj", {
492
+ source_branch: "feature",
493
+ target_branch: "main",
494
+ title: "My MR",
495
+ description: "Description"
496
+ });
497
+
498
+ const [requestUrl, init] = fetchMock.mock.calls[0] as [URL | string, RequestInit];
499
+ expect(String(requestUrl)).toContain("/merge_requests");
500
+ expect(init.method).toBe("POST");
501
+
502
+ const body = JSON.parse(init.body as string);
503
+ expect(body.source_branch).toBe("feature");
504
+ expect(body.target_branch).toBe("main");
505
+ expect(body.title).toBe("My MR");
506
+ });
507
+
508
+ it("creates branch with query parameters", async () => {
509
+ fetchMock.mockResolvedValue(jsonResponse({ name: "new-branch" }));
510
+
511
+ const client = new GitLabClient("https://gitlab.example.com", "token");
512
+ await client.createBranch("proj", { branch: "new-branch", ref: "main" });
513
+
514
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
515
+ const url = new URL(String(requestUrl));
516
+ expect(url.searchParams.get("branch")).toBe("new-branch");
517
+ expect(url.searchParams.get("ref")).toBe("main");
518
+ });
519
+
520
+ it("gets file contents with ref", async () => {
521
+ fetchMock.mockResolvedValue(jsonResponse({ content: "aGVsbG8=", encoding: "base64" }));
522
+
523
+ const client = new GitLabClient("https://gitlab.example.com", "token");
524
+ await client.getFileContents("proj", "src/index.ts", "main");
525
+
526
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
527
+ const url = new URL(String(requestUrl));
528
+ expect(url.pathname).toContain("/repository/files/src%2Findex.ts");
529
+ expect(url.searchParams.get("ref")).toBe("main");
530
+ });
531
+
532
+ it("uploads markdown file", async () => {
533
+ fetchMock.mockResolvedValue(jsonResponse({ markdown: "![file](/uploads/abc/file.md)" }));
534
+
535
+ const client = new GitLabClient("https://gitlab.example.com", "token");
536
+ await client.uploadMarkdown("proj", "# Hello", "readme.md");
537
+
538
+ const [, init] = fetchMock.mock.calls[0] as [URL | string, RequestInit];
539
+ expect(init.method).toBe("POST");
540
+ expect(init.body).toBeInstanceOf(FormData);
541
+ });
542
+
543
+ it("creates pipeline with variables", async () => {
544
+ fetchMock.mockResolvedValue(jsonResponse({ id: 1 }));
545
+
546
+ const client = new GitLabClient("https://gitlab.example.com", "token");
547
+ await client.createPipeline("proj", {
548
+ ref: "main",
549
+ variables: [{ key: "ENV", value: "production" }]
550
+ });
551
+
552
+ const [, init] = fetchMock.mock.calls[0] as [URL | string, RequestInit];
553
+ const body = JSON.parse(init.body as string);
554
+ expect(body.ref).toBe("main");
555
+ expect(body.variables).toEqual([{ key: "ENV", value: "production" }]);
556
+ });
557
+
558
+ it("gets commit diff", async () => {
559
+ fetchMock.mockResolvedValue(jsonResponse([]));
560
+
561
+ const client = new GitLabClient("https://gitlab.example.com", "token");
562
+ await client.getCommitDiff("proj", "abc123");
563
+
564
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
565
+ expect(String(requestUrl)).toContain("/commits/abc123/diff");
566
+ });
567
+
568
+ it("creates issue note with discussion_id", async () => {
569
+ fetchMock.mockResolvedValue(jsonResponse({ id: 1 }));
570
+
571
+ const client = new GitLabClient("https://gitlab.example.com", "token");
572
+ await client.createIssueNote("proj", "5", {
573
+ body: "Comment",
574
+ discussion_id: "disc-1"
575
+ });
576
+
577
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
578
+ expect(String(requestUrl)).toContain("/discussions/disc-1/notes");
579
+ });
580
+
581
+ it("creates issue note without discussion_id", async () => {
582
+ fetchMock.mockResolvedValue(jsonResponse({ id: 1 }));
583
+
584
+ const client = new GitLabClient("https://gitlab.example.com", "token");
585
+ await client.createIssueNote("proj", "5", { body: "Comment" });
586
+
587
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
588
+ expect(String(requestUrl)).toContain("/issues/5/notes");
589
+ expect(String(requestUrl)).not.toContain("/discussions/");
590
+ });
591
+
592
+ it("draft note creation maps body to note field", async () => {
593
+ fetchMock.mockResolvedValue(jsonResponse({ id: 1 }));
594
+
595
+ const client = new GitLabClient("https://gitlab.example.com", "token");
596
+ await client.createDraftNote("proj", "1", { body: "draft content" });
597
+
598
+ const [, init] = fetchMock.mock.calls[0] as [URL | string, RequestInit];
599
+ const body = JSON.parse(init.body as string);
600
+ expect(body.note).toBe("draft content");
601
+ });
602
+
603
+ it("myIssues uses correct path with project_id", async () => {
604
+ fetchMock.mockResolvedValue(jsonResponse([]));
605
+
606
+ const client = new GitLabClient("https://gitlab.example.com", "token");
607
+ await client.myIssues({ project_id: "my/proj", state: "opened" });
608
+
609
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
610
+ expect(String(requestUrl)).toContain("/projects/my%2Fproj/issues");
611
+ });
612
+
613
+ it("myIssues uses global path without project_id", async () => {
614
+ fetchMock.mockResolvedValue(jsonResponse([]));
615
+
616
+ const client = new GitLabClient("https://gitlab.example.com", "token");
617
+ await client.myIssues({ state: "opened" });
618
+
619
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
620
+ const url = new URL(String(requestUrl));
621
+ expect(url.pathname).toBe("/api/v4/issues");
622
+ expect(url.searchParams.get("scope")).toBe("assigned_to_me");
623
+ });
624
+
625
+ it("releases use encoded tag names", async () => {
626
+ fetchMock.mockResolvedValue(jsonResponse({}));
627
+
628
+ const client = new GitLabClient("https://gitlab.example.com", "token");
629
+ await client.getRelease("proj", "v1.0.0");
630
+
631
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
632
+ expect(String(requestUrl)).toContain("/releases/v1.0.0");
633
+ });
634
+
635
+ it("downloadReleaseAsset encodes path segments", async () => {
636
+ fetchMock.mockResolvedValue(jsonResponse({}));
637
+
638
+ const client = new GitLabClient("https://gitlab.example.com", "token");
639
+ await client.downloadReleaseAsset("proj", "v1.0", "bin/my app.tar.gz");
640
+
641
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
642
+ const urlStr = String(requestUrl);
643
+ expect(urlStr).toContain("/downloads/bin/my%20app.tar.gz");
644
+ });
645
+
646
+ it("deleteLabel uses query param for label name", async () => {
647
+ fetchMock.mockResolvedValue(jsonResponse(null));
648
+
649
+ const client = new GitLabClient("https://gitlab.example.com", "token");
650
+ await client.deleteLabel("proj", "bug");
651
+
652
+ const [requestUrl] = fetchMock.mock.calls[0] as [URL | string];
653
+ const url = new URL(String(requestUrl));
654
+ expect(url.searchParams.get("name")).toBe("bug");
655
+ });
656
+ });
657
+
658
+ describe("getEffectiveSessionAuth", () => {
659
+ it("returns defaults when no session auth is available", () => {
660
+ const result = getEffectiveSessionAuth("fallback-token", "https://gitlab.example.com");
661
+
662
+ expect(result.token).toBe("fallback-token");
663
+ expect(result.apiUrl).toBe("https://gitlab.example.com");
664
+ expect(result.updatedAt).toBeDefined();
665
+ });
666
+
667
+ it("returns undefined token when no defaults", () => {
668
+ const result = getEffectiveSessionAuth();
669
+
670
+ expect(result.token).toBeUndefined();
671
+ expect(result.apiUrl).toBeUndefined();
672
+ });
673
+ });
674
+ });