@zereight/mcp-gitlab 2.1.70 → 2.2.1

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 (158) hide show
  1. package/README.ko.md +9 -5
  2. package/README.md +15 -11
  3. package/README.pt-BR.md +9 -5
  4. package/README.zh-CN.md +9 -5
  5. package/build/config.js +12 -4
  6. package/build/deprecated-env.js +63 -0
  7. package/build/index.js +430 -200
  8. package/build/oauth-device-flow.js +80 -0
  9. package/build/oauth.js +135 -18
  10. package/build/schemas.js +1 -1
  11. package/build/server/remote-http-stack.js +39 -0
  12. package/build/tools/discover-activation.js +38 -0
  13. package/build/tools/exposed-tools.js +35 -0
  14. package/build/tools/listed-tools.js +76 -0
  15. package/build/tools/registry.js +232 -63
  16. package/build/tools/tool-descriptions.js +3 -3
  17. package/build/tools/tool-profile.js +66 -0
  18. package/build/utils/compact-tool-allowlist.js +14 -0
  19. package/build/utils/logger.js +34 -12
  20. package/package.json +3 -3
  21. package/build/scripts/check-skill-sync.js +0 -143
  22. package/build/scripts/generate-server-card.js +0 -39
  23. package/build/scripts/generate-tool-coverage.js +0 -58
  24. package/build/scripts/generate-tool-docs.js +0 -430
  25. package/build/scripts/tool-coverage/coverage.js +0 -341
  26. package/build/scripts/tool-coverage/coverage.test.js +0 -142
  27. package/build/test/callback-proxy-tests.js +0 -321
  28. package/build/test/cli/args.test.js +0 -175
  29. package/build/test/cli/boolean-flags.test.js +0 -106
  30. package/build/test/cli/compact-mcp-result.test.js +0 -93
  31. package/build/test/cli/credentials.test.js +0 -153
  32. package/build/test/cli/exposure.test.js +0 -104
  33. package/build/test/cli/load-config-snapshot.js +0 -5
  34. package/build/test/cli/output.test.js +0 -74
  35. package/build/test/cli/replay-command.test.js +0 -37
  36. package/build/test/cli/router.test.js +0 -183
  37. package/build/test/client-pool-test.js +0 -109
  38. package/build/test/clients/client.js +0 -34
  39. package/build/test/clients/custom-header-client.js +0 -122
  40. package/build/test/clients/sse-client.js +0 -95
  41. package/build/test/clients/stdio-client.js +0 -113
  42. package/build/test/clients/streamable-http-client.js +0 -95
  43. package/build/test/config-allowed-groups.test.js +0 -97
  44. package/build/test/create-commit-actions.test.js +0 -102
  45. package/build/test/draft-note-schema.test.js +0 -44
  46. package/build/test/dynamic-api-url-allowlist.test.js +0 -121
  47. package/build/test/dynamic-api-url-test.js +0 -304
  48. package/build/test/dynamic-routing-tests.js +0 -495
  49. package/build/test/get-merge-request-discussion-schema.test.js +0 -30
  50. package/build/test/gitlab-api-tool-errors.test.js +0 -112
  51. package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
  52. package/build/test/gitlab-diff-schema.test.js +0 -36
  53. package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
  54. package/build/test/gitlab-tag-schema.test.js +0 -51
  55. package/build/test/group-milestone-schema.test.js +0 -45
  56. package/build/test/mcp-oauth-tests.js +0 -905
  57. package/build/test/mcp-server-name.test.js +0 -87
  58. package/build/test/merge-merge-request-schema.test.js +0 -75
  59. package/build/test/multi-server-test.js +0 -190
  60. package/build/test/no-proxy-integration-test.js +0 -183
  61. package/build/test/no-proxy-test.js +0 -149
  62. package/build/test/nullable-gitlab-response-fields.test.js +0 -37
  63. package/build/test/nullish-tool-arguments-schema.test.js +0 -118
  64. package/build/test/oauth-device-flow-tests.js +0 -450
  65. package/build/test/oauth-download-url.test.js +0 -157
  66. package/build/test/oauth-startup.test.js +0 -204
  67. package/build/test/oauth-tests.js +0 -428
  68. package/build/test/path-segment-encoding.test.js +0 -98
  69. package/build/test/remote-auth-simple-test.js +0 -277
  70. package/build/test/remote-auth-tests.js +0 -314
  71. package/build/test/response-masking.test.js +0 -445
  72. package/build/test/schema-tests.js +0 -1679
  73. package/build/test/server/metrics.test.js +0 -47
  74. package/build/test/server/request-helpers.test.js +0 -48
  75. package/build/test/server/version.test.js +0 -13
  76. package/build/test/sse-auth-guard.test.js +0 -96
  77. package/build/test/sse-session-limits.test.js +0 -273
  78. package/build/test/stateless/callback-proxy.test.js +0 -510
  79. package/build/test/stateless/client-id.test.js +0 -177
  80. package/build/test/stateless/codec.test.js +0 -328
  81. package/build/test/stateless/config-ttl.test.js +0 -149
  82. package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
  83. package/build/test/stateless/session-id-integration.test.js +0 -675
  84. package/build/test/stateless/session-id.test.js +0 -157
  85. package/build/test/streamable-http-concurrent-session.test.js +0 -171
  86. package/build/test/streamable-http-dns-rebinding.test.js +0 -185
  87. package/build/test/streamable-http-sse-stream.test.js +0 -350
  88. package/build/test/streamable-http-static-token-auth.test.js +0 -157
  89. package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
  90. package/build/test/test-all-transport-server.js +0 -255
  91. package/build/test/test-auth-retry.js +0 -195
  92. package/build/test/test-blank-filters.js +0 -156
  93. package/build/test/test-ci-catalog.js +0 -177
  94. package/build/test/test-ci-lint.js +0 -191
  95. package/build/test/test-ci-variables.js +0 -309
  96. package/build/test/test-create-repository.js +0 -120
  97. package/build/test/test-dependency-proxy.js +0 -195
  98. package/build/test/test-deployment-tools.js +0 -463
  99. package/build/test/test-download-attachment.js +0 -190
  100. package/build/test/test-dynamic-project-scope.js +0 -524
  101. package/build/test/test-empty-toolsets.js +0 -535
  102. package/build/test/test-get-file-blame.js +0 -145
  103. package/build/test/test-get-file-contents.js +0 -128
  104. package/build/test/test-geteffectiveprojectid.js +0 -804
  105. package/build/test/test-health-check.js +0 -115
  106. package/build/test/test-issue-description-patch.js +0 -256
  107. package/build/test/test-job-artifacts.js +0 -275
  108. package/build/test/test-json-schema.js +0 -166
  109. package/build/test/test-list-group-members.js +0 -150
  110. package/build/test/test-list-group-merge-requests.js +0 -165
  111. package/build/test/test-list-issues.js +0 -170
  112. package/build/test/test-list-merge-requests.js +0 -201
  113. package/build/test/test-list-milestones.js +0 -145
  114. package/build/test/test-list-project-members.js +0 -132
  115. package/build/test/test-merge-request-approval-state-tools.js +0 -213
  116. package/build/test/test-merge-request-approvals.js +0 -187
  117. package/build/test/test-merge-request-pipelines.js +0 -106
  118. package/build/test/test-mr-diffs-filter.js +0 -132
  119. package/build/test/test-mr-file-diffs.js +0 -251
  120. package/build/test/test-oauth-proxy-rate-limit.js +0 -133
  121. package/build/test/test-orbit.js +0 -155
  122. package/build/test/test-permission-mode.js +0 -424
  123. package/build/test/test-pipeline-schedules.js +0 -484
  124. package/build/test/test-protected-branches.js +0 -174
  125. package/build/test/test-remote-downloads.js +0 -510
  126. package/build/test/test-search-code.js +0 -275
  127. package/build/test/test-snippets.js +0 -735
  128. package/build/test/test-tags.js +0 -220
  129. package/build/test/test-todos.js +0 -195
  130. package/build/test/test-token-optimizations.js +0 -691
  131. package/build/test/test-toolset-filtering.js +0 -499
  132. package/build/test/test-update-issue-slim.js +0 -141
  133. package/build/test/test-update-project.js +0 -112
  134. package/build/test/test-upload-markdown.js +0 -206
  135. package/build/test/test-vulnerabilities.js +0 -399
  136. package/build/test/test-webhooks.js +0 -218
  137. package/build/test/tool-description-quality.test.js +0 -50
  138. package/build/test/utils/bulk-publish-options.test.js +0 -32
  139. package/build/test/utils/download-token.test.js +0 -35
  140. package/build/test/utils/forwarded-public-base-url.test.js +0 -46
  141. package/build/test/utils/gitlab-instance-version.test.js +0 -32
  142. package/build/test/utils/gitlab-version-gate.test.js +0 -74
  143. package/build/test/utils/graphql-query.test.js +0 -168
  144. package/build/test/utils/helpers-path.test.js +0 -104
  145. package/build/test/utils/jmespath-tool-result.test.js +0 -91
  146. package/build/test/utils/masking.test.js +0 -384
  147. package/build/test/utils/merge-request-position.test.js +0 -57
  148. package/build/test/utils/mock-gitlab-server.js +0 -614
  149. package/build/test/utils/proxy-client-ip.test.js +0 -28
  150. package/build/test/utils/redact-sensitive.test.js +0 -32
  151. package/build/test/utils/safe-redirect-fetch.test.js +0 -463
  152. package/build/test/utils/server-launcher.js +0 -231
  153. package/build/test/utils/tool-args.test.js +0 -210
  154. package/build/test/utils/url.test.js +0 -141
  155. package/build/test/utils/version-check.test.js +0 -239
  156. package/build/test/utils/wiki-title.test.js +0 -21
  157. package/build/test-note.js +0 -54
  158. package/build/test-resolve-issue-note.js +0 -127
