@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.
@@ -0,0 +1,144 @@
1
+ import { describe, test, before, after } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { spawn } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
6
+ const MOCK_TOKEN = 'glpat-mock-token-12345';
7
+ const TEST_PROJECT_ID = '123';
8
+ const TEST_SECRET = 'testsecret123';
9
+ // Minimum valid 1x1 transparent PNG
10
+ const MINIMAL_PNG_BUF = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
11
+ // Unique suffix per test run to avoid conflicts on concurrent executions
12
+ const RUN_ID = Math.random().toString(36).slice(2, 8);
13
+ /**
14
+ * Spawn build/index.js, send a single download_attachment JSON-RPC call, and
15
+ * return the raw parsed JSON-RPC response (either {result:...} or {error:...}).
16
+ */
17
+ function callDownloadAttachment(args, env, timeoutMs = 15_000) {
18
+ return new Promise((resolve, reject) => {
19
+ const proc = spawn('node', ['build/index.js'], {
20
+ stdio: ['pipe', 'pipe', 'pipe'],
21
+ env: { ...process.env, ...env, GITLAB_READ_ONLY_MODE: 'true' },
22
+ });
23
+ const timer = setTimeout(() => {
24
+ proc.kill();
25
+ reject(new Error(`Process timed out after ${timeoutMs}ms`));
26
+ }, timeoutMs);
27
+ let stdout = '';
28
+ let stderr = '';
29
+ proc.stdout?.on('data', (d) => (stdout += d.toString()));
30
+ proc.stderr?.on('data', (d) => (stderr += d.toString()));
31
+ proc.on('error', (err) => {
32
+ clearTimeout(timer);
33
+ reject(new Error(`Failed to spawn process: ${err.message}`));
34
+ });
35
+ proc.on('close', () => {
36
+ clearTimeout(timer);
37
+ // Find the JSON-RPC response line matching our request id
38
+ const lines = stdout.split('\n').filter(l => l.trim().startsWith('{'));
39
+ for (const line of lines) {
40
+ try {
41
+ const parsed = JSON.parse(line);
42
+ if (parsed.id === 1) {
43
+ resolve(parsed);
44
+ return;
45
+ }
46
+ }
47
+ catch { /* try next line */ }
48
+ }
49
+ reject(new Error(`No matching JSON-RPC response found.\nstderr: ${stderr}`));
50
+ });
51
+ proc.stdin?.end(JSON.stringify({
52
+ jsonrpc: '2.0',
53
+ id: 1,
54
+ method: 'tools/call',
55
+ params: { name: 'download_attachment', arguments: args },
56
+ }) + '\n');
57
+ });
58
+ }
59
+ describe('download_attachment', () => {
60
+ let mockGitLab;
61
+ let env;
62
+ before(async () => {
63
+ const port = await findMockServerPort(9100);
64
+ mockGitLab = new MockGitLabServer({ port, validTokens: [MOCK_TOKEN] });
65
+ await mockGitLab.start();
66
+ env = {
67
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
68
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
69
+ };
70
+ // PNG upload endpoint
71
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/image.png`, (_req, res) => { res.set('Content-Type', 'image/png').send(MINIMAL_PNG_BUF); });
72
+ // Plain-text upload endpoint
73
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/document.txt`, (_req, res) => { res.set('Content-Type', 'text/plain').send('hello world'); });
74
+ });
75
+ after(async () => {
76
+ await mockGitLab.stop();
77
+ });
78
+ test('image file without local_path returns base64 image content block', async () => {
79
+ const raw = await callDownloadAttachment({ project_id: TEST_PROJECT_ID, secret: TEST_SECRET, filename: 'image.png' }, env);
80
+ const content = raw.result?.content;
81
+ assert.ok(Array.isArray(content), 'result.content should be an array');
82
+ const imageBlock = content.find(c => c.type === 'image');
83
+ assert.ok(imageBlock, 'Should contain an image content block');
84
+ assert.strictEqual(imageBlock.mimeType, 'image/png', 'mimeType should be image/png');
85
+ assert.ok(typeof imageBlock.data === 'string' && imageBlock.data.length > 0, 'Image block should have non-empty base64 data');
86
+ });
87
+ test('non-image file is saved to disk and returns file_path', async () => {
88
+ const raw = await callDownloadAttachment({ project_id: TEST_PROJECT_ID, secret: TEST_SECRET, filename: 'document.txt' }, env);
89
+ const text = raw.result?.content?.[0]?.text;
90
+ assert.ok(text, 'Should have text content');
91
+ const parsed = JSON.parse(text);
92
+ try {
93
+ assert.strictEqual(parsed.success, true, 'success should be true');
94
+ assert.ok(typeof parsed.file_path === 'string', 'file_path should be a string');
95
+ assert.ok(parsed.file_path.endsWith('document.txt'), 'file_path should end with document.txt');
96
+ }
97
+ finally {
98
+ if (parsed.file_path && fs.existsSync(parsed.file_path)) {
99
+ fs.unlinkSync(parsed.file_path);
100
+ }
101
+ }
102
+ });
103
+ test('image file with local_path is saved to disk and returns file_path', async () => {
104
+ // Must be a relative path – the implementation rejects absolute paths as traversal
105
+ const localPath = `omc-test-save-${RUN_ID}`;
106
+ try {
107
+ const raw = await callDownloadAttachment({ project_id: TEST_PROJECT_ID, secret: TEST_SECRET, filename: 'image.png', local_path: localPath }, env);
108
+ const text = raw.result?.content?.[0]?.text;
109
+ assert.ok(text, 'Should have text content');
110
+ const parsed = JSON.parse(text);
111
+ assert.strictEqual(parsed.success, true, 'success should be true');
112
+ assert.ok(typeof parsed.file_path === 'string', 'file_path should be a string');
113
+ assert.ok(parsed.file_path.includes('image.png'), 'file_path should include image.png');
114
+ }
115
+ finally {
116
+ fs.rmSync(localPath, { recursive: true, force: true });
117
+ }
118
+ });
119
+ test('local_path with ".." returns path traversal error', async () => {
120
+ const raw = await callDownloadAttachment({ project_id: TEST_PROJECT_ID, secret: TEST_SECRET, filename: 'image.png', local_path: '../../../tmp' }, env);
121
+ // MCP SDK may return a JSON-RPC error or an isError content block; both must mention "traversal"
122
+ const isRpcError = typeof raw.error?.message === 'string' &&
123
+ raw.error.message.toLowerCase().includes('traversal');
124
+ const isContentError = Array.isArray(raw.result?.content) &&
125
+ raw.result.content.some(c => typeof c.text === 'string' && c.text.toLowerCase().includes('traversal'));
126
+ assert.ok(isRpcError || isContentError, 'Should return an error mentioning directory traversal');
127
+ });
128
+ test('non-existent local_path directory is auto-created before saving', async () => {
129
+ const baseDir = `omc-test-newdir-${RUN_ID}`;
130
+ const localPath = `${baseDir}/subdir`;
131
+ fs.rmSync(baseDir, { recursive: true, force: true });
132
+ try {
133
+ const raw = await callDownloadAttachment({ project_id: TEST_PROJECT_ID, secret: TEST_SECRET, filename: 'document.txt', local_path: localPath }, env);
134
+ const text = raw.result?.content?.[0]?.text;
135
+ assert.ok(text, 'Should have text content');
136
+ const parsed = JSON.parse(text);
137
+ assert.strictEqual(parsed.success, true, 'success should be true');
138
+ assert.ok(fs.existsSync(parsed.file_path), 'Saved file should exist on disk');
139
+ }
140
+ finally {
141
+ fs.rmSync(baseDir, { recursive: true, force: true });
142
+ }
143
+ });
144
+ });
@@ -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
+ });
@@ -0,0 +1,171 @@
1
+ import { after, before, describe, test } 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-approval";
6
+ const TEST_PROJECT_ID = "123";
7
+ const TEST_MR_IID_WITH_FALLBACK = "88";
8
+ const TEST_MR_IID_WITH_APPROVAL_STATE = "89";
9
+ async function callTool(toolName, 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
+ USE_PIPELINE: "true",
18
+ },
19
+ });
20
+ let output = "";
21
+ let errorOutput = "";
22
+ proc.stdout?.on("data", (d) => (output += d));
23
+ proc.stderr?.on("data", (d) => (errorOutput += d));
24
+ proc.on("close", (code) => {
25
+ if (code !== 0) {
26
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
27
+ }
28
+ const line = output.split("\n").find(l => l.startsWith("{"));
29
+ if (!line) {
30
+ return reject(new Error("No JSON output found"));
31
+ }
32
+ try {
33
+ const response = JSON.parse(line);
34
+ if (response.error) {
35
+ reject(response.error);
36
+ }
37
+ else {
38
+ const content = response.result?.content?.[0]?.text;
39
+ if (content) {
40
+ try {
41
+ resolve(JSON.parse(content));
42
+ }
43
+ catch {
44
+ resolve(content);
45
+ }
46
+ }
47
+ else {
48
+ resolve(response.result);
49
+ }
50
+ }
51
+ }
52
+ catch (error) {
53
+ reject(error);
54
+ }
55
+ });
56
+ proc.stdin?.end(JSON.stringify({
57
+ jsonrpc: "2.0",
58
+ id: 1,
59
+ method: "tools/call",
60
+ params: { name: toolName, arguments: args },
61
+ }) + "\n");
62
+ });
63
+ }
64
+ describe("merge request approval state tools", () => {
65
+ let mockGitLab;
66
+ let mockGitLabUrl;
67
+ before(async () => {
68
+ const mockPort = await findMockServerPort(9400);
69
+ mockGitLab = new MockGitLabServer({
70
+ port: mockPort,
71
+ validTokens: [MOCK_TOKEN],
72
+ });
73
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/merge_requests/${TEST_MR_IID_WITH_FALLBACK}/approval_state`, (_req, res) => {
74
+ res.status(404).json({ error: "404 Not Found" });
75
+ });
76
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/merge_requests/${TEST_MR_IID_WITH_FALLBACK}/approvals`, (_req, res) => {
77
+ res.json({
78
+ approved: true,
79
+ user_has_approved: false,
80
+ user_can_approve: true,
81
+ approved_by: [
82
+ {
83
+ user: {
84
+ id: "35",
85
+ username: "sergey.kravchenya",
86
+ name: "Sergey Kravchenya",
87
+ state: "active",
88
+ avatar_url: "https://gitlab.mock/uploads/avatar.png",
89
+ web_url: "https://gitlab.mock/sergey.kravchenya",
90
+ },
91
+ },
92
+ ],
93
+ });
94
+ });
95
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/merge_requests/${TEST_MR_IID_WITH_APPROVAL_STATE}/approval_state`, (_req, res) => {
96
+ res.json({
97
+ approval_rules_overwritten: false,
98
+ rules: [
99
+ {
100
+ id: "101",
101
+ name: "Default rule",
102
+ rule_type: "regular",
103
+ approvals_required: 1,
104
+ approved: true,
105
+ approved_by: [
106
+ {
107
+ id: "35",
108
+ username: "sergey.kravchenya",
109
+ name: "Sergey Kravchenya",
110
+ state: "active",
111
+ avatar_url: "https://gitlab.mock/uploads/avatar.png",
112
+ web_url: "https://gitlab.mock/sergey.kravchenya",
113
+ },
114
+ ],
115
+ },
116
+ {
117
+ id: "102",
118
+ name: "Code owners",
119
+ rule_type: "code_owner",
120
+ approvals_required: 1,
121
+ approved: true,
122
+ approved_by: [
123
+ {
124
+ id: "35",
125
+ username: "sergey.kravchenya",
126
+ name: "Sergey Kravchenya",
127
+ state: "active",
128
+ avatar_url: "https://gitlab.mock/uploads/avatar.png",
129
+ web_url: "https://gitlab.mock/sergey.kravchenya",
130
+ },
131
+ ],
132
+ },
133
+ ],
134
+ });
135
+ });
136
+ await mockGitLab.start();
137
+ mockGitLabUrl = mockGitLab.getUrl();
138
+ });
139
+ after(async () => {
140
+ await mockGitLab.stop();
141
+ });
142
+ test("falls back to approvals endpoint when approval_state is unavailable", async () => {
143
+ const result = await callTool("get_merge_request_approval_state", {
144
+ project_id: TEST_PROJECT_ID,
145
+ merge_request_iid: TEST_MR_IID_WITH_FALLBACK,
146
+ }, {
147
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
148
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
149
+ });
150
+ assert.strictEqual(result.source_endpoint, "approvals");
151
+ assert.strictEqual(result.approved, true);
152
+ assert.deepStrictEqual(result.approved_by_usernames, ["sergey.kravchenya"]);
153
+ assert.ok(Array.isArray(result.approved_by));
154
+ assert.strictEqual(result.approved_by[0].username, "sergey.kravchenya");
155
+ });
156
+ test("returns deduplicated approvers from approval_state rules", async () => {
157
+ const result = await callTool("get_merge_request_approval_state", {
158
+ project_id: TEST_PROJECT_ID,
159
+ merge_request_iid: TEST_MR_IID_WITH_APPROVAL_STATE,
160
+ }, {
161
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
162
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
163
+ });
164
+ assert.strictEqual(result.source_endpoint, "approval_state");
165
+ assert.strictEqual(result.rules.length, 2);
166
+ assert.deepStrictEqual(result.approved_by_usernames, ["sergey.kravchenya"]);
167
+ assert.ok(Array.isArray(result.approved_by));
168
+ assert.strictEqual(result.approved_by.length, 1);
169
+ assert.strictEqual(result.approved_by[0].username, "sergey.kravchenya");
170
+ });
171
+ });