@zereight/mcp-gitlab 2.0.30 → 2.0.32

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.
@@ -0,0 +1,366 @@
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-12345";
6
+ const TEST_PROJECT_ID = "123";
7
+ const TEST_DEPLOYMENT_ID = "777";
8
+ const TEST_ENVIRONMENT_ID = "42";
9
+ const TEST_MERGE_REQUEST_IID = "88";
10
+ const TEST_MERGE_REQUEST_SHA = "merge-sha-6870";
11
+ const TEST_DIVERGED_COMMITS_COUNT = 35;
12
+ const TEST_SOURCE_COMMITS_COUNT = 6;
13
+ const TEST_APPROVER_USERNAME = "sergey.kravchenya";
14
+ const TEST_APPROVER_NAME = "Sergey Kravchenya";
15
+ const mrDeploymentsByCreatedAtAsc = Array.from({ length: 12 }, (_, index) => {
16
+ const sequence = index + 1;
17
+ const day = String(sequence).padStart(2, "0");
18
+ return {
19
+ id: `mr-deploy-${sequence}`,
20
+ status: "success",
21
+ ref: "main",
22
+ sha: TEST_MERGE_REQUEST_SHA,
23
+ created_at: `2026-02-${day}T10:00:00.000Z`,
24
+ updated_at: `2026-02-${day}T10:05:00.000Z`,
25
+ environment: {
26
+ id: `mr-env-${sequence}`,
27
+ name: sequence % 2 === 0 ? "prod" : "stage",
28
+ external_url: sequence % 2 === 0 ? "https://api.prod.example.com" : "https://api.stage.example.com",
29
+ },
30
+ deployable: {
31
+ id: `mr-job-${sequence}`,
32
+ name: `Deploy ${sequence}`,
33
+ status: "success",
34
+ stage: "deploy",
35
+ pipeline: {
36
+ id: `mr-pipeline-${sequence}`,
37
+ status: "success",
38
+ ref: "main",
39
+ sha: TEST_MERGE_REQUEST_SHA,
40
+ },
41
+ },
42
+ };
43
+ });
44
+ const mrDeploymentsUnsorted = [
45
+ mrDeploymentsByCreatedAtAsc[4],
46
+ mrDeploymentsByCreatedAtAsc[0],
47
+ mrDeploymentsByCreatedAtAsc[10],
48
+ mrDeploymentsByCreatedAtAsc[2],
49
+ mrDeploymentsByCreatedAtAsc[11],
50
+ mrDeploymentsByCreatedAtAsc[7],
51
+ mrDeploymentsByCreatedAtAsc[1],
52
+ mrDeploymentsByCreatedAtAsc[9],
53
+ mrDeploymentsByCreatedAtAsc[3],
54
+ mrDeploymentsByCreatedAtAsc[6],
55
+ mrDeploymentsByCreatedAtAsc[8],
56
+ mrDeploymentsByCreatedAtAsc[5],
57
+ ];
58
+ async function callTool(toolName, args, env) {
59
+ return new Promise((resolve, reject) => {
60
+ const proc = spawn("node", ["build/index.js"], {
61
+ stdio: ["pipe", "pipe", "pipe"],
62
+ env: {
63
+ ...process.env,
64
+ ...env,
65
+ GITLAB_READ_ONLY_MODE: "true",
66
+ USE_PIPELINE: "true",
67
+ },
68
+ });
69
+ let output = "";
70
+ let errorOutput = "";
71
+ proc.stdout?.on("data", (d) => (output += d));
72
+ proc.stderr?.on("data", (d) => (errorOutput += d));
73
+ proc.on("close", (code) => {
74
+ if (code !== 0) {
75
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
76
+ }
77
+ const line = output.split("\n").find((l) => l.startsWith("{"));
78
+ if (!line) {
79
+ return reject(new Error("No JSON output found"));
80
+ }
81
+ try {
82
+ const response = JSON.parse(line);
83
+ if (response.error) {
84
+ reject(response.error);
85
+ }
86
+ else {
87
+ const content = response.result?.content?.[0]?.text;
88
+ if (content) {
89
+ try {
90
+ resolve(JSON.parse(content));
91
+ }
92
+ catch {
93
+ resolve(content);
94
+ }
95
+ }
96
+ else {
97
+ resolve(response.result);
98
+ }
99
+ }
100
+ }
101
+ catch (e) {
102
+ reject(e);
103
+ }
104
+ });
105
+ proc.stdin?.end(JSON.stringify({
106
+ jsonrpc: "2.0",
107
+ id: 1,
108
+ method: "tools/call",
109
+ params: { name: toolName, arguments: args },
110
+ }) + "\n");
111
+ });
112
+ }
113
+ describe("deployment and environment tools", () => {
114
+ let mockGitLab;
115
+ let mockGitLabUrl;
116
+ before(async () => {
117
+ const mockPort = await findMockServerPort(9300);
118
+ mockGitLab = new MockGitLabServer({
119
+ port: mockPort,
120
+ validTokens: [MOCK_TOKEN],
121
+ });
122
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/deployments`, (req, res) => {
123
+ const querySha = Array.isArray(req.query.sha) ? req.query.sha[0] : req.query.sha;
124
+ if (querySha === TEST_MERGE_REQUEST_SHA) {
125
+ res.json(mrDeploymentsUnsorted);
126
+ return;
127
+ }
128
+ res.json([
129
+ {
130
+ id: TEST_DEPLOYMENT_ID,
131
+ status: "success",
132
+ ref: "master",
133
+ sha: "abc123",
134
+ created_at: "2026-02-20T16:27:59.348Z",
135
+ updated_at: "2026-02-20T16:32:38.235Z",
136
+ environment: { id: TEST_ENVIRONMENT_ID, name: "stage" },
137
+ deployable: {
138
+ id: "11",
139
+ name: "Stage deploy",
140
+ status: "success",
141
+ stage: "deploy",
142
+ pipeline: { id: "190349", status: "success", ref: "master", sha: "abc123" },
143
+ },
144
+ },
145
+ ]);
146
+ });
147
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/merge_requests/${TEST_MERGE_REQUEST_IID}`, (req, res) => {
148
+ res.json({
149
+ id: TEST_MERGE_REQUEST_IID,
150
+ iid: TEST_MERGE_REQUEST_IID,
151
+ project_id: TEST_PROJECT_ID,
152
+ title: "Deployment summary test MR",
153
+ description: "MR used by tests to validate deployment summary enrichment",
154
+ state: "merged",
155
+ author: {
156
+ id: "1",
157
+ username: "test-user",
158
+ name: "Test User",
159
+ },
160
+ source_branch: "feature/deploy-summary",
161
+ target_branch: "main",
162
+ web_url: "https://gitlab.mock/project/123/merge_requests/88",
163
+ created_at: "2026-02-01T10:00:00.000Z",
164
+ updated_at: "2026-02-20T11:00:00.000Z",
165
+ merged_at: "2026-02-20T11:05:00.000Z",
166
+ closed_at: null,
167
+ merge_commit_sha: TEST_MERGE_REQUEST_SHA,
168
+ diverged_commits_count: TEST_DIVERGED_COMMITS_COUNT,
169
+ rebase_in_progress: false,
170
+ });
171
+ });
172
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/merge_requests/${TEST_MERGE_REQUEST_IID}/commits`, (req, res) => {
173
+ const page = Number.parseInt((Array.isArray(req.query.page) ? req.query.page[0] : req.query.page)?.toString() ?? "1", 10);
174
+ if (page > 1) {
175
+ res.set("x-next-page", "");
176
+ res.json([]);
177
+ return;
178
+ }
179
+ res.set("x-next-page", "");
180
+ res.json(Array.from({ length: TEST_SOURCE_COMMITS_COUNT }, (_, index) => ({
181
+ id: `commit-${index + 1}`,
182
+ short_id: `${index + 1}`,
183
+ title: `Commit ${index + 1}`,
184
+ author_name: "Test Author",
185
+ author_email: "author@example.com",
186
+ authored_date: "2026-02-20T10:00:00.000Z",
187
+ committer_name: "Test Committer",
188
+ committer_email: "committer@example.com",
189
+ committed_date: "2026-02-20T10:00:00.000Z",
190
+ web_url: `https://gitlab.mock/project/123/-/commit/${index + 1}`,
191
+ parent_ids: [],
192
+ })));
193
+ });
194
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/merge_requests/${TEST_MERGE_REQUEST_IID}/approval_state`, (_req, res) => {
195
+ res.json({
196
+ approval_rules_overwritten: false,
197
+ rules: [
198
+ {
199
+ id: "101",
200
+ name: "Default rule",
201
+ rule_type: "regular",
202
+ approvals_required: 1,
203
+ approved: true,
204
+ approved_by: [
205
+ {
206
+ id: "35",
207
+ username: TEST_APPROVER_USERNAME,
208
+ name: TEST_APPROVER_NAME,
209
+ state: "active",
210
+ avatar_url: "https://gitlab.mock/uploads/avatar.png",
211
+ web_url: "https://gitlab.mock/sergey.kravchenya",
212
+ },
213
+ ],
214
+ },
215
+ ],
216
+ });
217
+ });
218
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}`, (req, res) => {
219
+ res.json({
220
+ id: TEST_PROJECT_ID,
221
+ path_with_namespace: "test-group/test-project",
222
+ merge_method: "merge",
223
+ });
224
+ });
225
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/deployments/${TEST_DEPLOYMENT_ID}`, (req, res) => {
226
+ res.json({
227
+ id: TEST_DEPLOYMENT_ID,
228
+ status: "success",
229
+ ref: "master",
230
+ sha: "abc123",
231
+ created_at: "2026-02-20T16:27:59.348Z",
232
+ updated_at: "2026-02-20T16:32:38.235Z",
233
+ environment: { id: TEST_ENVIRONMENT_ID, name: "stage" },
234
+ deployable: {
235
+ id: "11",
236
+ name: "Stage deploy",
237
+ status: "success",
238
+ stage: "deploy",
239
+ pipeline: { id: "190349", status: "success", ref: "master", sha: "abc123" },
240
+ },
241
+ });
242
+ });
243
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/environments`, (req, res) => {
244
+ res.json([
245
+ {
246
+ id: TEST_ENVIRONMENT_ID,
247
+ name: "stage",
248
+ slug: "stage",
249
+ state: "available",
250
+ external_url: "https://api.stage.example.com",
251
+ last_deployment: {
252
+ id: TEST_DEPLOYMENT_ID,
253
+ status: "success",
254
+ ref: "master",
255
+ sha: "abc123",
256
+ created_at: "2026-02-20T16:27:59.348Z",
257
+ },
258
+ },
259
+ ]);
260
+ });
261
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/environments/${TEST_ENVIRONMENT_ID}`, (req, res) => {
262
+ res.json({
263
+ id: TEST_ENVIRONMENT_ID,
264
+ name: "stage",
265
+ slug: "stage",
266
+ state: "available",
267
+ external_url: "https://api.stage.example.com",
268
+ last_deployment: {
269
+ id: TEST_DEPLOYMENT_ID,
270
+ status: "success",
271
+ ref: "master",
272
+ sha: "abc123",
273
+ created_at: "2026-02-20T16:27:59.348Z",
274
+ },
275
+ });
276
+ });
277
+ await mockGitLab.start();
278
+ mockGitLabUrl = mockGitLab.getUrl();
279
+ });
280
+ after(async () => {
281
+ await mockGitLab.stop();
282
+ });
283
+ test("list_deployments returns deployment list", async () => {
284
+ const result = await callTool("list_deployments", { project_id: TEST_PROJECT_ID, environment: "stage" }, {
285
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
286
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
287
+ });
288
+ assert.ok(Array.isArray(result), "Response should be an array");
289
+ assert.strictEqual(result.length, 1, "Should return one deployment");
290
+ assert.strictEqual(result[0].id, TEST_DEPLOYMENT_ID);
291
+ assert.strictEqual(result[0].environment.name, "stage");
292
+ });
293
+ test("get_deployment returns one deployment", async () => {
294
+ const result = await callTool("get_deployment", { project_id: TEST_PROJECT_ID, deployment_id: TEST_DEPLOYMENT_ID }, {
295
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
296
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
297
+ });
298
+ assert.strictEqual(result.id, TEST_DEPLOYMENT_ID);
299
+ assert.strictEqual(result.ref, "master");
300
+ assert.strictEqual(result.status, "success");
301
+ });
302
+ test("list_environments returns environment list", async () => {
303
+ const result = await callTool("list_environments", { project_id: TEST_PROJECT_ID }, {
304
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
305
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
306
+ });
307
+ assert.ok(Array.isArray(result), "Response should be an array");
308
+ assert.strictEqual(result.length, 1, "Should return one environment");
309
+ assert.strictEqual(result[0].id, TEST_ENVIRONMENT_ID);
310
+ assert.strictEqual(result[0].name, "stage");
311
+ });
312
+ test("get_environment returns one environment", async () => {
313
+ const result = await callTool("get_environment", { project_id: TEST_PROJECT_ID, environment_id: TEST_ENVIRONMENT_ID }, {
314
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
315
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
316
+ });
317
+ assert.strictEqual(result.id, TEST_ENVIRONMENT_ID);
318
+ assert.strictEqual(result.name, "stage");
319
+ assert.strictEqual(result.state, "available");
320
+ });
321
+ test("get_merge_request returns compact deployment summary sorted by created_at desc", async () => {
322
+ const result = await callTool("get_merge_request", { project_id: TEST_PROJECT_ID, merge_request_iid: TEST_MERGE_REQUEST_IID }, {
323
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
324
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
325
+ });
326
+ assert.ok(result.deployment_summary, "deployment_summary should be present");
327
+ assert.strictEqual(result.diverged_commits_count, TEST_DIVERGED_COMMITS_COUNT, "diverged_commits_count should be requested and returned by default");
328
+ assert.strictEqual(result.deployment_summary.lookup_sha, TEST_MERGE_REQUEST_SHA);
329
+ assert.ok(result.commit_addition_summary, "commit_addition_summary should be present");
330
+ assert.strictEqual(result.commit_addition_summary.source_commits_count, TEST_SOURCE_COMMITS_COUNT);
331
+ assert.strictEqual(result.commit_addition_summary.merge_method, "merge");
332
+ assert.strictEqual(result.commit_addition_summary.merge_commit_count, 1);
333
+ assert.strictEqual(result.commit_addition_summary.summary, "6 commits and 1 merge commit will be added to main.");
334
+ assert.ok(result.approval_summary, "approval_summary should be present");
335
+ assert.strictEqual(result.approval_summary.source_endpoint, "approval_state");
336
+ assert.strictEqual(result.approval_summary.approved, true);
337
+ assert.deepStrictEqual(result.approval_summary.approved_by_usernames, [TEST_APPROVER_USERNAME]);
338
+ assert.strictEqual(result.approval_summary.approved_by.length, 1);
339
+ assert.strictEqual(result.approval_summary.approved_by[0].name, TEST_APPROVER_NAME);
340
+ assert.strictEqual(result.deployment_summary.sort, "created_at_desc");
341
+ assert.strictEqual(result.deployment_summary.limit, 10);
342
+ assert.strictEqual(result.deployment_summary.total_count, 12);
343
+ assert.strictEqual(result.deployment_summary.returned_count, 10);
344
+ assert.ok(Array.isArray(result.deployment_summary.records), "records should be an array");
345
+ assert.strictEqual(result.deployment_summary.records.length, 10);
346
+ assert.ok(result.deployment_summary.records.every((record) => record.sha === TEST_MERGE_REQUEST_SHA), "all summary records should match MR sha");
347
+ for (let i = 1; i < result.deployment_summary.records.length; i++) {
348
+ const previousCreatedAt = Date.parse(result.deployment_summary.records[i - 1].created_at);
349
+ const currentCreatedAt = Date.parse(result.deployment_summary.records[i].created_at);
350
+ assert.ok(previousCreatedAt >= currentCreatedAt, "records should be sorted by created_at descending");
351
+ }
352
+ assert.strictEqual(result.deployment_summary.records[0].id, "mr-deploy-12", "latest deployment should be first");
353
+ assert.ok(!result.deployment_summary.records.some((record) => record.id === "mr-deploy-1"), "oldest deployment should be truncated");
354
+ assert.ok(!result.deployment_summary.records.some((record) => record.id === "mr-deploy-2"), "second oldest deployment should be truncated");
355
+ });
356
+ test("get_merge_request always requests diverged_commits_count", async () => {
357
+ const result = await callTool("get_merge_request", {
358
+ project_id: TEST_PROJECT_ID,
359
+ merge_request_iid: TEST_MERGE_REQUEST_IID,
360
+ }, {
361
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
362
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
363
+ });
364
+ assert.strictEqual(result.diverged_commits_count, TEST_DIVERGED_COMMITS_COUNT, "diverged_commits_count should always be included in get_merge_request response");
365
+ });
366
+ });
@@ -0,0 +1,194 @@
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
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ const MOCK_TOKEN = 'glpat-mock-token-12345';
9
+ const TEST_PROJECT_ID = '123';
10
+ const TEST_JOB_ID = '456';
11
+ const TEST_ENCODED_ARTIFACT_PATH = 'reports/report#1.txt';
12
+ // Helper to run an MCP tool via the built server
13
+ async function callTool(toolName, args, env) {
14
+ return new Promise((resolve, reject) => {
15
+ const proc = spawn('node', ['build/index.js'], {
16
+ stdio: ['pipe', 'pipe', 'pipe'],
17
+ env: {
18
+ ...process.env,
19
+ ...env,
20
+ GITLAB_READ_ONLY_MODE: 'true',
21
+ USE_PIPELINE: 'true',
22
+ },
23
+ });
24
+ let output = '';
25
+ let errorOutput = '';
26
+ proc.stdout?.on('data', (d) => (output += d));
27
+ proc.stderr?.on('data', (d) => (errorOutput += d));
28
+ proc.on('close', (code) => {
29
+ if (code !== 0)
30
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
31
+ const line = output.split('\n').find((l) => l.startsWith('{'));
32
+ if (!line)
33
+ return reject(new Error('No JSON output found'));
34
+ try {
35
+ const response = JSON.parse(line);
36
+ if (response.error) {
37
+ reject(response.error);
38
+ }
39
+ else {
40
+ const content = response.result?.content?.[0]?.text;
41
+ if (content) {
42
+ try {
43
+ resolve(JSON.parse(content));
44
+ }
45
+ catch {
46
+ // Not JSON (plain text response)
47
+ resolve(content);
48
+ }
49
+ }
50
+ else {
51
+ resolve(response.result);
52
+ }
53
+ }
54
+ }
55
+ catch (e) {
56
+ reject(e);
57
+ }
58
+ });
59
+ proc.stdin?.end(JSON.stringify({
60
+ jsonrpc: '2.0',
61
+ id: 1,
62
+ method: 'tools/call',
63
+ params: { name: toolName, arguments: args },
64
+ }) + '\n');
65
+ });
66
+ }
67
+ describe('job artifacts tools', () => {
68
+ let mockGitLab;
69
+ let mockGitLabUrl;
70
+ let tmpDir;
71
+ before(async () => {
72
+ const mockPort = await findMockServerPort(9200);
73
+ mockGitLab = new MockGitLabServer({
74
+ port: mockPort,
75
+ validTokens: [MOCK_TOKEN],
76
+ });
77
+ // Add mock handler for artifact tree listing
78
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/${TEST_JOB_ID}/artifacts/tree`, (req, res) => {
79
+ res.json([
80
+ {
81
+ name: 'report.xml',
82
+ path: 'report.xml',
83
+ type: 'file',
84
+ size: 1024,
85
+ mode: '100644',
86
+ },
87
+ {
88
+ name: 'logs',
89
+ path: 'logs',
90
+ type: 'directory',
91
+ mode: '040755',
92
+ },
93
+ {
94
+ name: 'output.log',
95
+ path: 'logs/output.log',
96
+ type: 'file',
97
+ size: 512,
98
+ mode: '100644',
99
+ },
100
+ ]);
101
+ });
102
+ // Add mock handler for downloading the full artifact archive
103
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/${TEST_JOB_ID}/artifacts`, (req, res) => {
104
+ // Return a minimal zip-like binary content for testing
105
+ const fakeZipContent = Buffer.from('PK\x03\x04fake-zip-content-for-testing');
106
+ res.set('Content-Type', 'application/zip');
107
+ res.set('Content-Disposition', `attachment; filename="artifacts_job_${TEST_JOB_ID}.zip"`);
108
+ res.send(fakeZipContent);
109
+ });
110
+ // Add mock handler for getting a single artifact file
111
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/${TEST_JOB_ID}/artifacts/report.xml`, (req, res) => {
112
+ res.set('Content-Type', 'application/xml');
113
+ res.send('<testsuites><testsuite name="unit" tests="5" failures="1"></testsuite></testsuites>');
114
+ });
115
+ // Add mock handler for path that requires URL encoding
116
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/${TEST_JOB_ID}/artifacts/reports/report%231.txt`, (req, res) => {
117
+ res.set('Content-Type', 'text/plain');
118
+ res.send('encoded artifact content');
119
+ });
120
+ // Add mock handler for 404 on non-existent artifact
121
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/999/artifacts/tree`, (req, res) => {
122
+ res.status(404).json({ message: 'Not Found' });
123
+ });
124
+ await mockGitLab.start();
125
+ mockGitLabUrl = mockGitLab.getUrl();
126
+ // Create a temp directory for download tests
127
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitlab-mcp-test-'));
128
+ });
129
+ after(async () => {
130
+ await mockGitLab.stop();
131
+ // Clean up temp directory
132
+ if (tmpDir && fs.existsSync(tmpDir)) {
133
+ fs.rmSync(tmpDir, { recursive: true, force: true });
134
+ }
135
+ });
136
+ test('list_job_artifacts returns artifact entries', async () => {
137
+ const result = await callTool('list_job_artifacts', { project_id: TEST_PROJECT_ID, job_id: TEST_JOB_ID }, {
138
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
139
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
140
+ });
141
+ assert.ok(Array.isArray(result), 'Response should be an array');
142
+ assert.strictEqual(result.length, 3, 'Should return 3 artifact entries');
143
+ assert.strictEqual(result[0].name, 'report.xml');
144
+ assert.strictEqual(result[0].type, 'file');
145
+ assert.strictEqual(result[0].size, 1024);
146
+ assert.strictEqual(result[1].name, 'logs');
147
+ assert.strictEqual(result[1].type, 'directory');
148
+ });
149
+ test('download_job_artifacts saves archive to disk', async () => {
150
+ const result = await callTool('download_job_artifacts', { project_id: TEST_PROJECT_ID, job_id: TEST_JOB_ID, local_path: tmpDir }, {
151
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
152
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
153
+ });
154
+ assert.ok(result.success, 'Download should succeed');
155
+ assert.ok(result.file_path.endsWith('.zip'), 'File should have .zip extension');
156
+ assert.ok(fs.existsSync(result.file_path), `File should exist at ${result.file_path}`);
157
+ const stats = fs.statSync(result.file_path);
158
+ assert.ok(stats.size > 0, 'Downloaded file should not be empty');
159
+ });
160
+ test('download_job_artifacts creates nested destination directories', async () => {
161
+ const nestedLocalPath = path.join(tmpDir, 'artifacts', 'run-42');
162
+ const result = await callTool('download_job_artifacts', { project_id: TEST_PROJECT_ID, job_id: TEST_JOB_ID, local_path: nestedLocalPath }, {
163
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
164
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
165
+ });
166
+ assert.ok(result.success, 'Download should succeed');
167
+ assert.ok(fs.existsSync(result.file_path), `File should exist at ${result.file_path}`);
168
+ assert.ok(fs.existsSync(nestedLocalPath), `Directory should be created at ${nestedLocalPath}`);
169
+ });
170
+ test('get_job_artifact_file returns file content', async () => {
171
+ const result = await callTool('get_job_artifact_file', {
172
+ project_id: TEST_PROJECT_ID,
173
+ job_id: TEST_JOB_ID,
174
+ artifact_path: 'report.xml',
175
+ }, {
176
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
177
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
178
+ });
179
+ assert.ok(typeof result === 'string', 'Response should be a string');
180
+ assert.ok(result.includes('<testsuites>'), 'Should contain XML content');
181
+ assert.ok(result.includes('failures="1"'), 'Should contain failure data');
182
+ });
183
+ test('get_job_artifact_file handles artifact paths with reserved characters', async () => {
184
+ const result = await callTool('get_job_artifact_file', {
185
+ project_id: TEST_PROJECT_ID,
186
+ job_id: TEST_JOB_ID,
187
+ artifact_path: TEST_ENCODED_ARTIFACT_PATH,
188
+ }, {
189
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
190
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
191
+ });
192
+ assert.strictEqual(result, 'encoded artifact content');
193
+ });
194
+ });