@zereight/mcp-gitlab 2.1.51 → 2.1.53
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.
- package/README.ko.md +24 -7
- package/README.md +130 -101
- package/README.zh-CN.md +24 -7
- package/build/index.js +409 -19
- package/build/schemas.js +233 -0
- package/build/test/test-job-artifacts.js +13 -9
- package/build/test/test-list-group-merge-requests.js +165 -0
- package/build/test/test-pipeline-schedules.js +482 -0
- package/build/test/test-toolset-filtering.js +2 -2
- package/build/tools/registry.js +100 -1
- package/build/tools/tool-descriptions.js +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1,482 @@
|
|
|
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
|
+
await assert.rejects(() => callToolJson("get_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: MISSING_SCHEDULE_ID }, baseEnv), /404/);
|
|
259
|
+
});
|
|
260
|
+
test("list_pipeline_schedule_pipelines returns the triggered pipelines", async () => {
|
|
261
|
+
const result = await callToolJson("list_pipeline_schedule_pipelines", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: TEST_SCHEDULE_ID }, baseEnv);
|
|
262
|
+
assert.ok(Array.isArray(result));
|
|
263
|
+
assert.strictEqual(result.length, 1);
|
|
264
|
+
assert.strictEqual(result[0].id, "47");
|
|
265
|
+
assert.strictEqual(result[0].status, "success");
|
|
266
|
+
assert.strictEqual(result[0].source, "schedule");
|
|
267
|
+
});
|
|
268
|
+
test("list_pipeline_schedule_pipelines forwards the endpoint's filters", async () => {
|
|
269
|
+
lastReceivedQuery = undefined;
|
|
270
|
+
await callToolJson("list_pipeline_schedule_pipelines", {
|
|
271
|
+
project_id: TEST_PROJECT_ID,
|
|
272
|
+
pipeline_schedule_id: TEST_SCHEDULE_ID,
|
|
273
|
+
scope: "finished",
|
|
274
|
+
status: "success",
|
|
275
|
+
sort: "desc",
|
|
276
|
+
updated_after: "2026-08-01T00:00:00Z",
|
|
277
|
+
created_before: "2026-08-31T00:00:00Z",
|
|
278
|
+
}, baseEnv);
|
|
279
|
+
// Express exposes req.query as a null-prototype object; spread to compare.
|
|
280
|
+
assert.deepStrictEqual({ ...capturedQuery() }, {
|
|
281
|
+
scope: "finished",
|
|
282
|
+
status: "success",
|
|
283
|
+
sort: "desc",
|
|
284
|
+
updated_after: "2026-08-01T00:00:00Z",
|
|
285
|
+
created_before: "2026-08-31T00:00:00Z",
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
// --- Mutations ---
|
|
289
|
+
test("create_pipeline_schedule posts cron, description and ref", async () => {
|
|
290
|
+
lastReceivedBody = undefined;
|
|
291
|
+
const text = await callToolText("create_pipeline_schedule", {
|
|
292
|
+
project_id: TEST_PROJECT_ID,
|
|
293
|
+
description: "Nightly build",
|
|
294
|
+
ref: "refs/heads/main",
|
|
295
|
+
cron: "0 1 * * *",
|
|
296
|
+
cron_timezone: "Asia/Tokyo",
|
|
297
|
+
}, baseEnv);
|
|
298
|
+
assert.strictEqual(capturedBody()?.cron, "0 1 * * *");
|
|
299
|
+
assert.strictEqual(capturedBody()?.description, "Nightly build");
|
|
300
|
+
assert.strictEqual(capturedBody()?.ref, "refs/heads/main");
|
|
301
|
+
assert.strictEqual(capturedBody()?.cron_timezone, "Asia/Tokyo");
|
|
302
|
+
assert.match(text, /Created pipeline schedule #14/);
|
|
303
|
+
});
|
|
304
|
+
test("create_pipeline_schedule sends active=false rather than coercing it to true", async () => {
|
|
305
|
+
lastReceivedBody = undefined;
|
|
306
|
+
await callToolText("create_pipeline_schedule", {
|
|
307
|
+
project_id: TEST_PROJECT_ID,
|
|
308
|
+
description: "Paused weekly deploy",
|
|
309
|
+
ref: "main",
|
|
310
|
+
cron: "0 3 * * 5",
|
|
311
|
+
active: "false",
|
|
312
|
+
}, baseEnv);
|
|
313
|
+
assert.strictEqual(capturedBody()?.active, false);
|
|
314
|
+
});
|
|
315
|
+
test("create_pipeline_schedule accepts non-string values inside an input array", async () => {
|
|
316
|
+
lastReceivedBody = undefined;
|
|
317
|
+
await callToolText("create_pipeline_schedule", {
|
|
318
|
+
project_id: TEST_PROJECT_ID,
|
|
319
|
+
description: "Nightly build",
|
|
320
|
+
ref: "main",
|
|
321
|
+
cron: "0 1 * * *",
|
|
322
|
+
inputs: [
|
|
323
|
+
{ name: "retries", value: 3 },
|
|
324
|
+
{ name: "verbose", value: true },
|
|
325
|
+
{ name: "targets", value: ["staging", 7, { region: "eu" }] },
|
|
326
|
+
],
|
|
327
|
+
}, baseEnv);
|
|
328
|
+
assert.deepStrictEqual(capturedBody()?.inputs, [
|
|
329
|
+
{ name: "retries", value: 3 },
|
|
330
|
+
{ name: "verbose", value: true },
|
|
331
|
+
{ name: "targets", value: ["staging", 7, { region: "eu" }] },
|
|
332
|
+
]);
|
|
333
|
+
});
|
|
334
|
+
test("create_pipeline_schedule rejects a map as a top-level input value", async () => {
|
|
335
|
+
await assert.rejects(() => callTool("create_pipeline_schedule", {
|
|
336
|
+
project_id: TEST_PROJECT_ID,
|
|
337
|
+
description: "Nightly build",
|
|
338
|
+
ref: "main",
|
|
339
|
+
cron: "0 1 * * *",
|
|
340
|
+
inputs: [{ name: "matrix", value: { os: "linux" } }],
|
|
341
|
+
}, baseEnv), /invalid|expected|value/i);
|
|
342
|
+
});
|
|
343
|
+
test("create_pipeline_schedule_variable rejects an invalid key", async () => {
|
|
344
|
+
await assert.rejects(() => callTool("create_pipeline_schedule_variable", {
|
|
345
|
+
project_id: TEST_PROJECT_ID,
|
|
346
|
+
pipeline_schedule_id: TEST_SCHEDULE_ID,
|
|
347
|
+
key: "not-a-valid-key",
|
|
348
|
+
value: "x",
|
|
349
|
+
}, baseEnv), /invalid|regex|pattern|key/i);
|
|
350
|
+
});
|
|
351
|
+
test("update_pipeline_schedule sends only the supplied fields", async () => {
|
|
352
|
+
lastReceivedBody = undefined;
|
|
353
|
+
const text = await callToolText("update_pipeline_schedule", {
|
|
354
|
+
project_id: TEST_PROJECT_ID,
|
|
355
|
+
pipeline_schedule_id: TEST_SCHEDULE_ID,
|
|
356
|
+
cron: "0 2 * * *",
|
|
357
|
+
}, baseEnv);
|
|
358
|
+
assert.deepStrictEqual(capturedBody(), { cron: "0 2 * * *" });
|
|
359
|
+
assert.match(text, /Updated pipeline schedule #13/);
|
|
360
|
+
assert.match(text, /0 2 \* \* \*/);
|
|
361
|
+
});
|
|
362
|
+
test("delete_pipeline_schedule reports the removed schedule id", async () => {
|
|
363
|
+
const text = await callToolText("delete_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: TEST_SCHEDULE_ID }, baseEnv);
|
|
364
|
+
assert.match(text, /Deleted pipeline schedule #13/);
|
|
365
|
+
});
|
|
366
|
+
test("play_pipeline_schedule reports GitLab's acknowledgement", async () => {
|
|
367
|
+
const text = await callToolText("play_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: TEST_SCHEDULE_ID }, baseEnv);
|
|
368
|
+
assert.match(text, /Ran pipeline schedule #13/);
|
|
369
|
+
assert.match(text, /201 Created/);
|
|
370
|
+
assert.match(text, /next scheduled run is unchanged/);
|
|
371
|
+
});
|
|
372
|
+
test("take_ownership_pipeline_schedule reports the new owner", async () => {
|
|
373
|
+
const text = await callToolText("take_ownership_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: TEST_SCHEDULE_ID }, baseEnv);
|
|
374
|
+
assert.match(text, /Took ownership of pipeline schedule #13/);
|
|
375
|
+
assert.match(text, /ada/);
|
|
376
|
+
});
|
|
377
|
+
// --- Schedule variables ---
|
|
378
|
+
test("get_pipeline_schedule_variable returns the variable", async () => {
|
|
379
|
+
const result = await callToolJson("get_pipeline_schedule_variable", {
|
|
380
|
+
project_id: TEST_PROJECT_ID,
|
|
381
|
+
pipeline_schedule_id: TEST_SCHEDULE_ID,
|
|
382
|
+
key: TEST_VAR_KEY,
|
|
383
|
+
}, baseEnv);
|
|
384
|
+
assert.strictEqual(result.key, TEST_VAR_KEY);
|
|
385
|
+
assert.strictEqual(result.value, "staging");
|
|
386
|
+
assert.strictEqual(result.variable_type, "env_var");
|
|
387
|
+
});
|
|
388
|
+
test("create_pipeline_schedule_variable posts key, value and type", async () => {
|
|
389
|
+
lastReceivedBody = undefined;
|
|
390
|
+
const text = await callToolText("create_pipeline_schedule_variable", {
|
|
391
|
+
project_id: TEST_PROJECT_ID,
|
|
392
|
+
pipeline_schedule_id: TEST_SCHEDULE_ID,
|
|
393
|
+
key: TEST_VAR_KEY,
|
|
394
|
+
value: "production",
|
|
395
|
+
variable_type: "env_var",
|
|
396
|
+
}, baseEnv);
|
|
397
|
+
assert.deepStrictEqual(capturedBody(), {
|
|
398
|
+
key: TEST_VAR_KEY,
|
|
399
|
+
value: "production",
|
|
400
|
+
variable_type: "env_var",
|
|
401
|
+
});
|
|
402
|
+
assert.match(text, new RegExp(`Created variable '${TEST_VAR_KEY}'`));
|
|
403
|
+
});
|
|
404
|
+
test("update_pipeline_schedule_variable puts the new value", async () => {
|
|
405
|
+
lastReceivedBody = undefined;
|
|
406
|
+
const text = await callToolText("update_pipeline_schedule_variable", {
|
|
407
|
+
project_id: TEST_PROJECT_ID,
|
|
408
|
+
pipeline_schedule_id: TEST_SCHEDULE_ID,
|
|
409
|
+
key: TEST_VAR_KEY,
|
|
410
|
+
value: "production",
|
|
411
|
+
}, baseEnv);
|
|
412
|
+
assert.deepStrictEqual(capturedBody(), { value: "production" });
|
|
413
|
+
assert.match(text, new RegExp(`Updated variable '${TEST_VAR_KEY}'`));
|
|
414
|
+
});
|
|
415
|
+
test("delete_pipeline_schedule_variable reports the removed key", async () => {
|
|
416
|
+
const text = await callToolText("delete_pipeline_schedule_variable", {
|
|
417
|
+
project_id: TEST_PROJECT_ID,
|
|
418
|
+
pipeline_schedule_id: TEST_SCHEDULE_ID,
|
|
419
|
+
key: TEST_VAR_KEY,
|
|
420
|
+
}, baseEnv);
|
|
421
|
+
assert.match(text, new RegExp(`Deleted variable '${TEST_VAR_KEY}'`));
|
|
422
|
+
assert.match(text, /pipeline schedule #13/);
|
|
423
|
+
});
|
|
424
|
+
// --- Toolset and permission behaviour ---
|
|
425
|
+
test("pipeline schedule tools are absent when the pipelines toolset is not activated", async () => {
|
|
426
|
+
const names = await listTools({
|
|
427
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
428
|
+
GITLAB_API_URL: mockApiUrl,
|
|
429
|
+
// No GITLAB_TOOLSETS — pipelines is not a default toolset
|
|
430
|
+
});
|
|
431
|
+
assert.ok(!names.includes("list_pipeline_schedules"), "list should be opt-in");
|
|
432
|
+
assert.ok(!names.includes("create_pipeline_schedule"), "create should be opt-in");
|
|
433
|
+
assert.ok(!names.includes("delete_pipeline_schedule"), "delete should be opt-in");
|
|
434
|
+
});
|
|
435
|
+
test("USE_PIPELINE=true exposes the pipeline schedule tools", async () => {
|
|
436
|
+
const names = await listTools({
|
|
437
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
438
|
+
GITLAB_API_URL: mockApiUrl,
|
|
439
|
+
USE_PIPELINE: "true",
|
|
440
|
+
});
|
|
441
|
+
assert.ok(names.includes("list_pipeline_schedules"), "list should be exposed");
|
|
442
|
+
assert.ok(names.includes("play_pipeline_schedule"), "play should be exposed");
|
|
443
|
+
assert.ok(names.includes("delete_pipeline_schedule_variable"), "variable delete should be exposed");
|
|
444
|
+
});
|
|
445
|
+
test("write tools are absent from tools/list in read-only mode", async () => {
|
|
446
|
+
const names = await listTools({ ...baseEnv, GITLAB_READ_ONLY_MODE: "true" });
|
|
447
|
+
for (const name of [
|
|
448
|
+
"create_pipeline_schedule",
|
|
449
|
+
"update_pipeline_schedule",
|
|
450
|
+
"delete_pipeline_schedule",
|
|
451
|
+
"play_pipeline_schedule",
|
|
452
|
+
"take_ownership_pipeline_schedule",
|
|
453
|
+
"create_pipeline_schedule_variable",
|
|
454
|
+
"update_pipeline_schedule_variable",
|
|
455
|
+
"delete_pipeline_schedule_variable",
|
|
456
|
+
]) {
|
|
457
|
+
assert.ok(!names.includes(name), `${name} should be absent in read-only mode`);
|
|
458
|
+
}
|
|
459
|
+
for (const name of [
|
|
460
|
+
"list_pipeline_schedules",
|
|
461
|
+
"get_pipeline_schedule",
|
|
462
|
+
"list_pipeline_schedule_pipelines",
|
|
463
|
+
"get_pipeline_schedule_variable",
|
|
464
|
+
]) {
|
|
465
|
+
assert.ok(names.includes(name), `${name} should be present in read-only mode`);
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
test("read-only mode rejects a create_pipeline_schedule call", async () => {
|
|
469
|
+
await assert.rejects(() => callTool("create_pipeline_schedule", {
|
|
470
|
+
project_id: TEST_PROJECT_ID,
|
|
471
|
+
description: "Nightly build",
|
|
472
|
+
ref: "main",
|
|
473
|
+
cron: "0 1 * * *",
|
|
474
|
+
}, { ...baseEnv, GITLAB_READ_ONLY_MODE: "true" }), /read-only|not allowed|Unknown tool/i);
|
|
475
|
+
});
|
|
476
|
+
test("permission mode 'modify' blocks the delete tools", async () => {
|
|
477
|
+
const names = await listTools({ ...baseEnv, GITLAB_PERMISSION_MODE: "modify" });
|
|
478
|
+
assert.ok(!names.includes("delete_pipeline_schedule"), "delete_pipeline_schedule should be blocked in modify mode");
|
|
479
|
+
assert.ok(!names.includes("delete_pipeline_schedule_variable"), "delete_pipeline_schedule_variable should be blocked in modify mode");
|
|
480
|
+
assert.ok(names.includes("update_pipeline_schedule"), "update_pipeline_schedule should remain available in modify mode");
|
|
481
|
+
});
|
|
482
|
+
});
|
|
@@ -16,14 +16,14 @@ const MOCK_PORT_BASE = 9200;
|
|
|
16
16
|
const MCP_PORT_BASE = 3200;
|
|
17
17
|
// Known tool counts per toolset (from TOOLSET_DEFINITIONS)
|
|
18
18
|
const TOOLSET_TOOL_COUNTS = {
|
|
19
|
-
merge_requests:
|
|
19
|
+
merge_requests: 42,
|
|
20
20
|
issues: 24,
|
|
21
21
|
repositories: 7,
|
|
22
22
|
branches: 15,
|
|
23
23
|
projects: 11,
|
|
24
24
|
labels: 5,
|
|
25
25
|
ci: 4,
|
|
26
|
-
pipelines:
|
|
26
|
+
pipelines: 31,
|
|
27
27
|
milestones: 17,
|
|
28
28
|
wiki: 10,
|
|
29
29
|
releases: 7,
|