@zereight/mcp-gitlab 2.0.28 ā 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.
- package/README.md +45 -5
- package/build/index.js +974 -65
- package/build/oauth.js +16 -4
- package/build/schemas.js +504 -101
- package/build/test/schema-tests.js +311 -0
- package/build/test/test-deployment-tools.js +366 -0
- package/build/test/test-download-attachment.js +144 -0
- package/build/test/test-job-artifacts.js +194 -0
- package/build/test/test-merge-request-approval-state-tools.js +171 -0
- package/build/test/test-toolset-filtering.js +452 -0
- package/package.json +3 -2
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env ts-node
|
|
2
|
+
import { GetFileContentsSchema, GitLabFileContentSchema, CreatePipelineSchema } from '../schemas.js';
|
|
3
|
+
function runGetFileContentsSchemaTests() {
|
|
4
|
+
console.log('š§Ŗ Testing GetFileContentsSchema...');
|
|
5
|
+
const cases = [
|
|
6
|
+
{
|
|
7
|
+
name: 'schema:get_file_contents:path-only',
|
|
8
|
+
input: { path: 'package.json' },
|
|
9
|
+
expected: { file_path: 'package.json', project_id: undefined, ref: undefined }
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
name: 'schema:get_file_contents:file-path-precedence',
|
|
13
|
+
input: { file_path: ' README.md ', path: 'package.json' },
|
|
14
|
+
expected: { file_path: 'README.md', project_id: undefined, ref: undefined }
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: 'schema:get_file_contents:project-id-trim',
|
|
18
|
+
input: { project_id: ' 123 ', file_path: 'a.txt' },
|
|
19
|
+
expected: { project_id: '123', file_path: 'a.txt', ref: undefined }
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'schema:get_file_contents:ref-trim-to-undefined',
|
|
23
|
+
input: { file_path: 'a.txt', ref: ' ' },
|
|
24
|
+
expected: { file_path: 'a.txt', project_id: undefined, ref: undefined }
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'schema:get_file_contents:file-path-empty-fallback-to-path',
|
|
28
|
+
input: { file_path: ' ', path: ' src/index.ts ' },
|
|
29
|
+
expected: { file_path: 'src/index.ts', project_id: undefined, ref: undefined }
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'schema:get_file_contents:project-id-omitted-remains-undefined',
|
|
33
|
+
input: { file_path: 'README.md' },
|
|
34
|
+
expected: { file_path: 'README.md', project_id: undefined, ref: undefined }
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'schema:get_file_contents:reject-empty-path',
|
|
38
|
+
input: { path: ' ' },
|
|
39
|
+
shouldFail: true
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'schema:get_file_contents:reject-both-empty-after-trim',
|
|
43
|
+
input: { file_path: ' ', path: ' ' },
|
|
44
|
+
shouldFail: true
|
|
45
|
+
}
|
|
46
|
+
];
|
|
47
|
+
let passed = 0;
|
|
48
|
+
let failed = 0;
|
|
49
|
+
cases.forEach(testCase => {
|
|
50
|
+
const result = {
|
|
51
|
+
name: testCase.name,
|
|
52
|
+
status: 'failed'
|
|
53
|
+
};
|
|
54
|
+
const parsed = GetFileContentsSchema.safeParse(testCase.input);
|
|
55
|
+
if (testCase.shouldFail) {
|
|
56
|
+
if (parsed.success) {
|
|
57
|
+
result.error = 'Expected schema validation to fail';
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
result.status = 'passed';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else if (parsed.success) {
|
|
64
|
+
const { project_id, file_path, ref } = parsed.data;
|
|
65
|
+
const expected = testCase.expected || {};
|
|
66
|
+
const matches = project_id === expected.project_id && file_path === expected.file_path && ref === expected.ref;
|
|
67
|
+
if (matches) {
|
|
68
|
+
result.status = 'passed';
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
result.error = `Unexpected parsed result: ${JSON.stringify(parsed.data)}`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
result.error = parsed.error?.message || 'Schema validation failed';
|
|
76
|
+
}
|
|
77
|
+
if (result.status === 'passed') {
|
|
78
|
+
passed++;
|
|
79
|
+
console.log(`ā
${result.name}`);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
failed++;
|
|
83
|
+
console.log(`ā ${result.name}: ${result.error}`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
87
|
+
return { passed, failed };
|
|
88
|
+
}
|
|
89
|
+
function runGitLabFileContentSchemaTests() {
|
|
90
|
+
console.log('\nš§Ŗ Testing GitLabFileContentSchema...');
|
|
91
|
+
const cases = [
|
|
92
|
+
{
|
|
93
|
+
name: 'schema:gitlab_file_content:minimal-required-fields',
|
|
94
|
+
input: {
|
|
95
|
+
file_path: 'README.md',
|
|
96
|
+
encoding: 'base64',
|
|
97
|
+
content: 'IyBSRUFETUU='
|
|
98
|
+
},
|
|
99
|
+
expected: {
|
|
100
|
+
file_path: 'README.md',
|
|
101
|
+
encoding: 'base64',
|
|
102
|
+
content: 'IyBSRUFETUU='
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: 'schema:gitlab_file_content:optional-size-coerces-to-number',
|
|
107
|
+
input: {
|
|
108
|
+
file_name: 'README.md',
|
|
109
|
+
file_path: 'README.md',
|
|
110
|
+
size: '42',
|
|
111
|
+
encoding: 'base64',
|
|
112
|
+
content: 'IyBSRUFETUU=',
|
|
113
|
+
ref: 'main'
|
|
114
|
+
},
|
|
115
|
+
expected: {
|
|
116
|
+
file_name: 'README.md',
|
|
117
|
+
file_path: 'README.md',
|
|
118
|
+
size: 42,
|
|
119
|
+
encoding: 'base64',
|
|
120
|
+
content: 'IyBSRUFETUU=',
|
|
121
|
+
ref: 'main'
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'schema:gitlab_file_content:reject-missing-content',
|
|
126
|
+
input: {
|
|
127
|
+
file_path: 'README.md',
|
|
128
|
+
encoding: 'base64'
|
|
129
|
+
},
|
|
130
|
+
shouldFail: true
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: 'schema:gitlab_file_content:reject-missing-encoding',
|
|
134
|
+
input: {
|
|
135
|
+
file_path: 'README.md',
|
|
136
|
+
content: 'IyBSRUFETUU='
|
|
137
|
+
},
|
|
138
|
+
shouldFail: true
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'schema:gitlab_file_content:reject-missing-file-path',
|
|
142
|
+
input: {
|
|
143
|
+
encoding: 'base64',
|
|
144
|
+
content: 'IyBSRUFETUU='
|
|
145
|
+
},
|
|
146
|
+
shouldFail: true
|
|
147
|
+
}
|
|
148
|
+
];
|
|
149
|
+
let passed = 0;
|
|
150
|
+
let failed = 0;
|
|
151
|
+
cases.forEach(testCase => {
|
|
152
|
+
const result = {
|
|
153
|
+
name: testCase.name,
|
|
154
|
+
status: 'failed'
|
|
155
|
+
};
|
|
156
|
+
const parsed = GitLabFileContentSchema.safeParse(testCase.input);
|
|
157
|
+
if (testCase.shouldFail) {
|
|
158
|
+
if (parsed.success) {
|
|
159
|
+
result.error = 'Expected schema validation to fail';
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
result.status = 'passed';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else if (parsed.success) {
|
|
166
|
+
const expected = testCase.expected || {};
|
|
167
|
+
const matches = Object.entries(expected).every(([key, value]) => {
|
|
168
|
+
return parsed.data[key] === value;
|
|
169
|
+
});
|
|
170
|
+
if (matches) {
|
|
171
|
+
result.status = 'passed';
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
result.error = `Unexpected parsed result: ${JSON.stringify(parsed.data)}`;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
result.error = parsed.error?.message || 'Schema validation failed';
|
|
179
|
+
}
|
|
180
|
+
if (result.status === 'passed') {
|
|
181
|
+
passed++;
|
|
182
|
+
console.log(`ā
${result.name}`);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
failed++;
|
|
186
|
+
console.log(`ā ${result.name}: ${result.error}`);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
190
|
+
return { passed, failed };
|
|
191
|
+
}
|
|
192
|
+
function runCreatePipelineSchemaTests() {
|
|
193
|
+
console.log('\nš§Ŗ Testing CreatePipelineSchema...');
|
|
194
|
+
const cases = [
|
|
195
|
+
{
|
|
196
|
+
name: 'schema:create_pipeline:minimal-required-fields',
|
|
197
|
+
input: { project_id: 'my/project', ref: 'main' },
|
|
198
|
+
expected: { project_id: 'my/project', ref: 'main' }
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: 'schema:create_pipeline:with-variables-only',
|
|
202
|
+
input: {
|
|
203
|
+
project_id: 'my/project',
|
|
204
|
+
ref: 'main',
|
|
205
|
+
variables: [{ key: 'ENV', value: 'production' }]
|
|
206
|
+
},
|
|
207
|
+
expected: {
|
|
208
|
+
project_id: 'my/project',
|
|
209
|
+
ref: 'main',
|
|
210
|
+
variables: [{ key: 'ENV', value: 'production' }]
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: 'schema:create_pipeline:with-inputs-only',
|
|
215
|
+
input: {
|
|
216
|
+
project_id: 'my/project',
|
|
217
|
+
ref: 'main',
|
|
218
|
+
inputs: { deploy_target: 'staging', version: '1.0.0' }
|
|
219
|
+
},
|
|
220
|
+
expected: {
|
|
221
|
+
project_id: 'my/project',
|
|
222
|
+
ref: 'main',
|
|
223
|
+
inputs: { deploy_target: 'staging', version: '1.0.0' }
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: 'schema:create_pipeline:with-variables-and-inputs',
|
|
228
|
+
input: {
|
|
229
|
+
project_id: 'my/project',
|
|
230
|
+
ref: 'develop',
|
|
231
|
+
variables: [{ key: 'CI', value: 'true' }],
|
|
232
|
+
inputs: { env: 'test' }
|
|
233
|
+
},
|
|
234
|
+
expected: {
|
|
235
|
+
project_id: 'my/project',
|
|
236
|
+
ref: 'develop',
|
|
237
|
+
variables: [{ key: 'CI', value: 'true' }],
|
|
238
|
+
inputs: { env: 'test' }
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: 'schema:create_pipeline:project-id-coercion',
|
|
243
|
+
input: { project_id: 123, ref: 'main' },
|
|
244
|
+
expected: { project_id: '123', ref: 'main' }
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: 'schema:create_pipeline:reject-missing-ref',
|
|
248
|
+
input: { project_id: 'my/project' },
|
|
249
|
+
shouldFail: true
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: 'schema:create_pipeline:reject-invalid-inputs-type',
|
|
253
|
+
input: { project_id: 'my/project', ref: 'main', inputs: 'not-an-object' },
|
|
254
|
+
shouldFail: true
|
|
255
|
+
}
|
|
256
|
+
];
|
|
257
|
+
let passed = 0;
|
|
258
|
+
let failed = 0;
|
|
259
|
+
cases.forEach(testCase => {
|
|
260
|
+
const result = {
|
|
261
|
+
name: testCase.name,
|
|
262
|
+
status: 'failed'
|
|
263
|
+
};
|
|
264
|
+
const parsed = CreatePipelineSchema.safeParse(testCase.input);
|
|
265
|
+
if (testCase.shouldFail) {
|
|
266
|
+
if (parsed.success) {
|
|
267
|
+
result.error = 'Expected schema validation to fail';
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
result.status = 'passed';
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else if (parsed.success) {
|
|
274
|
+
const expected = testCase.expected || {};
|
|
275
|
+
const matches = Object.entries(expected).every(([key, value]) => {
|
|
276
|
+
const actual = parsed.data[key];
|
|
277
|
+
return JSON.stringify(actual) === JSON.stringify(value);
|
|
278
|
+
});
|
|
279
|
+
if (matches) {
|
|
280
|
+
result.status = 'passed';
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
result.error = `Unexpected parsed result: ${JSON.stringify(parsed.data)}`;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
result.error = parsed.error?.message || 'Schema validation failed';
|
|
288
|
+
}
|
|
289
|
+
if (result.status === 'passed') {
|
|
290
|
+
passed++;
|
|
291
|
+
console.log(`ā
${result.name}`);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
failed++;
|
|
295
|
+
console.log(`ā ${result.name}: ${result.error}`);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
299
|
+
return { passed, failed };
|
|
300
|
+
}
|
|
301
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
302
|
+
const getFileContentsResult = runGetFileContentsSchemaTests();
|
|
303
|
+
const fileContentResult = runGitLabFileContentSchemaTests();
|
|
304
|
+
const createPipelineResult = runCreatePipelineSchemaTests();
|
|
305
|
+
const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed;
|
|
306
|
+
const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed;
|
|
307
|
+
console.log(`\nTotal Results: ${totalPassed} passed, ${totalFailed} failed`);
|
|
308
|
+
if (totalFailed > 0) {
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -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
|
+
});
|