@zereight/mcp-gitlab 2.0.33 → 2.0.35

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,138 @@
1
+ /**
2
+ * NO_PROXY Test Suite
3
+ * Tests NO_PROXY pattern matching and proxy bypass functionality
4
+ */
5
+ import { describe, test } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { GitLabClientPool } from '../gitlab-client-pool.js';
8
+ console.log('🚫 NO_PROXY Test Suite');
9
+ console.log('');
10
+ describe('NO_PROXY Pattern Matching', () => {
11
+ test('should bypass proxy for exact hostname match', () => {
12
+ const pool = new GitLabClientPool({
13
+ httpProxy: 'http://proxy.example.com:8080',
14
+ httpsProxy: 'http://proxy.example.com:8080',
15
+ noProxy: 'gitlab.internal.com',
16
+ });
17
+ // Create agent for the NO_PROXY matched host
18
+ const agent = pool.getOrCreateAgentForUrl('https://gitlab.internal.com/api/v4');
19
+ // The agent should NOT be a proxy agent
20
+ // It should be a standard HTTPS agent
21
+ assert.ok(agent, 'Agent should be created');
22
+ assert.strictEqual(agent.constructor.name, 'Agent', 'Should be a standard Agent, not a proxy agent');
23
+ });
24
+ test('should use proxy for non-matching hostname', () => {
25
+ const pool = new GitLabClientPool({
26
+ httpProxy: 'http://proxy.example.com:8080',
27
+ httpsProxy: 'http://proxy.example.com:8080',
28
+ noProxy: 'gitlab.internal.com',
29
+ });
30
+ // Create agent for a host that should use proxy
31
+ const agent = pool.getOrCreateAgentForUrl('https://gitlab.external.com/api/v4');
32
+ // The agent should be a proxy agent
33
+ assert.ok(agent, 'Agent should be created');
34
+ assert.notStrictEqual(agent.constructor.name, 'Agent', 'Should be a proxy agent, not standard Agent');
35
+ });
36
+ test('should bypass proxy for domain suffix match', () => {
37
+ const pool = new GitLabClientPool({
38
+ httpProxy: 'http://proxy.example.com:8080',
39
+ httpsProxy: 'http://proxy.example.com:8080',
40
+ noProxy: '.internal.com',
41
+ });
42
+ // Test multiple subdomains
43
+ const agent1 = pool.getOrCreateAgentForUrl('https://gitlab.internal.com/api/v4');
44
+ const agent2 = pool.getOrCreateAgentForUrl('https://api.internal.com/api/v4');
45
+ const agent3 = pool.getOrCreateAgentForUrl('https://dev.gitlab.internal.com/api/v4');
46
+ assert.strictEqual(agent1.constructor.name, 'Agent', 'gitlab.internal.com should bypass proxy');
47
+ assert.strictEqual(agent2.constructor.name, 'Agent', 'api.internal.com should bypass proxy');
48
+ assert.strictEqual(agent3.constructor.name, 'Agent', 'dev.gitlab.internal.com should bypass proxy');
49
+ });
50
+ test('should bypass proxy for localhost', () => {
51
+ const pool = new GitLabClientPool({
52
+ httpProxy: 'http://proxy.example.com:8080',
53
+ httpsProxy: 'http://proxy.example.com:8080',
54
+ noProxy: 'localhost,127.0.0.1',
55
+ });
56
+ const agent1 = pool.getOrCreateAgentForUrl('http://localhost:8080/api/v4');
57
+ const agent2 = pool.getOrCreateAgentForUrl('http://127.0.0.1:8080/api/v4');
58
+ assert.strictEqual(agent1.constructor.name, 'Agent', 'localhost should bypass proxy');
59
+ assert.strictEqual(agent2.constructor.name, 'Agent', '127.0.0.1 should bypass proxy');
60
+ });
61
+ test('should bypass proxy for wildcard pattern', () => {
62
+ const pool = new GitLabClientPool({
63
+ httpProxy: 'http://proxy.example.com:8080',
64
+ httpsProxy: 'http://proxy.example.com:8080',
65
+ noProxy: '*',
66
+ });
67
+ const agent = pool.getOrCreateAgentForUrl('https://gitlab.com/api/v4');
68
+ assert.strictEqual(agent.constructor.name, 'Agent', 'Wildcard should bypass all proxies');
69
+ });
70
+ test('should handle multiple NO_PROXY patterns', () => {
71
+ const pool = new GitLabClientPool({
72
+ httpProxy: 'http://proxy.example.com:8080',
73
+ httpsProxy: 'http://proxy.example.com:8080',
74
+ noProxy: 'localhost,.internal.com,192.168.1.1',
75
+ });
76
+ const agent1 = pool.getOrCreateAgentForUrl('http://localhost/api/v4');
77
+ const agent2 = pool.getOrCreateAgentForUrl('https://gitlab.internal.com/api/v4');
78
+ const agent3 = pool.getOrCreateAgentForUrl('http://192.168.1.1/api/v4');
79
+ const agent4 = pool.getOrCreateAgentForUrl('https://gitlab.com/api/v4');
80
+ assert.strictEqual(agent1.constructor.name, 'Agent', 'localhost should bypass proxy');
81
+ assert.strictEqual(agent2.constructor.name, 'Agent', '.internal.com should bypass proxy');
82
+ assert.strictEqual(agent3.constructor.name, 'Agent', '192.168.1.1 should bypass proxy');
83
+ assert.notStrictEqual(agent4.constructor.name, 'Agent', 'gitlab.com should use proxy');
84
+ });
85
+ test('should handle NO_PROXY with whitespace', () => {
86
+ const pool = new GitLabClientPool({
87
+ httpProxy: 'http://proxy.example.com:8080',
88
+ httpsProxy: 'http://proxy.example.com:8080',
89
+ noProxy: ' localhost , .internal.com , 192.168.1.1 ',
90
+ });
91
+ const agent1 = pool.getOrCreateAgentForUrl('http://localhost/api/v4');
92
+ const agent2 = pool.getOrCreateAgentForUrl('https://gitlab.internal.com/api/v4');
93
+ assert.strictEqual(agent1.constructor.name, 'Agent', 'Should handle whitespace in NO_PROXY');
94
+ assert.strictEqual(agent2.constructor.name, 'Agent', 'Should handle whitespace in NO_PROXY');
95
+ });
96
+ test('should work without NO_PROXY set', () => {
97
+ const pool = new GitLabClientPool({
98
+ httpProxy: 'http://proxy.example.com:8080',
99
+ httpsProxy: 'http://proxy.example.com:8080',
100
+ });
101
+ const agent = pool.getOrCreateAgentForUrl('https://gitlab.com/api/v4');
102
+ // Should use proxy when NO_PROXY is not set
103
+ assert.notStrictEqual(agent.constructor.name, 'Agent', 'Should use proxy when NO_PROXY is not set');
104
+ });
105
+ test('should work with NO_PROXY set but empty', () => {
106
+ const pool = new GitLabClientPool({
107
+ httpProxy: 'http://proxy.example.com:8080',
108
+ httpsProxy: 'http://proxy.example.com:8080',
109
+ noProxy: '',
110
+ });
111
+ const agent = pool.getOrCreateAgentForUrl('https://gitlab.com/api/v4');
112
+ // Should use proxy when NO_PROXY is empty
113
+ assert.notStrictEqual(agent.constructor.name, 'Agent', 'Should use proxy when NO_PROXY is empty');
114
+ });
115
+ test('should handle port-specific patterns', () => {
116
+ const pool = new GitLabClientPool({
117
+ httpProxy: 'http://proxy.example.com:8080',
118
+ httpsProxy: 'http://proxy.example.com:8080',
119
+ noProxy: 'gitlab.internal.com:443',
120
+ });
121
+ const agent1 = pool.getOrCreateAgentForUrl('https://gitlab.internal.com/api/v4'); // HTTPS uses port 443 by default
122
+ const agent2 = pool.getOrCreateAgentForUrl('http://gitlab.internal.com/api/v4'); // HTTP uses port 80 by default
123
+ assert.strictEqual(agent1.constructor.name, 'Agent', 'Should bypass proxy for matching port');
124
+ assert.notStrictEqual(agent2.constructor.name, 'Agent', 'Should use proxy for non-matching port');
125
+ });
126
+ test('should handle case-insensitive matching', () => {
127
+ const pool = new GitLabClientPool({
128
+ httpProxy: 'http://proxy.example.com:8080',
129
+ httpsProxy: 'http://proxy.example.com:8080',
130
+ noProxy: 'GitLab.Internal.COM',
131
+ });
132
+ const agent1 = pool.getOrCreateAgentForUrl('https://gitlab.internal.com/api/v4');
133
+ const agent2 = pool.getOrCreateAgentForUrl('https://GITLAB.INTERNAL.COM/api/v4');
134
+ assert.strictEqual(agent1.constructor.name, 'Agent', 'Should match case-insensitively (lowercase)');
135
+ assert.strictEqual(agent2.constructor.name, 'Agent', 'Should match case-insensitively (uppercase)');
136
+ });
137
+ });
138
+ console.log('✅ NO_PROXY tests completed');
@@ -9,6 +9,7 @@ import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server
9
9
  import { CustomHeaderClient } from './clients/custom-header-client.js';
