@zereight/mcp-gitlab 2.0.22 → 2.0.24

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/build/schemas.js CHANGED
@@ -792,12 +792,12 @@ export const GitLabDiscussionNoteSchema = z.object({
792
792
  position: z
793
793
  .object({
794
794
  // Only present for DiffNote
795
- base_sha: z.string().optional(),
796
- start_sha: z.string().optional(),
797
- head_sha: z.string().optional(),
795
+ base_sha: z.string().nullable().optional(),
796
+ start_sha: z.string().nullable().optional(),
797
+ head_sha: z.string().nullable().optional(),
798
798
  old_path: z.string().nullable().optional().describe("File path before change"),
799
799
  new_path: z.string().nullable().optional().describe("File path after change"),
800
- position_type: z.enum(["text", "image", "file"]).optional(),
800
+ position_type: z.enum(["text", "image", "file"]).nullable().optional(),
801
801
  new_line: z
802
802
  .number()
803
803
  .nullable()
@@ -808,11 +808,11 @@ export const GitLabDiscussionNoteSchema = z.object({
808
808
  .nullable()
809
809
  .optional()
810
810
  .describe("Line number in the original file (before changes). Used for deleted lines and context lines. Null for newly added lines."),
811
- line_range: LineRangeSchema.nullable().optional(), // For multi-line diff notes
812
- width: z.number().optional(), // For image diff notes
813
- height: z.number().optional(), // For image diff notes
814
- x: z.number().optional(), // For image diff notes
815
- y: z.number().optional(), // For image diff notes
811
+ line_range: LineRangeSchema.nullable().optional(), // Accept any value for line_range including null
812
+ width: z.number().nullable().optional(), // For image diff notes
813
+ height: z.number().nullable().optional(), // For image diff notes
814
+ x: z.number().nullable().optional(), // For image diff notes
815
+ y: z.number().nullable().optional(), // For image diff notes
816
816
  })
817
817
  .passthrough() // Allow additional fields
818
818
  .optional(),
@@ -1003,7 +1003,7 @@ export const GetBranchDiffsSchema = ProjectParamsSchema.extend({
1003
1003
  excluded_file_patterns: z
1004
1004
  .array(z.string())
1005
1005
  .optional()
1006
- .describe('Array of regex patterns to exclude files from the diff results. Each pattern is a JavaScript-compatible regular expression that matches file paths to ignore. Examples: ["^test/mocks/", "\\.spec\\.ts$", "package-lock\\.json"]'),
1006
+ .describe('Array of regex patterns to exclude files from the diff results. Each pattern is a JavaScript-compatible regular expression that matches file paths to ignore. Examples: ["^vendor/", "^test/mocks/", "\\.spec\\.ts$", "package-lock\\.json"]'),
1007
1007
  });
1008
1008
  export const GetMergeRequestSchema = ProjectParamsSchema.extend({
1009
1009
  merge_request_iid: z.coerce.string().optional().describe("The IID of a merge request"),
@@ -1039,8 +1039,61 @@ export const MergeMergeRequestSchema = ProjectParamsSchema.extend({
1039
1039
  squash_commit_message: z.string().optional().describe("Custom squash commit message"),
1040
1040
  squash: z.boolean().optional().default(false).describe("Squash commits into a single commit when merging"),
1041
1041
  });
1042
+ // Merge Request Approval schemas
1043
+ export const ApproveMergeRequestSchema = ProjectParamsSchema.extend({
1044
+ merge_request_iid: z.coerce.string().describe("The IID of the merge request to approve"),
1045
+ sha: z.string().optional().describe("The HEAD of the merge request. Optional, but used to ensure the merge request hasn't changed since you last reviewed it"),
1046
+ approval_password: z.string().optional().describe("Current user's password. Required if 'Require user re-authentication to approve' is enabled in the project settings"),
1047
+ });
1048
+ export const UnapproveMergeRequestSchema = ProjectParamsSchema.extend({
1049
+ merge_request_iid: z.coerce.string().describe("The IID of the merge request to unapprove"),
1050
+ });
1051
+ // Merge Request Approval State response schema
1052
+ export const GitLabApprovalUserSchema = z.object({
1053
+ id: z.coerce.string(),
1054
+ username: z.string(),
1055
+ name: z.string(),
1056
+ state: z.string(),
1057
+ avatar_url: z.string().nullable().optional(),
1058
+ web_url: z.string(),
1059
+ });
1060
+ export const GitLabApprovalRuleSchema = z.object({
1061
+ id: z.coerce.string(),
1062
+ name: z.string(),
1063
+ rule_type: z.string(),
1064
+ eligible_approvers: z.array(GitLabApprovalUserSchema).optional(),
1065
+ approvals_required: z.number(),
1066
+ users: z.array(GitLabApprovalUserSchema).optional(),
1067
+ groups: z.array(z.object({
1068
+ id: z.coerce.string(),
1069
+ name: z.string(),
1070
+ path: z.string(),
1071
+ full_path: z.string(),
1072
+ avatar_url: z.string().nullable().optional(),
1073
+ web_url: z.string(),
1074
+ })).optional(),
1075
+ contains_hidden_groups: z.boolean().optional(),
1076
+ approved_by: z.array(GitLabApprovalUserSchema).optional(),
1077
+ source_rule: z.object({
1078
+ id: z.coerce.string().optional(),
1079
+ name: z.string().optional(),
1080
+ rule_type: z.string().optional(),
1081
+ }).nullable().optional(),
1082
+ approved: z.boolean().optional(),
1083
+ });
1084
+ export const GitLabMergeRequestApprovalStateSchema = z.object({
1085
+ approval_rules_overwritten: z.boolean().optional(),
1086
+ rules: z.array(GitLabApprovalRuleSchema).optional(),
1087
+ });
1088
+ export const GetMergeRequestApprovalStateSchema = ProjectParamsSchema.extend({
1089
+ merge_request_iid: z.coerce.string().describe("The IID of the merge request"),
1090
+ });
1042
1091
  export const GetMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
1043
1092
  view: z.enum(["inline", "parallel"]).optional().describe("Diff view type"),
1093
+ excluded_file_patterns: z
1094
+ .array(z.string())
1095
+ .optional()
1096
+ .describe('Array of regex patterns to exclude files from the diff results. Each pattern is a JavaScript-compatible regular expression that matches file paths to ignore. Examples: ["^vendor/", "^test/mocks/", "\\.spec\\.ts$", "package-lock\\.json"]'),
1044
1097
  });
1045
1098
  export const ListMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
1046
1099
  page: z.number().optional().describe("Page number for pagination (default: 1)"),
@@ -1049,6 +1102,16 @@ export const ListMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
1049
1102
  .optional()
1050
1103
  .describe("Present diffs in the unified diff format. Default is false. Introduced in GitLab 16.5."),
1051
1104
  });
1105
+ // Merge Request Versions API operation schemas
1106
+ export const ListMergeRequestVersionsSchema = ProjectParamsSchema.extend({
1107
+ merge_request_iid: z.coerce.string().describe("The internal ID of the merge request"),
1108
+ });
1109
+ export const GetMergeRequestVersionSchema = ListMergeRequestVersionsSchema.extend({
1110
+ version_id: z.coerce.string().describe("The ID of the merge request diff version"),
1111
+ unidiff: z.boolean()
1112
+ .optional()
1113
+ .describe("Present diffs in the unified diff format. Default is false. Introduced in GitLab 16.5."),
1114
+ });
1052
1115
  export const CreateNoteSchema = z.object({
1053
1116
  project_id: z.coerce.string().describe("Project ID or namespace/project_path"),
1054
1117
  noteable_type: z
@@ -1418,6 +1481,7 @@ export const MergeRequestThreadPositionCreateSchema = z.object({
1418
1481
  x: z.number().optional().describe("IMAGE DIFFS ONLY: X coordinate on the image (for position_type='image')."),
1419
1482
  y: z.number().optional().describe("IMAGE DIFFS ONLY: Y coordinate on the image (for position_type='image')."),
1420
1483
  });
1484
+ // Schema for creating/sending position to GitLab API (stricter)
1421
1485
  export const MergeRequestThreadPositionSchema = z.object({
1422
1486
  base_sha: z
1423
1487
  .string()
@@ -1454,18 +1518,22 @@ export const MergeRequestThreadPositionSchema = z.object({
1454
1518
  line_range: LineRangeSchema.nullable().optional().describe("MULTILINE COMMENTS: Specify start/end line positions for commenting on multiple lines. Alternative to single old_line/new_line."),
1455
1519
  width: z
1456
1520
  .number()
1521
+ .nullable()
1457
1522
  .optional()
1458
1523
  .describe("IMAGE DIFFS ONLY: Width of the image (for position_type='image')."),
1459
1524
  height: z
1460
1525
  .number()
1526
+ .nullable()
1461
1527
  .optional()
1462
1528
  .describe("IMAGE DIFFS ONLY: Height of the image (for position_type='image')."),
1463
1529
  x: z
1464
1530
  .number()
1531
+ .nullable()
1465
1532
  .optional()
1466
1533
  .describe("IMAGE DIFFS ONLY: X coordinate on the image (for position_type='image')."),
1467
1534
  y: z
1468
1535
  .number()
1536
+ .nullable()
1469
1537
  .optional()
1470
1538
  .describe("IMAGE DIFFS ONLY: Y coordinate on the image (for position_type='image')."),
1471
1539
  });
@@ -1502,7 +1570,7 @@ export const ListDraftNotesSchema = ProjectParamsSchema.extend({
1502
1570
  export const CreateDraftNoteSchema = ProjectParamsSchema.extend({
1503
1571
  merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
1504
1572
  body: z.string().describe("The content of the draft note"),
1505
- position: MergeRequestThreadPositionCreateSchema.optional().describe("Position when creating a diff note"),
1573
+ position: MergeRequestThreadPositionSchema.optional().describe("Position when creating a diff note"),
1506
1574
  resolve_discussion: z.boolean().optional().describe("Whether to resolve the discussion when publishing"),
1507
1575
  });
1508
1576
  // Update draft note schema
@@ -1510,7 +1578,7 @@ export const UpdateDraftNoteSchema = ProjectParamsSchema.extend({
1510
1578
  merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
1511
1579
  draft_note_id: z.coerce.string().describe("The ID of the draft note"),
1512
1580
  body: z.string().optional().describe("The content of the draft note"),
1513
- position: MergeRequestThreadPositionCreateSchema.optional().describe("Position when creating a diff note"),
1581
+ position: MergeRequestThreadPositionSchema.optional().describe("Position when creating a diff note"),
1514
1582
  resolve_discussion: z.boolean().optional().describe("Whether to resolve the discussion when publishing"),
1515
1583
  });
1516
1584
  // Delete draft note schema
@@ -1659,6 +1727,10 @@ export const ListProjectMembersSchema = z.object({
1659
1727
  query: z.string().optional().describe("Search for members by name or username"),
1660
1728
  user_ids: z.array(z.number()).optional().describe("Filter by user IDs"),
1661
1729
  skip_users: z.array(z.number()).optional().describe("User IDs to exclude"),
1730
+ include_inheritance: z
1731
+ .boolean()
1732
+ .optional()
1733
+ .describe("Include inherited members. Defaults to false."),
1662
1734
  per_page: z.number().optional().describe("Number of items per page (default: 20, max: 100)"),
1663
1735
  page: z.number().optional().describe("Page number for pagination (default: 1)"),
1664
1736
  });
@@ -1785,6 +1857,22 @@ export const GetProjectEventsSchema = z.object({
1785
1857
  page: z.number().optional().describe("Returns the specified results page. Default: 1"),
1786
1858
  per_page: z.number().optional().describe("Number of results per page. Default: 20"),
1787
1859
  });
1860
+ // Merge Request Versions schemas - Response schemas based on GitLab API documentation
1861
+ export const GitLabMergeRequestVersionSchema = z.object({
1862
+ id: z.number(),
1863
+ head_commit_sha: z.string(),
1864
+ base_commit_sha: z.string(),
1865
+ start_commit_sha: z.string(),
1866
+ created_at: z.string(),
1867
+ merge_request_id: z.number(),
1868
+ state: z.string(),
1869
+ real_size: z.string(),
1870
+ patch_id_sha: z.string(),
1871
+ });
1872
+ export const GitLabMergeRequestVersionDetailSchema = GitLabMergeRequestVersionSchema.extend({
1873
+ commits: z.array(GitLabCommitSchema),
1874
+ diffs: z.array(GitLabDiffSchema),
1875
+ });
1788
1876
  // GraphQL generic execution schema
1789
1877
  export const ExecuteGraphQLSchema = z.object({
1790
1878
  query: z.string().describe("GraphQL query string"),
@@ -0,0 +1,132 @@
1
+ import { describe, test, before, after, beforeEach } 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 directMembers = [
8
+ {
9
+ id: 1,
10
+ username: 'direct-user',
11
+ name: 'Direct User',
12
+ state: 'active',
13
+ avatar_url: null,
14
+ web_url: 'https://gitlab.mock/users/1',
15
+ access_level: 30,
16
+ created_at: '2024-01-01T00:00:00Z'
17
+ }
18
+ ];
19
+ const inheritedMembers = [
20
+ {
21
+ id: 2,
22
+ username: 'inherited-user',
23
+ name: 'Inherited User',
24
+ state: 'active',
25
+ avatar_url: null,
26
+ web_url: 'https://gitlab.mock/users/2',
27
+ access_level: 20,
28
+ created_at: '2024-01-02T00:00:00Z'
29
+ }
30
+ ];
31
+ async function callListProjectMembers(args, env) {
32
+ return new Promise((resolve, reject) => {
33
+ const proc = spawn('node', ['build/index.js'], {
34
+ stdio: ['pipe', 'pipe', 'pipe'],
35
+ env: {
36
+ ...process.env,
37
+ ...env,
38
+ GITLAB_READ_ONLY_MODE: 'true'
39
+ }
40
+ });
41
+ let output = '';
42
+ let errorOutput = '';
43
+ proc.stdout?.on('data', d => output += d);
44
+ proc.stderr?.on('data', d => errorOutput += d);
45
+ proc.on('close', (code) => {
46
+ if (code !== 0)
47
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
48
+ const line = output.split('\n').find(l => l.startsWith('{'));
49
+ if (!line)
50
+ return reject(new Error('No JSON output found'));
51
+ try {
52
+ const response = JSON.parse(line);
53
+ if (response.error) {
54
+ reject(response.error);
55
+ }
56
+ else {
57
+ const content = response.result?.content?.[0]?.text;
58
+ if (content) {
59
+ try {
60
+ resolve(JSON.parse(content));
61
+ }
62
+ catch (e) {
63
+ reject(new Error(`Failed to parse tool output JSON: ${content}`));
64
+ }
65
+ }
66
+ else {
67
+ resolve(response.result);
68
+ }
69
+ }
70
+ }
71
+ catch (e) {
72
+ reject(e);
73
+ }
74
+ });
75
+ proc.stdin?.end(JSON.stringify({
76
+ jsonrpc: "2.0", id: 1, method: "tools/call",
77
+ params: { name: "list_project_members", arguments: args }
78
+ }) + '\n');
79
+ });
80
+ }
81
+ describe('list_project_members', () => {
82
+ let mockGitLab;
83
+ let mockGitLabUrl;
84
+ let directEndpointHit = false;
85
+ let inheritedEndpointHit = false;
86
+ before(async () => {
87
+ const mockPort = await findMockServerPort(9000);
88
+ mockGitLab = new MockGitLabServer({
89
+ port: mockPort,
90
+ validTokens: [MOCK_TOKEN]
91
+ });
92
+ await mockGitLab.start();
93
+ mockGitLabUrl = mockGitLab.getUrl();
94
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/members`, (req, res) => {
95
+ directEndpointHit = true;
96
+ res.json(directMembers);
97
+ });
98
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/members/all`, (req, res) => {
99
+ inheritedEndpointHit = true;
100
+ res.json([...directMembers, ...inheritedMembers]);
101
+ });
102
+ });
103
+ beforeEach(() => {
104
+ directEndpointHit = false;
105
+ inheritedEndpointHit = false;
106
+ });
107
+ after(async () => {
108
+ await mockGitLab.stop();
109
+ });
110
+ test('lists direct project members', async () => {
111
+ const members = await callListProjectMembers({ project_id: TEST_PROJECT_ID }, {
112
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
113
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
114
+ });
115
+ assert.ok(Array.isArray(members), 'Response should be an array');
116
+ assert.strictEqual(members.length, 1, 'Should return direct members');
117
+ assert.strictEqual(members[0].username, 'direct-user');
118
+ assert.strictEqual(directEndpointHit, true, 'Direct members endpoint should be called');
119
+ assert.strictEqual(inheritedEndpointHit, false, 'Inherited members endpoint should not be called');
120
+ });
121
+ test('lists project members including inheritance', async () => {
122
+ const members = await callListProjectMembers({ project_id: TEST_PROJECT_ID, include_inheritance: true }, {
123
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
124
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
125
+ });
126
+ assert.ok(Array.isArray(members), 'Response should be an array');
127
+ assert.strictEqual(members.length, 2, 'Should return inherited members');
128
+ assert.strictEqual(members[1].username, 'inherited-user');
129
+ assert.strictEqual(inheritedEndpointHit, true, 'Inherited members endpoint should be called');
130
+ assert.strictEqual(directEndpointHit, false, 'Direct members endpoint should not be called');
131
+ });
132
+ });
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env npx ts-node
2
+ /**
3
+ * Test script for merge request approval tools
4
+ *
5
+ * Usage:
6
+ * GITLAB_PERSONAL_ACCESS_TOKEN=<token> GITLAB_PROJECT_ID=<project> npx ts-node test/test-merge-request-approvals.ts
7
+ *
8
+ * Optional: Set MERGE_REQUEST_IID to test a specific merge request
9
+ */
10
+ import { spawn } from "child_process";
11
+ import path from "path";
12
+ import { fileURLToPath } from "url";
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = path.dirname(__filename);
15
+ const GITLAB_TOKEN = process.env.GITLAB_PERSONAL_ACCESS_TOKEN || process.env.GITLAB_TOKEN;
16
+ const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID;
17
+ const GITLAB_API_URL = process.env.GITLAB_API_URL || "https://gitlab.com/api/v4";
18
+ const MERGE_REQUEST_IID = process.env.MERGE_REQUEST_IID;
19
+ async function sendMcpRequest(serverProcess, method, params) {
20
+ return new Promise((resolve, reject) => {
21
+ const request = {
22
+ jsonrpc: "2.0",
23
+ id: Date.now(),
24
+ method,
25
+ params,
26
+ };
27
+ let responseData = "";
28
+ const onData = (data) => {
29
+ responseData += data.toString();
30
+ const lines = responseData.split("\n");
31
+ for (const line of lines) {
32
+ if (line.trim()) {
33
+ try {
34
+ const parsed = JSON.parse(line);
35
+ serverProcess.stdout?.off("data", onData);
36
+ resolve(parsed);
37
+ return;
38
+ }
39
+ catch {
40
+ // Continue accumulating data
41
+ }
42
+ }
43
+ }
44
+ };
45
+ serverProcess.stdout?.on("data", onData);
46
+ serverProcess.stdin?.write(JSON.stringify(request) + "\n");
47
+ setTimeout(() => {
48
+ serverProcess.stdout?.off("data", onData);
49
+ reject(new Error("Request timeout"));
50
+ }, 30000);
51
+ });
52
+ }
53
+ async function runTests() {
54
+ console.log("=== Merge Request Approval Tools Test ===\n");
55
+ if (!GITLAB_TOKEN) {
56
+ console.error("Error: GITLAB_PERSONAL_ACCESS_TOKEN or GITLAB_TOKEN environment variable is required");
57
+ process.exit(1);
58
+ }
59
+ if (!GITLAB_PROJECT_ID) {
60
+ console.error("Error: GITLAB_PROJECT_ID environment variable is required");
61
+ process.exit(1);
62
+ }
63
+ console.log(`GitLab API URL: ${GITLAB_API_URL}`);
64
+ console.log(`Project ID: ${GITLAB_PROJECT_ID}`);
65
+ console.log(`Merge Request IID: ${MERGE_REQUEST_IID || "(will find one)"}\n`);
66
+ // Start the MCP server
67
+ const serverPath = path.join(__dirname, "..", "build", "index.js");
68
+ const serverProcess = spawn("node", [serverPath], {
69
+ env: {
70
+ ...process.env,
71
+ GITLAB_PERSONAL_ACCESS_TOKEN: GITLAB_TOKEN,
72
+ GITLAB_API_URL,
73
+ },
74
+ stdio: ["pipe", "pipe", "pipe"],
75
+ });
76
+ serverProcess.stderr?.on("data", data => {
77
+ const msg = data.toString();
78
+ if (!msg.includes("GitLab MCP Server running")) {
79
+ console.error("Server stderr:", msg);
80
+ }
81
+ });
82
+ // Wait for server to start
83
+ await new Promise(resolve => setTimeout(resolve, 2000));
84
+ try {
85
+ // Initialize the MCP connection
86
+ console.log("1. Initializing MCP connection...");
87
+ await sendMcpRequest(serverProcess, "initialize", {
88
+ protocolVersion: "2024-11-05",
89
+ capabilities: {},
90
+ clientInfo: { name: "test-client", version: "1.0.0" },
91
+ });
92
+ console.log(" ✓ Connected\n");
93
+ // Find a merge request to test with
94
+ let mrIid = MERGE_REQUEST_IID;
95
+ if (!mrIid) {
96
+ console.log("2. Finding an open merge request...");
97
+ const listResponse = await sendMcpRequest(serverProcess, "tools/call", {
98
+ name: "list_merge_requests",
99
+ arguments: {
100
+ project_id: GITLAB_PROJECT_ID,
101
+ state: "opened",
102
+ per_page: 1,
103
+ },
104
+ });
105
+ if (listResponse.error) {
106
+ console.error(" ✗ Error:", listResponse.error.message);
107
+ process.exit(1);
108
+ }
109
+ const mrs = JSON.parse(listResponse.result?.content?.[0]?.text || "[]");
110
+ if (mrs.length === 0) {
111
+ console.log(" ⚠ No open merge requests found. Create one to test approval tools.");
112
+ process.exit(0);
113
+ }
114
+ mrIid = mrs[0].iid;
115
+ console.log(` ✓ Found MR !${mrIid}: ${mrs[0].title}\n`);
116
+ }
117
+ // Test get_merge_request_approval_state
118
+ console.log("3. Testing get_merge_request_approval_state...");
119
+ const approvalStateResponse = await sendMcpRequest(serverProcess, "tools/call", {
120
+ name: "get_merge_request_approval_state",
121
+ arguments: {
122
+ project_id: GITLAB_PROJECT_ID,
123
+ merge_request_iid: mrIid,
124
+ },
125
+ });
126
+ if (approvalStateResponse.error) {
127
+ console.error(" ✗ Error:", approvalStateResponse.error.message);
128
+ }
129
+ else {
130
+ const state = JSON.parse(approvalStateResponse.result?.content?.[0]?.text || "{}");
131
+ console.log(" ✓ Got approval state");
132
+ console.log(` Rules: ${state.rules?.length || 0}`);
133
+ // Show details for each rule
134
+ for (const rule of state.rules || []) {
135
+ const approvedBy = rule.approved_by || [];
136
+ const approvedNames = approvedBy.map((u) => u.name).join(", ") || "none";
137
+ const status = rule.approved ? "✓ APPROVED" : "○ pending";
138
+ console.log(`\n Rule: "${rule.name}"`);
139
+ console.log(` Status: ${status}`);
140
+ console.log(` Required: ${rule.approvals_required} approval(s)`);
141
+ console.log(` Approved by: ${approvedNames} (${approvedBy.length}/${rule.approvals_required})`);
142
+ }
143
+ console.log();
144
+ }
145
+ // Test approve_merge_request
146
+ console.log("4. Testing approve_merge_request...");
147
+ const approveResponse = await sendMcpRequest(serverProcess, "tools/call", {
148
+ name: "approve_merge_request",
149
+ arguments: {
150
+ project_id: GITLAB_PROJECT_ID,
151
+ merge_request_iid: mrIid,
152
+ },
153
+ });
154
+ if (approveResponse.error) {
155
+ console.log(" ✗ Approve error:", approveResponse.error);
156
+ }
157
+ else {
158
+ console.log(" ✓ Approved successfully");
159
+ }
160
+ // Wait 3 seconds before unapproving
161
+ console.log("\n Waiting 3 seconds...");
162
+ await new Promise(resolve => setTimeout(resolve, 3000));
163
+ // Test unapprove_merge_request
164
+ console.log("\n5. Testing unapprove_merge_request...");
165
+ const unapproveResponse = await sendMcpRequest(serverProcess, "tools/call", {
166
+ name: "unapprove_merge_request",
167
+ arguments: {
168
+ project_id: GITLAB_PROJECT_ID,
169
+ merge_request_iid: mrIid,
170
+ },
171
+ });
172
+ if (unapproveResponse.error) {
173
+ console.log(" ✗ Unapprove error:", unapproveResponse.error);
174
+ }
175
+ else {
176
+ console.log(" ✓ Unapproved successfully");
177
+ }
178
+ console.log("\n=== Tests Complete ===");
179
+ }
180
+ finally {
181
+ serverProcess.kill();
182
+ }
183
+ }
184
+ runTests().catch(error => {
185
+ console.error("Test failed:", error);
186
+ process.exit(1);
187
+ });
@@ -0,0 +1,132 @@
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 get_merge_request_diffs
9
+ async function callGetMergeRequestDiffs(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: "get_merge_request_diffs", arguments: args }
58
+ }) + '\n');
59
+ });
60
+ }
61
+ describe('get_merge_request_diffs with excluded_file_patterns', () => {
62
+ let mockGitLab;
63
+ let mockGitLabUrl;
64
+ before(async () => {
65
+ const mockPort = await findMockServerPort(9100);
66
+ mockGitLab = new MockGitLabServer({
67
+ port: mockPort,
68
+ validTokens: [MOCK_TOKEN]
69
+ });
70
+ await mockGitLab.start();
71
+ mockGitLabUrl = mockGitLab.getUrl();
72
+ });
73
+ after(async () => {
74
+ await mockGitLab.stop();
75
+ });
76
+ test('returns all diffs without filtering', async () => {
77
+ const diffs = await callGetMergeRequestDiffs({ project_id: TEST_PROJECT_ID, merge_request_iid: TEST_MR_IID }, {
78
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
79
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
80
+ });
81
+ assert.ok(Array.isArray(diffs), 'Response should be an array');
82
+ assert.strictEqual(diffs.length, 4, 'Should return 4 diffs');
83
+ assert.strictEqual(diffs[0].new_path, 'src/index.ts');
84
+ assert.strictEqual(diffs[1].new_path, 'vendor/package/file.js');
85
+ assert.strictEqual(diffs[2].new_path, 'README.md');
86
+ assert.strictEqual(diffs[3].new_path, 'package-lock.json');
87
+ });
88
+ test('filters out vendor folder with ^vendor/ pattern', async () => {
89
+ const diffs = await callGetMergeRequestDiffs({
90
+ project_id: TEST_PROJECT_ID,
91
+ merge_request_iid: TEST_MR_IID,
92
+ excluded_file_patterns: ['^vendor/']
93
+ }, {
94
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
95
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
96
+ });
97
+ assert.ok(Array.isArray(diffs), 'Response should be an array');
98
+ assert.strictEqual(diffs.length, 3, 'Should return 3 diffs (vendor filtered out)');
99
+ assert.strictEqual(diffs[0].new_path, 'src/index.ts');
100
+ assert.strictEqual(diffs[1].new_path, 'README.md');
101
+ assert.strictEqual(diffs[2].new_path, 'package-lock.json');
102
+ });
103
+ test('filters out package-lock.json with package-lock pattern', async () => {
104
+ const diffs = await callGetMergeRequestDiffs({
105
+ project_id: TEST_PROJECT_ID,
106
+ merge_request_iid: TEST_MR_IID,
107
+ excluded_file_patterns: ['package-lock\\.json']
108
+ }, {
109
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
110
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
111
+ });
112
+ assert.ok(Array.isArray(diffs), 'Response should be an array');
113
+ assert.strictEqual(diffs.length, 3, 'Should return 3 diffs (package-lock.json filtered out)');
114
+ assert.strictEqual(diffs[0].new_path, 'src/index.ts');
115
+ assert.strictEqual(diffs[1].new_path, 'vendor/package/file.js');
116
+ assert.strictEqual(diffs[2].new_path, 'README.md');
117
+ });
118
+ test('filters multiple patterns at once', async () => {
119
+ const diffs = await callGetMergeRequestDiffs({
120
+ project_id: TEST_PROJECT_ID,
121
+ merge_request_iid: TEST_MR_IID,
122
+ excluded_file_patterns: ['^vendor/', 'package-lock\\.json']
123
+ }, {
124
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
125
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
126
+ });
127
+ assert.ok(Array.isArray(diffs), 'Response should be an array');
128
+ assert.strictEqual(diffs.length, 2, 'Should return 2 diffs (vendor and package-lock filtered out)');
129
+ assert.strictEqual(diffs[0].new_path, 'src/index.ts');
130
+ assert.strictEqual(diffs[1].new_path, 'README.md');
131
+ });
132
+ });