@@ -1,484 +0,0 @@
1
- import { describe, test, before, after } from "node:test";
2
- import assert from "node:assert";
3
- import { spawn } from "child_process";
4
- import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
5
- const MOCK_TOKEN = "glpat-mock-token-pipeline-schedules";
6
- const TEST_PROJECT_ID = "123";
7
- const TEST_SCHEDULE_ID = "13";
8
- const MISSING_SCHEDULE_ID = "999";
9
- const TEST_VAR_KEY = "NIGHTLY_TARGET";
10
- const MOCK_OWNER = {
11
- name: "Administrator",
12
- username: "root",
13
- id: 1,
14
- state: "active",
15
- avatar_url: "https://gitlab.example.com/uploads/-/system/user/avatar/1/avatar.png",
16
- web_url: "https://gitlab.example.com/root",
17
- };
18
- const MOCK_SCHEDULE = {
19
- id: Number(TEST_SCHEDULE_ID),
20
- description: "Nightly build",
21
- ref: "refs/heads/main",
22
- cron: "0 1 * * *",
23
- cron_timezone: "UTC",
24
- next_run_at: "2026-09-01T01:00:00.000Z",
25
- active: true,
26
- created_at: "2026-08-01T13:31:08.849Z",
27
- updated_at: "2026-08-01T13:40:17.727Z",
28
- owner: MOCK_OWNER,
29
- };
30
- const MOCK_INACTIVE_SCHEDULE = {
31
- ...MOCK_SCHEDULE,
32
- id: 14,
33
- description: "Paused weekly deploy",
34
- cron: "0 3 * * 5",
35
- active: false,
36
- };
37
- const MOCK_SCHEDULE_DETAIL = {
38
- ...MOCK_SCHEDULE,
39
- last_pipeline: {
40
- id: 332,
41
- sha: "0e788619d0b5ec17388dffb973ecd505946156db",
42
- ref: "refs/heads/main",
43
- status: "success",
44
- },
45
- variables: [{ key: TEST_VAR_KEY, variable_type: "env_var", value: "staging", raw: false }],
46
- inputs: [{ name: "deploy_strategy", value: "blue-green" }],
47
- };
48
- const MOCK_SCHEDULE_PIPELINE = {
49
- id: 47,
50
- iid: 12,
51
- project_id: Number(TEST_PROJECT_ID),
52
- status: "success",
53
- source: "schedule",
54
- ref: "refs/heads/main",
55
- sha: "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
56
- web_url: "https://gitlab.example.com/foo/bar/-/pipelines/47",
57
- created_at: "2026-08-20T11:28:34.085Z",
58
- updated_at: "2026-08-20T11:32:35.169Z",
59
- };
60
- const MOCK_VARIABLE = {
61
- key: TEST_VAR_KEY,
62
- variable_type: "env_var",
63
- value: "staging",
64
- };
65
- async function callTool(toolName, args, env) {
66
- return new Promise((resolve, reject) => {
67
- const proc = spawn("node", ["build/index.js"], {
68
- stdio: ["pipe", "pipe", "pipe"],
69
- env: { ...process.env, ...env },
70
- });
71
- let output = "";
72
- let errorOutput = "";
73
- proc.stdout?.on("data", (d) => (output += d));
74
- proc.stderr?.on("data", (d) => (errorOutput += d));
75
- proc.on("close", code => {
76
- if (code !== 0) {
77
- return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
78
- }
79
- const line = output.split("\n").find(l => l.startsWith("{"));
80
- if (!line)
81
- return reject(new Error("No JSON output found"));
82
- try {
83
- const response = JSON.parse(line);
84
- if (response.error) {
85
- reject(new Error(response.error?.message ?? String(response.error)));
86
- }
87
- else {
88
- resolve(response.result);
89
- }
90
- }
91
- catch (e) {
92
- reject(e);
93
- }
94
- });
95
- proc.stdin?.end(JSON.stringify({
96
- jsonrpc: "2.0",
97
- id: 1,
98
- method: "tools/call",
99
- params: { name: toolName, arguments: args },
100
- }) + "\n");
101
- });
102
- }
103
- /** Tools that return JSON return it as the first content block's text. */
104
- async function callToolJson(toolName, args, env) {
105
- const result = await callTool(toolName, args, env);
106
- const content = result?.content?.[0]?.text;
107
- return content ? JSON.parse(content) : result;
108
- }
109
- /** Tools that return a human summary return it as the first content block's text. */
110
- async function callToolText(toolName, args, env) {
111
- const result = await callTool(toolName, args, env);
112
- return result?.content?.[0]?.text ?? "";
113
- }
114
- function listTools(env) {
115
- return new Promise((resolve, reject) => {
116
- const proc = spawn("node", ["build/index.js"], {
117
- stdio: ["pipe", "pipe", "pipe"],
118
- env: { ...process.env, ...env },
119
- });
120
- let output = "";
121
- proc.stdout?.on("data", (d) => (output += d));
122
- proc.on("close", () => {
123
- try {
124
- const line = output.split("\n").find(l => l.startsWith("{"));
125
- if (!line)
126
- return reject(new Error("No JSON output found"));
127
- const response = JSON.parse(line);
128
- resolve((response.result?.tools ?? []).map((t) => t.name));
129
- }
130
- catch (e) {
131
- reject(e);
132
- }
133
- });
134
- proc.stdin?.end(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }) + "\n");
135
- });
136
- }
137
- describe("Pipeline schedule tools", () => {
138
- let mockServer;
139
- let mockApiUrl;
140
- let baseEnv;
141
- let lastReceivedScope;
142
- let lastReceivedBody;
143
- let lastReceivedQuery;
144
- // Read through a function so control-flow narrowing from the `= undefined`
145
- // reset before each call does not collapse the type to `never`.
146
- const capturedBody = () => lastReceivedBody;
147
- const capturedQuery = () => lastReceivedQuery;
148
- const schedulesPath = `/projects/${TEST_PROJECT_ID}/pipeline_schedules`;
149
- const schedulePath = `${schedulesPath}/${TEST_SCHEDULE_ID}`;
150
- const variablesPath = `${schedulePath}/variables`;
151
- const variablePath = `${variablesPath}/${TEST_VAR_KEY}`;
152
- before(async () => {
153
- mockServer = new MockGitLabServer({
154
- port: await findMockServerPort(),
155
- validTokens: [MOCK_TOKEN],
156
- });
157
- // --- Schedule endpoints ---
158
- mockServer.addMockHandler("get", schedulesPath, (req, res) => {
159
- const scope = req.query.scope;
160
- lastReceivedScope = scope;
161
- if (scope === "inactive") {
162
- res.json([MOCK_INACTIVE_SCHEDULE]);
163
- }
164
- else if (scope === "active") {
165
- res.json([MOCK_SCHEDULE]);
166
- }
167
- else {
168
- res.json([MOCK_SCHEDULE, MOCK_INACTIVE_SCHEDULE]);
169
- }
170
- });
171
- mockServer.addMockHandler("get", schedulePath, (_req, res) => {
172
- res.json(MOCK_SCHEDULE_DETAIL);
173
- });
174
- mockServer.addMockHandler("get", `${schedulesPath}/${MISSING_SCHEDULE_ID}`, (_req, res) => {
175
- res.status(404).json({ message: "404 Not found" });
176
- });
177
- mockServer.addMockHandler("get", `${schedulePath}/pipelines`, (req, res) => {
178
- lastReceivedQuery = req.query;
179
- res.json([MOCK_SCHEDULE_PIPELINE]);
180
- });
181
- mockServer.addMockHandler("post", schedulesPath, (req, res) => {
182
- lastReceivedBody = req.body;
183
- res.status(201).json({ ...MOCK_SCHEDULE, ...req.body, id: 14 });
184
- });
185
- mockServer.addMockHandler("put", schedulePath, (req, res) => {
186
- lastReceivedBody = req.body;
187
- res.json({ ...MOCK_SCHEDULE, ...req.body });
188
- });
189
- mockServer.addMockHandler("delete", schedulePath, (_req, res) => {
190
- res.json(MOCK_SCHEDULE);
191
- });
192
- mockServer.addMockHandler("post", `${schedulePath}/play`, (_req, res) => {
193
- res.status(201).json({ message: "201 Created" });
194
- });
195
- mockServer.addMockHandler("post", `${schedulePath}/take_ownership`, (_req, res) => {
196
- res.json({
197
- ...MOCK_SCHEDULE,
198
- owner: { ...MOCK_OWNER, id: 50, name: "Ada", username: "ada" },
199
- });
200
- });
201
- // --- Schedule variable endpoints ---
202
- mockServer.addMockHandler("get", variablePath, (_req, res) => {
203
- res.json(MOCK_VARIABLE);
204
- });
205
- mockServer.addMockHandler("post", variablesPath, (req, res) => {
206
- lastReceivedBody = req.body;
207
- res.status(201).json({ ...MOCK_VARIABLE, ...req.body });
208
- });
209
- mockServer.addMockHandler("put", variablePath, (req, res) => {
210
- lastReceivedBody = req.body;
211
- res.json({ ...MOCK_VARIABLE, ...req.body });
212
- });
213
- // GitLab documents a 200 with the deleted variable, but self-managed
214
- // instances have been seen answering 204; neither body is parsed.
215
- mockServer.addMockHandler("delete", variablePath, (_req, res) => {
216
- res.status(204).send();
217
- });
218
- await mockServer.start();
219
- mockApiUrl = `${mockServer.getUrl()}/api/v4`;
220
- baseEnv = {
221
- GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
222
- GITLAB_API_URL: mockApiUrl,
223
- GITLAB_TOOLSETS: "pipelines",
224
- };
225
- });
226
- after(async () => {
227
- await mockServer.stop();
228
- });
229
- // --- Reads ---
230
- test("list_pipeline_schedules returns every schedule when no scope is given", async () => {
231
- lastReceivedScope = undefined;
232
- const result = await callToolJson("list_pipeline_schedules", { project_id: TEST_PROJECT_ID }, baseEnv);
233
- assert.ok(Array.isArray(result));
234
- assert.strictEqual(result.length, 2);
235
- assert.strictEqual(result[0].description, "Nightly build");
236
- assert.strictEqual(result[0].cron, "0 1 * * *");
237
- assert.strictEqual(result[0].active, true);
238
- assert.strictEqual(lastReceivedScope, undefined);
239
- });
240
- test("list_pipeline_schedules forwards scope to GitLab", async () => {
241
- lastReceivedScope = undefined;
242
- const result = await callToolJson("list_pipeline_schedules", { project_id: TEST_PROJECT_ID, scope: "inactive" }, baseEnv);
243
- assert.strictEqual(lastReceivedScope, "inactive");
244
- assert.strictEqual(result.length, 1);
245
- assert.strictEqual(result[0].active, false);
246
- assert.strictEqual(result[0].description, "Paused weekly deploy");
247
- });
248
- test("get_pipeline_schedule returns variables, inputs and last pipeline", async () => {
249
- const result = await callToolJson("get_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: TEST_SCHEDULE_ID }, baseEnv);
250
- assert.strictEqual(result.id, TEST_SCHEDULE_ID);
251
- assert.strictEqual(result.description, "Nightly build");
252
- assert.strictEqual(result.owner.username, "root");
253
- assert.strictEqual(result.last_pipeline.status, "success");
254
- assert.strictEqual(result.variables[0].key, TEST_VAR_KEY);
255
- assert.strictEqual(result.inputs[0].name, "deploy_strategy");
256
- });
257
- test("get_pipeline_schedule surfaces a 404 as an error", async () => {
258
- const result = await callTool("get_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: MISSING_SCHEDULE_ID }, baseEnv);
259
- assert.strictEqual(result.isError, true);
260
- assert.match(result.content?.[0]?.text ?? "", /404/);
261
- });
262
- test("list_pipeline_schedule_pipelines returns the triggered pipelines", async () => {
263
- const result = await callToolJson("list_pipeline_schedule_pipelines", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: TEST_SCHEDULE_ID }, baseEnv);
264
- assert.ok(Array.isArray(result));
265
- assert.strictEqual(result.length, 1);
266
- assert.strictEqual(result[0].id, "47");
267
- assert.strictEqual(result[0].status, "success");
268
- assert.strictEqual(result[0].source, "schedule");
269
- });
270
- test("list_pipeline_schedule_pipelines forwards the endpoint's filters", async () => {
271
- lastReceivedQuery = undefined;
272
- await callToolJson("list_pipeline_schedule_pipelines", {
273
- project_id: TEST_PROJECT_ID,
274
- pipeline_schedule_id: TEST_SCHEDULE_ID,
275
- scope: "finished",
276
- status: "success",
277
- sort: "desc",
278
- updated_after: "2026-08-01T00:00:00Z",
279
- created_before: "2026-08-31T00:00:00Z",
280
- }, baseEnv);
281
- // Express exposes req.query as a null-prototype object; spread to compare.
282
- assert.deepStrictEqual({ ...capturedQuery() }, {
283
- scope: "finished",
284
- status: "success",
285
- sort: "desc",
286
- updated_after: "2026-08-01T00:00:00Z",
287
- created_before: "2026-08-31T00:00:00Z",
288
- });
289
- });
290
- // --- Mutations ---
291
- test("create_pipeline_schedule posts cron, description and ref", async () => {
292
- lastReceivedBody = undefined;
293
- const text = await callToolText("create_pipeline_schedule", {
294
- project_id: TEST_PROJECT_ID,
295
- description: "Nightly build",
296
- ref: "refs/heads/main",
297
- cron: "0 1 * * *",
298
- cron_timezone: "Asia/Tokyo",
299
- }, baseEnv);
300
- assert.strictEqual(capturedBody()?.cron, "0 1 * * *");
301
- assert.strictEqual(capturedBody()?.description, "Nightly build");
302
- assert.strictEqual(capturedBody()?.ref, "refs/heads/main");
303
- assert.strictEqual(capturedBody()?.cron_timezone, "Asia/Tokyo");
304
- assert.match(text, /Created pipeline schedule #14/);
305
- });
306
- test("create_pipeline_schedule sends active=false rather than coercing it to true", async () => {
307
- lastReceivedBody = undefined;
308
- await callToolText("create_pipeline_schedule", {
309
- project_id: TEST_PROJECT_ID,
310
- description: "Paused weekly deploy",
311
- ref: "main",
312
- cron: "0 3 * * 5",
313
- active: "false",
314
- }, baseEnv);
315
- assert.strictEqual(capturedBody()?.active, false);
316
- });
317
- test("create_pipeline_schedule accepts non-string values inside an input array", async () => {
318
- lastReceivedBody = undefined;
319
- await callToolText("create_pipeline_schedule", {
320
- project_id: TEST_PROJECT_ID,
321
- description: "Nightly build",
322
- ref: "main",
323
- cron: "0 1 * * *",
324
- inputs: [
325
- { name: "retries", value: 3 },
326
- { name: "verbose", value: true },
327
- { name: "targets", value: ["staging", 7, { region: "eu" }] },
328
- ],
329
- }, baseEnv);
330
- assert.deepStrictEqual(capturedBody()?.inputs, [
331
- { name: "retries", value: 3 },
332
- { name: "verbose", value: true },
333
- { name: "targets", value: ["staging", 7, { region: "eu" }] },
334
- ]);
335
- });
336
- test("create_pipeline_schedule rejects a map as a top-level input value", async () => {
337
- await assert.rejects(() => callTool("create_pipeline_schedule", {
338
- project_id: TEST_PROJECT_ID,
339
- description: "Nightly build",
340
- ref: "main",
341
- cron: "0 1 * * *",
342
- inputs: [{ name: "matrix", value: { os: "linux" } }],
343
- }, baseEnv), /invalid|expected|value/i);
344
- });
345
- test("create_pipeline_schedule_variable rejects an invalid key", async () => {
346
- await assert.rejects(() => callTool("create_pipeline_schedule_variable", {
347
- project_id: TEST_PROJECT_ID,
348
- pipeline_schedule_id: TEST_SCHEDULE_ID,
349
- key: "not-a-valid-key",
350
- value: "x",
351
- }, baseEnv), /invalid|regex|pattern|key/i);
352
- });
353
- test("update_pipeline_schedule sends only the supplied fields", async () => {
354
- lastReceivedBody = undefined;
355
- const text = await callToolText("update_pipeline_schedule", {
356
- project_id: TEST_PROJECT_ID,
357
- pipeline_schedule_id: TEST_SCHEDULE_ID,
358
- cron: "0 2 * * *",
359
- }, baseEnv);
360
- assert.deepStrictEqual(capturedBody(), { cron: "0 2 * * *" });
361
- assert.match(text, /Updated pipeline schedule #13/);
362
- assert.match(text, /0 2 \* \* \*/);
363
- });
364
- test("delete_pipeline_schedule reports the removed schedule id", async () => {
365
- const text = await callToolText("delete_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: TEST_SCHEDULE_ID }, baseEnv);
366
- assert.match(text, /Deleted pipeline schedule #13/);
367
- });
368
- test("play_pipeline_schedule reports GitLab's acknowledgement", async () => {
369
- const text = await callToolText("play_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: TEST_SCHEDULE_ID }, baseEnv);
370
- assert.match(text, /Ran pipeline schedule #13/);
371
- assert.match(text, /201 Created/);
372
- assert.match(text, /next scheduled run is unchanged/);
373
- });
374
- test("take_ownership_pipeline_schedule reports the new owner", async () => {
375
- const text = await callToolText("take_ownership_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: TEST_SCHEDULE_ID }, baseEnv);
376
- assert.match(text, /Took ownership of pipeline schedule #13/);
377
- assert.match(text, /ada/);
378
- });
379
- // --- Schedule variables ---
380
- test("get_pipeline_schedule_variable returns the variable", async () => {
381
- const result = await callToolJson("get_pipeline_schedule_variable", {
382
- project_id: TEST_PROJECT_ID,
383
- pipeline_schedule_id: TEST_SCHEDULE_ID,
384
- key: TEST_VAR_KEY,
385
- }, baseEnv);
386
- assert.strictEqual(result.key, TEST_VAR_KEY);
387
- assert.strictEqual(result.value, "staging");
388
- assert.strictEqual(result.variable_type, "env_var");
389
- });
390
- test("create_pipeline_schedule_variable posts key, value and type", async () => {
391
- lastReceivedBody = undefined;
392
- const text = await callToolText("create_pipeline_schedule_variable", {
393
- project_id: TEST_PROJECT_ID,
394
- pipeline_schedule_id: TEST_SCHEDULE_ID,
395
- key: TEST_VAR_KEY,
396
- value: "production",
397
- variable_type: "env_var",
398
- }, baseEnv);
399
- assert.deepStrictEqual(capturedBody(), {
400
- key: TEST_VAR_KEY,
401
- value: "production",
402
- variable_type: "env_var",
403
- });
404
- assert.match(text, new RegExp(`Created variable '${TEST_VAR_KEY}'`));
405
- });
406
- test("update_pipeline_schedule_variable puts the new value", async () => {
407
- lastReceivedBody = undefined;
408
- const text = await callToolText("update_pipeline_schedule_variable", {
409
- project_id: TEST_PROJECT_ID,
410
- pipeline_schedule_id: TEST_SCHEDULE_ID,
411
- key: TEST_VAR_KEY,
412
- value: "production",
413
- }, baseEnv);
414
- assert.deepStrictEqual(capturedBody(), { value: "production" });
415
- assert.match(text, new RegExp(`Updated variable '${TEST_VAR_KEY}'`));
416
- });
417
- test("delete_pipeline_schedule_variable reports the removed key", async () => {
418
- const text = await callToolText("delete_pipeline_schedule_variable", {
419
- project_id: TEST_PROJECT_ID,
420
- pipeline_schedule_id: TEST_SCHEDULE_ID,
421
- key: TEST_VAR_KEY,
422
- }, baseEnv);
423
- assert.match(text, new RegExp(`Deleted variable '${TEST_VAR_KEY}'`));
424
- assert.match(text, /pipeline schedule #13/);
425
- });
426
- // --- Toolset and permission behaviour ---
427
- test("pipeline schedule tools are absent when the pipelines toolset is not activated", async () => {
428
- const names = await listTools({
429
- GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
430
- GITLAB_API_URL: mockApiUrl,
431
- // No GITLAB_TOOLSETS — pipelines is not a default toolset
432
- });
433
- assert.ok(!names.includes("list_pipeline_schedules"), "list should be opt-in");
434
- assert.ok(!names.includes("create_pipeline_schedule"), "create should be opt-in");
435
- assert.ok(!names.includes("delete_pipeline_schedule"), "delete should be opt-in");
436
- });
437
- test("USE_PIPELINE=true exposes the pipeline schedule tools", async () => {
438
- const names = await listTools({
439
- GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
440
- GITLAB_API_URL: mockApiUrl,
441
- USE_PIPELINE: "true",
442
- });
443
- assert.ok(names.includes("list_pipeline_schedules"), "list should be exposed");
444
- assert.ok(names.includes("play_pipeline_schedule"), "play should be exposed");
445
- assert.ok(names.includes("delete_pipeline_schedule_variable"), "variable delete should be exposed");
446
- });
447
- test("write tools are absent from tools/list in read-only mode", async () => {
448
- const names = await listTools({ ...baseEnv, GITLAB_READ_ONLY_MODE: "true" });
449
- for (const name of [
450
- "create_pipeline_schedule",
451
- "update_pipeline_schedule",
452
- "delete_pipeline_schedule",
453
- "play_pipeline_schedule",
454
- "take_ownership_pipeline_schedule",
455
- "create_pipeline_schedule_variable",
456
- "update_pipeline_schedule_variable",
457
- "delete_pipeline_schedule_variable",
458
- ]) {
459
- assert.ok(!names.includes(name), `${name} should be absent in read-only mode`);
460
- }
461
- for (const name of [
462
- "list_pipeline_schedules",
463
- "get_pipeline_schedule",
464
- "list_pipeline_schedule_pipelines",
465
- "get_pipeline_schedule_variable",
466
- ]) {
467
- assert.ok(names.includes(name), `${name} should be present in read-only mode`);
468
- }
469
- });
470
- test("read-only mode rejects a create_pipeline_schedule call", async () => {
471
- await assert.rejects(() => callTool("create_pipeline_schedule", {
472
- project_id: TEST_PROJECT_ID,
473
- description: "Nightly build",
474
- ref: "main",
475
- cron: "0 1 * * *",
476
- }, { ...baseEnv, GITLAB_READ_ONLY_MODE: "true" }), /read-only|not allowed|Unknown tool/i);
477
- });
478
- test("permission mode 'modify' blocks the delete tools", async () => {
479
- const names = await listTools({ ...baseEnv, GITLAB_PERMISSION_MODE: "modify" });
480
- assert.ok(!names.includes("delete_pipeline_schedule"), "delete_pipeline_schedule should be blocked in modify mode");
481
- assert.ok(!names.includes("delete_pipeline_schedule_variable"), "delete_pipeline_schedule_variable should be blocked in modify mode");
482
- assert.ok(names.includes("update_pipeline_schedule"), "update_pipeline_schedule should remain available in modify mode");
483
- });
484
- });
@@ -1,174 +0,0 @@
1
- import { after, before, describe, test } from "node:test";
2
- import assert from "node:assert";
3
- import { spawn } from "child_process";
4
- import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
5
- const MOCK_TOKEN = "glpat-mock-token-protected-branches";
6
- const TEST_PROJECT_ID = "123";
7
- const TEST_BRANCH = "main";
8
- function buildProtectedBranch(overrides = {}) {
9
- return {
10
- id: 1,
11
- name: TEST_BRANCH,
12
- push_access_levels: [
13
- {
14
- access_level: 30,
15
- access_level_description: "Developers + Maintainers",
16
- user_id: null,
17
- group_id: null,
18
- },
19
- ],
20
- merge_access_levels: [
21
- {
22
- access_level: 40,
23
- access_level_description: "Maintainers",
24
- user_id: null,
25
- group_id: null,
26
- },
27
- ],
28
- unprotect_access_levels: [
29
- {
30
- access_level: 60,
31
- access_level_description: "Administrators",
32
- user_id: null,
33
- group_id: null,
34
- },
35
- ],
36
- allow_force_push: false,
37
- code_owner_approval_required: false,
38
- ...overrides,
39
- };
40
- }
41
- async function callTool(toolName, args, env) {
42
- return new Promise((resolve, reject) => {
43
- const proc = spawn("node", ["build/index.js"], {
44
- stdio: ["pipe", "pipe", "pipe"],
45
- env: {
46
- ...process.env,
47
- ...env,
48
- },
49
- });
50
- let output = "";
51
- let errorOutput = "";
52
- proc.stdout?.on("data", (d) => (output += d));
53
- proc.stderr?.on("data", (d) => (errorOutput += d));
54
- proc.on("close", code => {
55
- if (code !== 0) {
56
- reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
57
- return;
58
- }
59
- const line = output.split("\n").find(l => l.startsWith("{"));
60
- if (!line) {
61
- reject(new Error("No JSON output found"));
62
- return;
63
- }
64
- const response = JSON.parse(line);
65
- if (response.error) {
66
- reject(new Error(response.error.message ?? JSON.stringify(response.error)));
67
- return;
68
- }
69
- const content = response.result?.content?.[0]?.text;
70
- if (!content) {
71
- resolve(response.result);
72
- return;
73
- }
74
- try {
75
- resolve(JSON.parse(content));
76
- }
77
- catch {
78
- resolve(content);
79
- }
80
- });
81
- proc.stdin?.end(JSON.stringify({
82
- jsonrpc: "2.0",
83
- id: 1,
84
- method: "tools/call",
85
- params: { name: toolName, arguments: args },
86
- }) + "\n");
87
- });
88
- }
89
- describe("protected branch tools", () => {
90
- let mockGitLab;
91
- let mockGitLabUrl;
92
- before(async () => {
93
- const mockPort = await findMockServerPort();
94
- mockGitLab = new MockGitLabServer({
95
- port: mockPort,
96
- validTokens: [MOCK_TOKEN],
97
- });
98
- mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/protected_branches`, (req, res) => {
99
- assert.strictEqual(req.query.search, "main");
100
- assert.strictEqual(req.query.page, "2");
101
- assert.strictEqual(req.query.per_page, "10");
102
- res.json([buildProtectedBranch()]);
103
- });
104
- mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/protected_branches/${TEST_BRANCH}`, (_req, res) => {
105
- res.json(buildProtectedBranch());
106
- });
107
- mockGitLab.addMockHandler("post", `/projects/${TEST_PROJECT_ID}/protected_branches`, (req, res) => {
108
- assert.deepStrictEqual(req.body, {
109
- name: TEST_BRANCH,
110
- push_access_level: 30,
111
- merge_access_level: 40,
112
- unprotect_access_level: 60,
113
- allow_force_push: false,
114
- code_owner_approval_required: false,
115
- });
116
- res.status(201).json(buildProtectedBranch());
117
- });
118
- mockGitLab.addMockHandler("delete", `/projects/${TEST_PROJECT_ID}/protected_branches/${TEST_BRANCH}`, (_req, res) => {
119
- res.status(204).send();
120
- });
121
- mockGitLab.addMockHandler("put", `/projects/${TEST_PROJECT_ID}`, (req, res) => {
122
- assert.deepStrictEqual(req.body, { default_branch: TEST_BRANCH });
123
- res.json({ id: Number(TEST_PROJECT_ID), default_branch: TEST_BRANCH });
124
- });
125
- await mockGitLab.start();
126
- mockGitLabUrl = mockGitLab.getUrl();
127
- });
128
- after(async () => {
129
- await mockGitLab.stop();
130
- });
131
- const env = () => ({
132
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
133
- GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
134
- GITLAB_TOOLSETS: "branches",
135
- });
136
- test("list_protected_branches forwards search and pagination", async () => {
137
- const result = await callTool("list_protected_branches", { project_id: TEST_PROJECT_ID, search: "main", page: 2, per_page: 10 }, env());
138
- assert.ok(Array.isArray(result));
139
- assert.strictEqual(result[0].name, TEST_BRANCH);
140
- });
141
- test("get_protected_branch parses a single protected branch", async () => {
142
- const result = await callTool("get_protected_branch", { project_id: TEST_PROJECT_ID, branch_name: TEST_BRANCH }, env());
143
- assert.strictEqual(result.name, TEST_BRANCH);
144
- assert.strictEqual(result.allow_force_push, false);
145
- });
146
- test("protect_branch posts normalized branch name, access levels, and false booleans", async () => {
147
- const result = await callTool("protect_branch", {
148
- project_id: TEST_PROJECT_ID,
149
- branch_name: TEST_BRANCH,
150
- push_access_level: "30",
151
- merge_access_level: 40,
152
- unprotect_access_level: 60,
153
- allow_force_push: "false",
154
- code_owner_approval_required: "false",
155
- }, env());
156
- assert.strictEqual(result.name, TEST_BRANCH);
157
- });
158
- test("protect_branch rejects invalid access levels before calling GitLab", async () => {
159
- await assert.rejects(() => callTool("protect_branch", {
160
- project_id: TEST_PROJECT_ID,
161
- branch_name: TEST_BRANCH,
162
- push_access_level: 99,
163
- }, env()), /Access level must be one of/);
164
- });
165
- test("unprotect_branch sends DELETE and returns status", async () => {
166
- const result = await callTool("unprotect_branch", { project_id: TEST_PROJECT_ID, branch_name: TEST_BRANCH }, env());
167
- assert.deepStrictEqual(result, { status: "unprotected", branch: TEST_BRANCH });
168
- });
169
- test("update_default_branch sends the expected PUT body", async () => {
170
- const result = await callTool("update_default_branch", { project_id: TEST_PROJECT_ID, default_branch: TEST_BRANCH }, env());
171
- assert.strictEqual(result.status, "updated");
172
- assert.strictEqual(result.default_branch, TEST_BRANCH);
173
- });
174
- });