10
10
  // Test constants
11
11
  const MOCK_TOKEN = 'glpat-mock-token-12345';
12
+ const MOCK_JOB_TOKEN = 'glcbt-mock-job-token-9876';
12
13
  const TEST_PROJECT_ID = '123';
13
14
  // Port ranges to avoid collisions
14
15
  const MOCK_GITLAB_PORT_BASE = 9000;
@@ -32,7 +33,7 @@ describe('Remote Authorization - Basic Functionality', () => {
32
33
  const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
33
34
  mockGitLab = new MockGitLabServer({
34
35
  port: mockPort,
35
- validTokens: [MOCK_TOKEN]
36
+ validTokens: [MOCK_TOKEN, MOCK_JOB_TOKEN],
36
37
  });
37
38
  await mockGitLab.start();
38
39
  const mockGitLabUrl = mockGitLab.getUrl();
@@ -80,6 +81,16 @@ describe('Remote Authorization - Basic Functionality', () => {
80
81
  console.log(` ✓ Connected with Private-Token, got ${tools.tools.length} tools`);
81
82
  await client.disconnect();
82
83
  });
84
+ test('should connect with JOB-TOKEN header', async () => {
85
+ const client = new CustomHeaderClient({
86
+ 'job-token': MOCK_JOB_TOKEN,
87
+ });
88
+ await client.connect(mcpUrl);
89
+ const tools = await client.listTools();
90
+ assert.ok(tools.tools.length > 0, 'Should have tools');
91
+ console.log(` ✓ Connected with JOB-TOKEN, got ${tools.tools.length} tools`);
92
+ await client.disconnect();
93
+ });
83
94
  test('should successfully call listTools with auth', async () => {
84
95
  const client = new CustomHeaderClient({
85
96
  'authorization': `Bearer ${MOCK_TOKEN}`
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Test suite for getEffectiveProjectId function
3
+ * Tests the behavior of project ID resolution with different environment configurations
4
+ */
5
+ import { describe, test, before, after } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST } from './utils/server-launcher.js';
8
+ import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
9
+ import { StreamableHTTPTestClient } from './clients/streamable-http-client.js';
10
+ // Use the same token that will be passed via GITLAB_TOKEN_TEST environment variable
11
+ const MOCK_TOKEN = process.env.GITLAB_TOKEN_TEST || 'glpat-mock-token-12345';
12
+ const DEFAULT_PROJECT_ID = '123';
13
+ const OTHER_PROJECT_ID = '456';
14
+ // Ensure GITLAB_TOKEN_TEST is set for launchServer() validation
15
+ if (!process.env.GITLAB_TOKEN_TEST && !process.env.GITLAB_TOKEN) {
16
+ process.env.GITLAB_TOKEN_TEST = MOCK_TOKEN;
17
+ }
18
+ if (!process.env.TEST_PROJECT_ID) {
19
+ process.env.TEST_PROJECT_ID = DEFAULT_PROJECT_ID;
20
+ }
21
+ console.log('🔍 Testing getEffectiveProjectId functionality');
22
+ console.log('');
23
+ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
24
+ describe('getEffectiveProjectId - No GITLAB_ALLOWED_PROJECT_IDS', () => {
25
+ let mcpUrl;
26
+ let mockGitLab;
27
+ let servers = [];
28
+ let client;
29
+ before(async () => {
30
+ // Start mock GitLab server
31
+ const mockPort = await findMockServerPort(9100);
32
+ mockGitLab = new MockGitLabServer({
33
+ port: mockPort,
34
+ validTokens: [MOCK_TOKEN]
35
+ });
36
+ await mockGitLab.start();
37
+ const mockGitLabUrl = mockGitLab.getUrl();
38
+ // Start MCP server WITHOUT GITLAB_ALLOWED_PROJECT_IDS
39
+ const mcpPort = await findAvailablePort(3100);
40
+ const server = await launchServer({
41
+ mode: TransportMode.STREAMABLE_HTTP,
42
+ port: mcpPort,
43
+ timeout: 5000,
44
+ env: {
45
+ STREAMABLE_HTTP: 'true',
46
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
47
+ GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
48
+ GITLAB_READ_ONLY_MODE: 'true',
49
+ }
50
+ });
51
+ servers.push(server);
52
+ mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
53
+ client = new StreamableHTTPTestClient();
54
+ await client.connect(mcpUrl);
55
+ console.log(`Mock GitLab: ${mockGitLabUrl}`);
56
+ console.log(`MCP Server: ${mcpUrl}`);
57
+ console.log(`Default Project: ${DEFAULT_PROJECT_ID}`);
58
+ });
59
+ after(async () => {
60
+ if (client) {
61
+ await client.disconnect();
62
+ }
63
+ cleanupServers(servers);
64
+ if (mockGitLab) {
65
+ await mockGitLab.stop();
66
+ }
67
+ });
68
+ test('should use GITLAB_PROJECT_ID when no project_id is provided', async () => {
69
+ // Call get_project without specifying project_id
70
+ const result = await client.callTool('get_project', {
71
+ project_id: ''
72
+ });
73
+ assert.ok(result.content, 'Should have content');
74
+ const content = result.content[0];
75
+ assert.ok('text' in content, 'Content should have text');
76
+ const project = JSON.parse(content.text);
77
+ // The mock server should receive a request for the default project
78
+ assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should use GITLAB_PROJECT_ID as default');
79
+ console.log(` ✓ Used default project ${DEFAULT_PROJECT_ID} when no project_id provided`);
80
+ });
81
+ test('should prioritize passed project_id over GITLAB_PROJECT_ID', async () => {
82
+ // Call get_project with a different project_id
83
+ const result = await client.callTool('get_project', {
84
+ project_id: OTHER_PROJECT_ID
85
+ });
86
+ assert.ok(result.content, 'Should have content');
87
+ const content = result.content[0];
88
+ assert.ok('text' in content, 'Content should have text');
89
+ const project = JSON.parse(content.text);
90
+ // Should use the passed project_id, not GITLAB_PROJECT_ID
91
+ assert.strictEqual(project.id.toString(), OTHER_PROJECT_ID, 'Should use passed project_id');
92
+ console.log(` ✓ Used passed project_id ${OTHER_PROJECT_ID} instead of default ${DEFAULT_PROJECT_ID}`);
93
+ });
94
+ });
95
+ describe('getEffectiveProjectId - With single GITLAB_ALLOWED_PROJECT_IDS', () => {
96
+ let mcpUrl;
97
+ let mockGitLab;
98
+ let servers = [];
99
+ let client;
100
+ before(async () => {
101
+ // Start mock GitLab server
102
+ const mockPort = await findMockServerPort(9200);
103
+ mockGitLab = new MockGitLabServer({
104
+ port: mockPort,
105
+ validTokens: [MOCK_TOKEN]
106
+ });
107
+ await mockGitLab.start();
108
+ const mockGitLabUrl = mockGitLab.getUrl();
109
+ // Start MCP server WITH single GITLAB_ALLOWED_PROJECT_IDS
110
+ const mcpPort = await findAvailablePort(3200);
111
+ const server = await launchServer({
112
+ mode: TransportMode.STREAMABLE_HTTP,
113
+ port: mcpPort,
114
+ timeout: 5000,
115
+ env: {
116
+ STREAMABLE_HTTP: 'true',
117
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
118
+ GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
119
+ GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
120
+ GITLAB_READ_ONLY_MODE: 'true',
121
+ }
122
+ });
123
+ servers.push(server);
124
+ mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
125
+ client = new StreamableHTTPTestClient();
126
+ await client.connect(mcpUrl);
127
+ console.log(`Mock GitLab: ${mockGitLabUrl}`);
128
+ console.log(`MCP Server: ${mcpUrl}`);
129
+ console.log(`Allowed Project: ${DEFAULT_PROJECT_ID}`);
130
+ });
131
+ after(async () => {
132
+ if (client) {
133
+ await client.disconnect();
134
+ }
135
+ cleanupServers(servers);
136
+ if (mockGitLab) {
137
+ await mockGitLab.stop();
138
+ }
139
+ });
140
+ test('should use single allowed project as default', async () => {
141
+ const result = await client.callTool('get_project', {
142
+ project_id: ''
143
+ });
144
+ assert.ok(result.content, 'Should have content');
145
+ const content = result.content[0];
146
+ assert.ok('text' in content, 'Content should have text');
147
+ const project = JSON.parse(content.text);
148
+ assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should use allowed project as default');
149
+ console.log(` ✓ Used allowed project ${DEFAULT_PROJECT_ID} as default`);
150
+ });
151
+ test('should reject access to non-allowed project', async () => {
152
+ try {
153
+ await client.callTool('get_project', {
154
+ project_id: OTHER_PROJECT_ID
155
+ });
156
+ assert.fail('Should have rejected access to non-allowed project');
157
+ }
158
+ catch (error) {
159
+ assert.ok(error instanceof Error);
160
+ assert.ok(error.message.includes('Access denied'), 'Should indicate access denied');
161
+ console.log(' ✓ Correctly rejected access to non-allowed project');
162
+ }
163
+ });
164
+ });
165
+ describe('getEffectiveProjectId - With multiple GITLAB_ALLOWED_PROJECT_IDS', () => {
166
+ let mcpUrl;
167
+ let mockGitLab;
168
+ let servers = [];
169
+ let client;
170
+ before(async () => {
171
+ // Start mock GitLab server
172
+ const mockPort = await findMockServerPort(9300);
173
+ mockGitLab = new MockGitLabServer({
174
+ port: mockPort,
175
+ validTokens: [MOCK_TOKEN]
176
+ });
177
+ await mockGitLab.start();
178
+ const mockGitLabUrl = mockGitLab.getUrl();
179
+ // Start MCP server WITH multiple GITLAB_ALLOWED_PROJECT_IDS
180
+ const mcpPort = await findAvailablePort(3300);
181
+ const server = await launchServer({
182
+ mode: TransportMode.STREAMABLE_HTTP,
183
+ port: mcpPort,
184
+ timeout: 5000,
185
+ env: {
186
+ STREAMABLE_HTTP: 'true',
187
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
188
+ GITLAB_ALLOWED_PROJECT_IDS: `${DEFAULT_PROJECT_ID},${OTHER_PROJECT_ID}`,
189
+ GITLAB_READ_ONLY_MODE: 'true',
190
+ }
191
+ });
192
+ servers.push(server);
193
+ mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
194
+ client = new StreamableHTTPTestClient();
195
+ await client.connect(mcpUrl);
196
+ console.log(`Mock GitLab: ${mockGitLabUrl}`);
197
+ console.log(`MCP Server: ${mcpUrl}`);
198
+ console.log(`Allowed Projects: ${DEFAULT_PROJECT_ID},${OTHER_PROJECT_ID}`);
199
+ });
200
+ after(async () => {
201
+ if (client) {
202
+ await client.disconnect();
203
+ }
204
+ cleanupServers(servers);
205
+ if (mockGitLab) {
206
+ await mockGitLab.stop();
207
+ }
208
+ });
209
+ test('should require explicit project_id when multiple projects allowed', async () => {
210
+ try {
211
+ await client.callTool('get_project', {
212
+ project_id: ''
213
+ });
214
+ assert.fail('Should have required explicit project_id');
215
+ }
216
+ catch (error) {
217
+ assert.ok(error instanceof Error);
218
+ assert.ok(error.message.includes('Please specify a project ID'), 'Should require project ID');
219
+ console.log(' ✓ Correctly required explicit project_id');
220
+ }
221
+ });
222
+ test('should allow access to first allowed project', async () => {
223
+ const result = await client.callTool('get_project', {
224
+ project_id: DEFAULT_PROJECT_ID
225
+ });
226
+ assert.ok(result.content, 'Should have content');
227
+ const content = result.content[0];
228
+ assert.ok('text' in content, 'Content should have text');
229
+ const project = JSON.parse(content.text);
230
+ assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should allow first project');
231
+ console.log(` ✓ Allowed access to first project ${DEFAULT_PROJECT_ID}`);
232
+ });
233
+ test('should allow access to second allowed project', async () => {
234
+ const result = await client.callTool('get_project', {
235
+ project_id: OTHER_PROJECT_ID
236
+ });
237
+ assert.ok(result.content, 'Should have content');
238
+ const content = result.content[0];
239
+ assert.ok('text' in content, 'Content should have text');
240
+ const project = JSON.parse(content.text);
241
+ assert.strictEqual(project.id.toString(), OTHER_PROJECT_ID, 'Should allow second project');
242
+ console.log(` ✓ Allowed access to second project ${OTHER_PROJECT_ID}`);
243
+ });
244
+ });
245
+ }); // end wrapper describe
@@ -0,0 +1,251 @@
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_MR_IID = '1';
8
+ // Helper to call list_merge_request_changed_files
9
+ async function callListMergeRequestChangedFiles(args = {}, env) {
10
+ return new Promise((resolve, reject) => {
11
+ const proc = spawn('node', ['build/index.js'], {
12
+ stdio: ['pipe', 'pipe', 'pipe'],
13
+ env: {
14
+ ...process.env,
15
+ ...env,
16
+ GITLAB_READ_ONLY_MODE: 'true'
17
+ }
18
+ });
19
+ let output = '';
20
+ let errorOutput = '';
21
+ proc.stdout?.on('data', d => output += d);
22
+ proc.stderr?.on('data', d => errorOutput += d);
23
+ proc.on('close', (code) => {
24
+ if (code !== 0)
25
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
26
+ // Find the JSON line in stdout
27
+ const line = output.split('\n').find(l => l.startsWith('{'));
28
+ if (!line)
29
+ return reject(new Error('No JSON output found'));
30
+ try {
31
+ const response = JSON.parse(line);
32
+ if (response.error) {
33
+ reject(response.error);
34
+ }
35
+ else {
36
+ // Parse the tool result content
37
+ const content = response.result?.content?.[0]?.text;
38
+ if (content) {
39
+ try {
40
+ resolve(JSON.parse(content));
41
+ }
42
+ catch (e) {
43
+ reject(new Error(`Failed to parse tool output JSON: ${content}`));
44
+ }
45
+ }
46
+ else {
47
+ resolve(response.result);
48
+ }
49
+ }
50
+ }
51
+ catch (e) {
52
+ reject(e);
53
+ }
54
+ });
55
+ proc.stdin?.end(JSON.stringify({
56
+ jsonrpc: "2.0", id: 1, method: "tools/call",
57
+ params: { name: "list_merge_request_changed_files", arguments: args }
58
+ }) + '\n');
59
+ });
60
+ }
61
+ // Helper to call get_merge_request_file_diff
62
+ async function callGetMergeRequestFileDiff(args = {}, env) {
63
+ return new Promise((resolve, reject) => {
64
+ const proc = spawn('node', ['build/index.js'], {
65
+ stdio: ['pipe', 'pipe', 'pipe'],
66
+ env: {
67
+ ...process.env,
68
+ ...env,
69
+ GITLAB_READ_ONLY_MODE: 'true'
70
+ }
71
+ });
72
+ let output = '';
73
+ let errorOutput = '';
74
+ proc.stdout?.on('data', d => output += d);
75
+ proc.stderr?.on('data', d => errorOutput += d);
76
+ proc.on('close', (code) => {
77
+ if (code !== 0)
78
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
79
+ // Find the JSON line in stdout
80
+ const line = output.split('\n').find(l => l.startsWith('{'));
81
+ if (!line)
82
+ return reject(new Error('No JSON output found'));
83
+ try {
84
+ const response = JSON.parse(line);
85
+ if (response.error) {
86
+ reject(response.error);
87
+ }
88
+ else {
89
+ // Parse the tool result content
90
+ const content = response.result?.content?.[0]?.text;
91
+ if (content) {
92
+ try {
93
+ resolve(JSON.parse(content));
94
+ }
95
+ catch (e) {
96
+ reject(new Error(`Failed to parse tool output JSON: ${content}`));
97
+ }
98
+ }
99
+ else {
100
+ resolve(response.result);
101
+ }
102
+ }
103
+ }
104
+ catch (e) {
105
+ reject(e);
106
+ }
107
+ });
108
+ proc.stdin?.end(JSON.stringify({
109
+ jsonrpc: "2.0", id: 1, method: "tools/call",
110
+ params: { name: "get_merge_request_file_diff", arguments: args }
111
+ }) + '\n');
112
+ });
113
+ }
114
+ describe('list_merge_request_changed_files', () => {
115
+ let mockGitLab;
116
+ let mockGitLabUrl;
117
+ before(async () => {
118
+ const mockPort = await findMockServerPort(9150);
119
+ mockGitLab = new MockGitLabServer({
120
+ port: mockPort,
121
+ validTokens: [MOCK_TOKEN]
122
+ });
123
+ await mockGitLab.start();
124
+ mockGitLabUrl = mockGitLab.getUrl();
125
+ });
126
+ after(async () => {
127
+ await mockGitLab.stop();
128
+ });
129
+ test('returns all changed files without filtering', async () => {
130
+ const files = await callListMergeRequestChangedFiles({ project_id: TEST_PROJECT_ID, merge_request_iid: TEST_MR_IID }, {
131
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
132
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
133
+ });
134
+ assert.ok(Array.isArray(files), 'Response should be an array');
135
+ assert.strictEqual(files.length, 4, 'Should return 4 files');
136
+ // Check structure of returned files
137
+ for (const file of files) {
138
+ assert.ok(file.new_path !== undefined, 'Each file should have new_path');
139
+ assert.ok(file.old_path !== undefined, 'Each file should have old_path');
140
+ }
141
+ assert.strictEqual(files[0].new_path, 'src/index.ts');
142
+ assert.strictEqual(files[1].new_path, 'vendor/package/file.js');
143
+ assert.strictEqual(files[2].new_path, 'README.md');
144
+ assert.strictEqual(files[3].new_path, 'package-lock.json');
145
+ });
146
+ test('filters out vendor folder with ^vendor/ pattern', async () => {
147
+ const files = await callListMergeRequestChangedFiles({
148
+ project_id: TEST_PROJECT_ID,
149
+ merge_request_iid: TEST_MR_IID,
150
+ excluded_file_patterns: ['^vendor/']
151
+ }, {
152
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
153
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
154
+ });
155
+ assert.ok(Array.isArray(files), 'Response should be an array');
156
+ assert.strictEqual(files.length, 3, 'Should return 3 files (vendor filtered out)');
157
+ assert.strictEqual(files[0].new_path, 'src/index.ts');
158
+ assert.strictEqual(files[1].new_path, 'README.md');
159
+ assert.strictEqual(files[2].new_path, 'package-lock.json');
160
+ });
161
+ test('filters multiple patterns at once', async () => {
162
+ const files = await callListMergeRequestChangedFiles({
163
+ project_id: TEST_PROJECT_ID,
164
+ merge_request_iid: TEST_MR_IID,
165
+ excluded_file_patterns: ['^vendor/', 'package-lock\\.json']
166
+ }, {
167
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
168
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
169
+ });
170
+ assert.ok(Array.isArray(files), 'Response should be an array');
171
+ assert.strictEqual(files.length, 2, 'Should return 2 files (vendor and package-lock filtered out)');
172
+ assert.strictEqual(files[0].new_path, 'src/index.ts');
173
+ assert.strictEqual(files[1].new_path, 'README.md');
174
+ });
175
+ });
176
+ describe('get_merge_request_file_diff', () => {
177
+ let mockGitLab;
178
+ let mockGitLabUrl;
179
+ before(async () => {
180
+ const mockPort = await findMockServerPort(9200);
181
+ mockGitLab = new MockGitLabServer({
182
+ port: mockPort,
183
+ validTokens: [MOCK_TOKEN]
184
+ });
185
+ await mockGitLab.start();
186
+ mockGitLabUrl = mockGitLab.getUrl();
187
+ });
188
+ after(async () => {
189
+ await mockGitLab.stop();
190
+ });
191
+ test('returns diffs for existing files in single page', async () => {
192
+ // Request only first few files that fit in one page (per_page=20)
193
+ const fileDiff = await callGetMergeRequestFileDiff({
194
+ project_id: TEST_PROJECT_ID,
195
+ merge_request_iid: TEST_MR_IID,
196
+ file_paths: ['src/index.ts', 'README.md']
197
+ }, {
198
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
199
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
200
+ });
201
+ assert.ok(Array.isArray(fileDiff), 'Response should be an array');
202
+ assert.strictEqual(fileDiff.length, 2, 'Should return 2 diff results');
203
+ // Check that we got the correct files
204
+ const paths = fileDiff.map((f) => f.new_path || f.old_path).sort();
205
+ assert.deepStrictEqual(paths, ['README.md', 'src/index.ts'].sort());
206
+ });
207
+ test('handles pagination when result spans multiple pages', async () => {
208
+ // Request more files than fit in one page (we have 15 total, per_page defaults to 20)
209
+ // but let's use a smaller per_page by testing with unidiff param
210
+ const fileDiff = await callGetMergeRequestFileDiff({
211
+ project_id: TEST_PROJECT_ID,
212
+ merge_request_iid: TEST_MR_IID,
213
+ file_paths: [
214
+ 'src/index.ts',
215
+ 'config/settings.yml',
216
+ 'models/user.go'
217
+ ]
218
+ }, {
219
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
220
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
221
+ });
222
+ assert.ok(Array.isArray(fileDiff), 'Response should be an array');
223
+ assert.strictEqual(fileDiff.length, 3, 'Should return 3 diff results');
224
+ });
225
+ test('returns error objects for not-found files', async () => {
226
+ // Request some existing + non-existing files
227
+ const fileDiff = await callGetMergeRequestFileDiff({
228
+ project_id: TEST_PROJECT_ID,
229
+ merge_request_iid: TEST_MR_IID,
230
+ file_paths: ['src/index.ts', 'nonexistent/file.txt', 'also_missing.py']
231
+ }, {
232
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
233
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
234
+ });
235
+ assert.ok(Array.isArray(fileDiff), 'Response should be an array');
236
+ // Should return 3 results: 1 success + 2 errors
237
+ assert.strictEqual(fileDiff.length, 3);
238
+ // Find the error entries
239
+ const errorEntries = fileDiff.filter((f) => f.error !== undefined);
240
+ const successEntries = fileDiff.filter((f) => f.error === undefined);
241
+ assert.strictEqual(errorEntries.length, 2, 'Should have 2 error entries');
242
+ assert.strictEqual(successEntries.length, 1, 'Should have 1 success entry');
243
+ // Verify error messages are helpful
244
+ const errorMsgs = errorEntries.map((e) => e.error);
245
+ assert.ok(errorMsgs.some(msg => msg.includes('nonexistent/file.txt')), 'Error message should mention nonexistent file');
246
+ assert.ok(errorMsgs.some(msg => msg.includes('also_missing.py')), 'Error message should mention other missing file');
247
+ // Check hint is present in at least one error
248
+ const hints = errorEntries.map((e) => e.hint).filter(Boolean);
249
+ assert.ok(hints.length > 0, 'Errors should include hints to check list_merge_request_changed_files');
250
+ });
251
+ });